mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
* perf(web): run the renderer zoneless and drop the zone.js polyfill Plan item C6 step 5. app.config.ts provides provideZonelessChangeDetection() directly and apps/web builds without the zone.js polyfill, so a tick runs only when a signal, markForCheck, an Angular event or a render hook asks for one, not after every macrotask. The flag from #1824 (change-detection.providers*.ts and the *-zoneless configurations) goes away; a build-config test fails if the polyfill or a zone provider comes back. NgZone.run/runOutsideAngular calls that only mattered with zone.js are removed from the dashboard data services, the settings unload guard and the embedded MPV position poll (with the spec case asserting it), and the unused polyfills.ts, polyfills-test.ts and setup-jest.ts are deleted. Unit tests keep zone.js/testing for fakeAsync and waitForAsync; apps/remote-control-web still bootstraps with zone.js. renderer.initialBytes: 1,596,201 -> 1,545,931 bytes (-50,270) on a local production build of this base; the baseline is lowered to the measured value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(web): cite #1929 as the initial-bytes baseline evidence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(web): ratchet the change-detection ticks that zoneless made deterministic On the canonical runner (Performance ratchet run 38359088033, three runs of this branch, five iterations each) renderer.cdTicksToFirstCard read 5, renderer.cdTicksIdle30s 3 and renderer.cdTicksToFirstPage 9 in all 15 iterations, against 31, 1 to 4 and 22 with zone.js. They become baselines with zero slack and the Performance journeys job checks them. J3 and J4 tick counts still vary by one and stay unenforced; J3 wall clock is in master's range, so the local rise measured on #1824 was machine load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(web): allow J1's one-tick lazy-chunk race in the first-card tick baseline After merging #1931 (Angular Material off the startup path) the Performance journeys job read renderer.cdTicksToFirstCard 5, 5, 5, 5, 6: the lazily loaded chunk resolves on either side of a render. All 15 iterations before it read 5. The baseline stays 5 with one tick of slack and a note; a return towards zone.js levels (31) still fails, and the idle and first-page tick baselines stayed exact in the same run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): find a finished programme for the phone dialog after UTC midnight The Xtream mock lays its 30-minute EPG slots out relative to now, and the list view shows the programmes that overlap the selected day. In the first half hour after midnight (the spec pins TZ=UTC) today holds only the programme on air, so 'stacks the programme dialog actions on a phone' found no data-when=past row and failed every retry on #1929's Windows shard (00:18-00:23 UTC). The spec now waits for the list, and steps back a day when today has no finished programme yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(architecture): record the zoneless renderer in the workspace contract Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1279 lines
44 KiB
TypeScript
1279 lines
44 KiB
TypeScript
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
|
import { Store } from '@ngrx/store';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import {
|
|
PlaylistActions,
|
|
selectAllPlaylistsMeta,
|
|
selectPlaylistsLoadingFlag,
|
|
} from '@iptvnator/m3u-state';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import {
|
|
DatabaseService,
|
|
GlobalRecentlyAddedKind,
|
|
PlaylistsService,
|
|
RuntimeCapabilitiesService,
|
|
injectTranslationTick,
|
|
} from '@iptvnator/services';
|
|
import {
|
|
XTREAM_DATA_SOURCE,
|
|
XtreamContentItem,
|
|
} from '@iptvnator/portal/xtream/data-access';
|
|
import {
|
|
buildPlaylistRecentItems,
|
|
Channel,
|
|
M3uFavoriteChannel,
|
|
Playlist,
|
|
PortalAddedItem,
|
|
PortalActivityItem,
|
|
PlaylistMeta,
|
|
PortalActivityType,
|
|
PortalFavoriteItem,
|
|
PortalRecentItem,
|
|
resolvePortalActivityWatchKind,
|
|
stalkerItemMatchesId,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import {
|
|
buildStalkerFavoriteItems,
|
|
mapDbFavoriteToItem,
|
|
mapDbRecentlyAddedToItem,
|
|
mapDbRecentToItem,
|
|
toDateTimestamp,
|
|
toTimestamp,
|
|
} from './dashboard-mappers';
|
|
import {
|
|
PORTAL_PLAYBACK_POSITIONS,
|
|
WorkspaceNavigationTarget,
|
|
} from '@iptvnator/portal/shared/util';
|
|
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
|
import {
|
|
getGlobalFavoriteLink as getGlobalFavoriteLinkUtil,
|
|
getGlobalFavoriteNavigationState as getGlobalFavoriteNavigationStateUtil,
|
|
getPlaylistLink as getPlaylistLinkUtil,
|
|
getRecentItemDetailNavigationState as getRecentItemDetailNavigationStateUtil,
|
|
getRecentItemLink as getRecentItemLinkUtil,
|
|
getRecentItemNavigationState as getRecentItemNavigationStateUtil,
|
|
getRecentItemResumeNavigation as getRecentItemResumeNavigationUtil,
|
|
getRecentlyAddedLink as getRecentlyAddedLinkUtil,
|
|
getRecentlyAddedNavigationState as getRecentlyAddedNavigationStateUtil,
|
|
isTypeInKind as isTypeInKindUtil,
|
|
type DashboardContentKind,
|
|
} from './dashboard-navigation.util';
|
|
import { createDashboardContinueWatching } from './dashboard-continue-watching';
|
|
import {
|
|
emptyDashboardPlaybackPositionMaps,
|
|
loadDashboardPlaybackPositions,
|
|
newestPlaybackPosition,
|
|
playbackPositionMapKey,
|
|
seriesPlaybackPositionMapKey,
|
|
} from './dashboard-playback-positions';
|
|
|
|
export type { DashboardContentKind };
|
|
|
|
/** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */
|
|
export type GlobalRecentItem = PortalRecentItem;
|
|
/** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */
|
|
export type DashboardFavoriteItem = PortalFavoriteItem;
|
|
/** @deprecated Use {@link PortalAddedItem} from `@iptvnator/shared/interfaces` instead. */
|
|
export type DashboardRecentlyAddedItem = PortalAddedItem;
|
|
export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class DashboardDataService {
|
|
private readonly store = inject(Store);
|
|
private readonly dbService = inject(DatabaseService);
|
|
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
|
|
private readonly playlistsService = inject(PlaylistsService);
|
|
private readonly runtime = inject(RuntimeCapabilitiesService);
|
|
private readonly translate = inject(TranslateService);
|
|
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
|
private readonly favoritesAutoRefreshEnabled = signal(false);
|
|
private readonly languageTick = injectTranslationTick();
|
|
|
|
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
|
|
private readonly xtreamRecentlyAddedItemsState = signal<
|
|
DashboardRecentlyAddedItem[]
|
|
>([]);
|
|
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
|
|
[]
|
|
);
|
|
/**
|
|
* Per-M3U-playlist favorites contributions, keyed by playlist ID.
|
|
* Each playlist's contribution lands in the map as soon as ITS load
|
|
* resolves, so the favorites rail flips on a playlist at a time
|
|
* instead of waiting for the slowest of a Promise.all.
|
|
*/
|
|
private readonly m3uPlaylistFavoritesMap = signal<
|
|
Map<string, DashboardFavoriteItem[]>
|
|
>(new Map());
|
|
|
|
/**
|
|
* Memoized parsed favorites per playlist. The fingerprint includes the
|
|
* favorites JSON and the playlist's update timestamp so a cache hit
|
|
* returns instantly and a refresh / favorites change naturally
|
|
* invalidates without explicit busting. Avoids re-parsing the entire
|
|
* (potentially 90K-channel) playlist payload on repeated dashboard
|
|
* mounts.
|
|
*/
|
|
private readonly m3uFavoritesCache = new Map<
|
|
string,
|
|
{ fingerprint: string; items: DashboardFavoriteItem[] }
|
|
>();
|
|
|
|
private readonly m3uGlobalFavorites = computed<DashboardFavoriteItem[]>(
|
|
() => {
|
|
const map = this.m3uPlaylistFavoritesMap();
|
|
const all: DashboardFavoriteItem[] = [];
|
|
map.forEach((items) => {
|
|
for (const item of items) {
|
|
all.push(item);
|
|
}
|
|
});
|
|
return all;
|
|
}
|
|
);
|
|
private readonly globalRecentLoadingState = signal(true);
|
|
private readonly globalRecentLoadedState = signal(false);
|
|
// The portal history read, from SQLite or (PWA) the Xtream data source;
|
|
// the history is loaded once it and the playlist inventory are in.
|
|
private readonly globalRecentDbLoadedState = signal(false);
|
|
private readonly globalFavoritesLoadingState = signal(true);
|
|
private readonly globalFavoritesLoadedState = signal(false);
|
|
private readonly xtreamGlobalFavoritesLoadedState = signal(false);
|
|
private readonly playlistBackedGlobalFavoritesLoadedState = signal(false);
|
|
private readonly xtreamRecentlyAddedLoadingState = signal(true);
|
|
private readonly xtreamRecentlyAddedLoadedState = signal(false);
|
|
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
|
readonly playlistsLoaded = this.store.selectSignal(
|
|
selectPlaylistsLoadingFlag
|
|
);
|
|
readonly xtreamPlaylistCount = computed(
|
|
() => this.playlists().filter((playlist) => !!playlist.serverUrl).length
|
|
);
|
|
readonly hasXtreamPlaylists = computed(
|
|
() => this.xtreamPlaylistCount() > 0
|
|
);
|
|
readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly();
|
|
readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly();
|
|
readonly globalFavoritesLoading =
|
|
this.globalFavoritesLoadingState.asReadonly();
|
|
readonly globalFavoritesLoaded =
|
|
this.globalFavoritesLoadedState.asReadonly();
|
|
readonly xtreamRecentlyAddedLoading =
|
|
this.xtreamRecentlyAddedLoadingState.asReadonly();
|
|
readonly xtreamRecentlyAddedLoaded =
|
|
this.xtreamRecentlyAddedLoadedState.asReadonly();
|
|
readonly xtreamRecentlyAddedItems =
|
|
this.xtreamRecentlyAddedItemsState.asReadonly();
|
|
|
|
private get hasPortalActivityStorage(): boolean {
|
|
return this.runtime.supportsPortalActivityStorage;
|
|
}
|
|
|
|
readonly dashboardReady = computed(
|
|
() =>
|
|
this.playlistsLoaded() &&
|
|
this.globalRecentLoaded() &&
|
|
this.globalFavoritesLoaded() &&
|
|
(!this.hasXtreamPlaylists() || this.xtreamRecentlyAddedLoaded())
|
|
);
|
|
|
|
private readonly playlistFavoritesReloadKey = computed(() => {
|
|
if (!this.playlistsLoaded()) {
|
|
return null;
|
|
}
|
|
|
|
return this.playlists()
|
|
.map((playlist) =>
|
|
[
|
|
playlist._id,
|
|
playlist.serverUrl
|
|
? 'xtream'
|
|
: playlist.macAddress
|
|
? 'stalker'
|
|
: 'm3u',
|
|
JSON.stringify(playlist.favorites ?? []),
|
|
].join('::')
|
|
)
|
|
.join('|');
|
|
});
|
|
|
|
readonly playlistBackedGlobalRecentItems = computed<GlobalRecentItem[]>(
|
|
() => {
|
|
this.languageTick();
|
|
return buildPlaylistRecentItems(this.playlists(), {
|
|
stalker: this.translateText(
|
|
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
|
|
),
|
|
m3u: this.translateText('WORKSPACE.DASHBOARD.M3U'),
|
|
});
|
|
}
|
|
);
|
|
|
|
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
|
|
[
|
|
...this.xtreamGlobalRecentItems(),
|
|
...this.playlistBackedGlobalRecentItems(),
|
|
]
|
|
.sort(
|
|
(a, b) =>
|
|
toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at)
|
|
)
|
|
.slice(0, 200)
|
|
);
|
|
|
|
// Split the recent list by content type so the dashboard can render
|
|
// movies/series ("Continue watching") separately from live channels
|
|
// ("Live now on your favorites"). Two card formats — one rail each.
|
|
readonly globalRecentVodItems = computed<GlobalRecentItem[]>(() =>
|
|
this.globalRecentItems().filter(
|
|
(item) => item.type === 'movie' || item.type === 'series'
|
|
)
|
|
);
|
|
|
|
readonly globalRecentLiveItems = computed<GlobalRecentItem[]>(() =>
|
|
this.globalRecentItems().filter((item) => item.type === 'live')
|
|
);
|
|
|
|
// Favorited live channels — the dashboard's "Live now" rail uses this
|
|
// source only. Recently watched live channels have their own dashboard rail.
|
|
readonly globalFavoriteLiveItems = computed<DashboardFavoriteItem[]>(() =>
|
|
this.globalFavoriteItems().filter((item) => item.type === 'live')
|
|
);
|
|
|
|
// Playback positions, keyed by `${playlist_id}::${contentXtreamId}::${type}`.
|
|
// Loaded lazily on dashboard mount (and refreshed when recent items
|
|
// change) so the hero + Continue Watching cards can show "X min left"
|
|
// and a progress bar. Live channels never have positions; M3U content
|
|
// doesn't either. Map starts empty and degrades cleanly on missing data.
|
|
private readonly playbackPositionMaps = signal(
|
|
emptyDashboardPlaybackPositionMaps()
|
|
);
|
|
|
|
readonly playbackPositions$ = computed(
|
|
() => this.playbackPositionMaps().byContent
|
|
);
|
|
|
|
/** Playlists whose rows the position maps hold; null before any load. */
|
|
private readonly playbackPositionPlaylistIds =
|
|
signal<ReadonlySet<string> | null>(null);
|
|
private playbackPositionsLoadGeneration = 0;
|
|
/**
|
|
* Counts the position reloads that landed (dashboard entry, a changed
|
|
* history, "Mark watched"): each is a lookup round, which retries failed
|
|
* episode lookups and refreshes lists past their age.
|
|
*/
|
|
private readonly playbackPositionsReloads = signal(0);
|
|
|
|
/** The Continue Watching rail's list and loading gate. */
|
|
private readonly continueWatching = createDashboardContinueWatching({
|
|
items: this.globalRecentVodItems,
|
|
historyLoaded: this.globalRecentLoaded,
|
|
playlists: this.playlists,
|
|
storedPosition: (item) => this.storedPlaybackPositionForItem(item),
|
|
episodeRows: (playlistId, seriesXtreamId) =>
|
|
this.playbackPositionMaps().episodesBySeries.get(
|
|
seriesPlaybackPositionMapKey(playlistId, seriesXtreamId)
|
|
),
|
|
positionsLoadedFor: this.playbackPositionPlaylistIds,
|
|
positionReloads: this.playbackPositionsReloads,
|
|
});
|
|
|
|
readonly continueWatchingSettled = this.continueWatching.settled;
|
|
readonly continueWatchingItems = this.continueWatching.items;
|
|
|
|
/**
|
|
* The position a dashboard card shows and resumes: the stored one, or
|
|
* for a series whose newest episode is watched or an extra, the episode
|
|
* it continues with (a not-started next episode begins at 0:00).
|
|
*/
|
|
getPlaybackPositionForItem(
|
|
item: PortalActivityItem
|
|
): PlaybackPositionData | null {
|
|
const continuation = this.continueWatching.continuationFor(item);
|
|
return continuation?.kind === 'continue'
|
|
? continuation.position
|
|
: this.storedPlaybackPositionForItem(item);
|
|
}
|
|
|
|
private storedPlaybackPositionForItem(
|
|
item: PortalActivityItem
|
|
): PlaybackPositionData | null {
|
|
// The progress model, not the routing type: a Stalker embedded-VOD
|
|
// row routes as a movie but tracks episodes under its parent id,
|
|
// and a `vod` lookup for it finds nothing.
|
|
const watchKind = resolvePortalActivityWatchKind(item);
|
|
if (!watchKind) {
|
|
return null;
|
|
}
|
|
const xtreamId =
|
|
typeof item.xtream_id === 'number'
|
|
? item.xtream_id
|
|
: Number(item.xtream_id);
|
|
if (!Number.isFinite(xtreamId)) {
|
|
return null;
|
|
}
|
|
|
|
if (watchKind === 'movie') {
|
|
const key = playbackPositionMapKey(
|
|
item.playlist_id,
|
|
xtreamId,
|
|
'vod'
|
|
);
|
|
return this.playbackPositionMaps().byContent.get(key) ?? null;
|
|
}
|
|
|
|
// Series recent_items rows carry either the series id (the series
|
|
// landing page writes the series itself) or, for direct-play flows,
|
|
// the episode id. Match both shapes through keyed maps so card renders
|
|
// do not scan every saved playback position.
|
|
const episodePosition =
|
|
this.playbackPositionMaps().byContent.get(
|
|
playbackPositionMapKey(item.playlist_id, xtreamId, 'episode')
|
|
) ?? null;
|
|
const seriesPosition =
|
|
this.playbackPositionMaps().newestBySeries.get(
|
|
seriesPlaybackPositionMapKey(item.playlist_id, xtreamId)
|
|
) ?? null;
|
|
return newestPlaybackPosition(episodePosition, seriesPosition);
|
|
}
|
|
|
|
/**
|
|
* Refresh the in-memory positions for every playlist that owns at least
|
|
* one VOD/series recent item (`loadDashboardPlaybackPositions`: one bulk
|
|
* IPC per playlist, all at once). Continue Watching and the hero wait
|
|
* for this set before they render.
|
|
*/
|
|
async reloadPlaybackPositions(): Promise<void> {
|
|
// Latest reload wins: an older one finishing last would put back the
|
|
// positions of a smaller history and leave newer titles unsettled.
|
|
const generation = ++this.playbackPositionsLoadGeneration;
|
|
const playlistIds = new Set<string>();
|
|
for (const item of this.globalRecentItems()) {
|
|
if (item.type === 'movie' || item.type === 'series') {
|
|
playlistIds.add(item.playlist_id);
|
|
}
|
|
}
|
|
const maps =
|
|
playlistIds.size === 0
|
|
? emptyDashboardPlaybackPositionMaps()
|
|
: await loadDashboardPlaybackPositions(
|
|
this.playbackPositions,
|
|
playlistIds
|
|
);
|
|
|
|
if (generation !== this.playbackPositionsLoadGeneration) {
|
|
return;
|
|
}
|
|
this.playbackPositionMaps.set(maps);
|
|
// A playlist whose load failed counts as loaded: its titles
|
|
// show without progress rather than holding the rail back.
|
|
this.playbackPositionPlaylistIds.set(playlistIds);
|
|
this.playbackPositionsReloads.update((count) => count + 1);
|
|
}
|
|
|
|
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
|
|
this.languageTick();
|
|
return buildStalkerFavoriteItems(
|
|
this.playlists(),
|
|
this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL')
|
|
);
|
|
});
|
|
|
|
readonly globalFavoriteItems = computed(() =>
|
|
[
|
|
...this.xtreamGlobalFavorites(),
|
|
...this.m3uGlobalFavorites(),
|
|
...this.stalkerGlobalFavorites(),
|
|
]
|
|
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at))
|
|
.slice(0, 200)
|
|
);
|
|
|
|
private readonly recentPlaylistActivityTimestamps = computed(() => {
|
|
const timestamps = new Map<string, number>();
|
|
|
|
for (const item of this.globalRecentItems()) {
|
|
const viewedAt = toDateTimestamp(item.viewed_at);
|
|
const current = timestamps.get(item.playlist_id) ?? 0;
|
|
|
|
if (viewedAt > current) {
|
|
timestamps.set(item.playlist_id, viewedAt);
|
|
}
|
|
}
|
|
|
|
return timestamps;
|
|
});
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
this.playlistFavoritesReloadKey();
|
|
if (
|
|
!this.playlistsLoaded() ||
|
|
!this.favoritesAutoRefreshEnabled()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
void this.reloadPlaylistBackedGlobalFavorites();
|
|
});
|
|
|
|
effect(() => {
|
|
this.playlistsLoaded();
|
|
this.finishInitialGlobalRecentLoadIfReady();
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
});
|
|
|
|
// Before the inventory has loaded "no Xtream playlists" is not known
|
|
// yet; settling here would end the first load before it started.
|
|
effect(() => {
|
|
if (!this.playlistsLoaded() || this.hasXtreamPlaylists()) {
|
|
return;
|
|
}
|
|
|
|
this.xtreamRecentlyAddedItemsState.set([]);
|
|
this.xtreamRecentlyAddedLoadingState.set(false);
|
|
this.xtreamRecentlyAddedLoadedState.set(true);
|
|
});
|
|
}
|
|
|
|
readonly stats = computed(() => {
|
|
const items = this.playlists();
|
|
return {
|
|
total: items.length,
|
|
xtream: items.filter((item) => !!item.serverUrl).length,
|
|
stalker: items.filter((item) => !!item.macAddress).length,
|
|
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
|
|
.length,
|
|
};
|
|
});
|
|
|
|
readonly recentPlaylists = computed(() =>
|
|
[...this.playlists()]
|
|
.sort((a, b) => {
|
|
const activityDelta =
|
|
this.getPlaylistActivityTimestamp(b) -
|
|
this.getPlaylistActivityTimestamp(a);
|
|
if (activityDelta !== 0) {
|
|
return activityDelta;
|
|
}
|
|
|
|
const metadataDelta =
|
|
this.getRecentTimestamp(b) - this.getRecentTimestamp(a);
|
|
if (metadataDelta !== 0) {
|
|
return metadataDelta;
|
|
}
|
|
|
|
return a._id.localeCompare(b._id);
|
|
})
|
|
.slice(0, 12)
|
|
);
|
|
|
|
readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4));
|
|
|
|
async reloadGlobalRecentItems(): Promise<void> {
|
|
if (!this.globalRecentLoaded()) {
|
|
this.globalRecentLoadingState.set(true);
|
|
}
|
|
|
|
if (!this.hasPortalActivityStorage) {
|
|
try {
|
|
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
|
|
this.xtreamGlobalRecentItems.set(recentItems);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to reload PWA recent items',
|
|
err
|
|
);
|
|
} finally {
|
|
this.globalRecentDbLoadedState.set(true);
|
|
this.finishInitialGlobalRecentLoadIfReady();
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const recentItems = await this.dbService.getGlobalRecentlyViewed();
|
|
const normalized = recentItems.map((item) =>
|
|
mapDbRecentToItem(item)
|
|
);
|
|
this.xtreamGlobalRecentItems.set(normalized);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to reload global recent items',
|
|
err
|
|
);
|
|
this.xtreamGlobalRecentItems.set([]);
|
|
} finally {
|
|
this.globalRecentDbLoadedState.set(true);
|
|
this.finishInitialGlobalRecentLoadIfReady();
|
|
}
|
|
}
|
|
|
|
async reloadGlobalFavorites(): Promise<void> {
|
|
if (!this.globalFavoritesLoaded()) {
|
|
this.globalFavoritesLoadingState.set(true);
|
|
this.xtreamGlobalFavoritesLoadedState.set(false);
|
|
this.playlistBackedGlobalFavoritesLoadedState.set(false);
|
|
}
|
|
|
|
const xtreamReload = this.reloadXtreamGlobalFavorites().finally(() => {
|
|
this.xtreamGlobalFavoritesLoadedState.set(true);
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
});
|
|
const m3uReload = this.reloadPlaylistBackedGlobalFavorites();
|
|
|
|
await Promise.all([xtreamReload, m3uReload]);
|
|
if (
|
|
this.playlistsLoaded() &&
|
|
!this.playlistBackedGlobalFavoritesLoadedState()
|
|
) {
|
|
await this.reloadPlaylistBackedGlobalFavorites();
|
|
}
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
this.favoritesAutoRefreshEnabled.set(true);
|
|
}
|
|
|
|
async getGlobalRecentlyAddedItems(
|
|
kind: DashboardRecentlyAddedFilterKind,
|
|
limit = 200
|
|
): Promise<DashboardRecentlyAddedItem[]> {
|
|
if (!this.hasPortalActivityStorage) {
|
|
return [];
|
|
}
|
|
|
|
const items = await this.dbService.getGlobalRecentlyAdded(kind, limit);
|
|
return items
|
|
.map((item) => mapDbRecentlyAddedToItem(item))
|
|
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
|
|
}
|
|
|
|
async getXtreamRecentlyAddedItems(
|
|
limit = 20
|
|
): Promise<DashboardRecentlyAddedItem[]> {
|
|
if (!this.hasPortalActivityStorage) {
|
|
return [];
|
|
}
|
|
|
|
const items = await this.dbService.getGlobalRecentlyAdded(
|
|
'all',
|
|
limit,
|
|
'xtream'
|
|
);
|
|
return items
|
|
.map((item) => mapDbRecentlyAddedToItem(item))
|
|
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
|
|
}
|
|
|
|
async reloadXtreamRecentlyAddedItems(limit = 20): Promise<void> {
|
|
if (!this.xtreamRecentlyAddedLoaded()) {
|
|
this.xtreamRecentlyAddedLoadingState.set(true);
|
|
}
|
|
|
|
if (!this.hasPortalActivityStorage) {
|
|
this.xtreamRecentlyAddedItemsState.set([]);
|
|
this.xtreamRecentlyAddedLoadingState.set(false);
|
|
this.xtreamRecentlyAddedLoadedState.set(true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const items = await this.getXtreamRecentlyAddedItems(limit);
|
|
this.xtreamRecentlyAddedItemsState.set(items);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to reload Xtream recently added items',
|
|
err
|
|
);
|
|
this.xtreamRecentlyAddedItemsState.set([]);
|
|
} finally {
|
|
this.xtreamRecentlyAddedLoadingState.set(false);
|
|
this.xtreamRecentlyAddedLoadedState.set(true);
|
|
}
|
|
}
|
|
|
|
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
|
if (!this.hasPortalActivityStorage) {
|
|
const favorites = await this.loadPwaXtreamGlobalFavorites();
|
|
this.xtreamGlobalFavorites.set(favorites);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const favorites = await this.dbService.getAllGlobalFavorites();
|
|
const normalized = favorites.map((item) =>
|
|
mapDbFavoriteToItem(item)
|
|
);
|
|
this.xtreamGlobalFavorites.set(normalized);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to reload global favorites',
|
|
err
|
|
);
|
|
this.xtreamGlobalFavorites.set([]);
|
|
}
|
|
}
|
|
|
|
private async loadPwaXtreamGlobalRecentItems(): Promise<
|
|
GlobalRecentItem[]
|
|
> {
|
|
const nested = await Promise.all(
|
|
this.getXtreamPlaylists().map(async (playlist) => {
|
|
const rows = await this.xtreamDataSource.getRecentItems(
|
|
playlist._id
|
|
);
|
|
return rows.map((item) =>
|
|
this.mapPwaXtreamRecentItem(item, playlist)
|
|
);
|
|
})
|
|
);
|
|
return nested.reduce<GlobalRecentItem[]>(
|
|
(items, playlistItems) => items.concat(playlistItems),
|
|
[]
|
|
);
|
|
}
|
|
|
|
private async loadPwaXtreamGlobalFavorites(): Promise<
|
|
DashboardFavoriteItem[]
|
|
> {
|
|
const nested = await Promise.all(
|
|
this.getXtreamPlaylists().map(async (playlist) => {
|
|
const rows = await this.xtreamDataSource.getFavorites(
|
|
playlist._id
|
|
);
|
|
return rows.map((item) =>
|
|
this.mapPwaXtreamFavoriteItem(item, playlist)
|
|
);
|
|
})
|
|
);
|
|
return nested.reduce<DashboardFavoriteItem[]>(
|
|
(items, playlistItems) => items.concat(playlistItems),
|
|
[]
|
|
);
|
|
}
|
|
|
|
private getXtreamPlaylists(): PlaylistMeta[] {
|
|
return this.playlists().filter(
|
|
(playlist) => !!playlist.serverUrl && !playlist.macAddress
|
|
);
|
|
}
|
|
|
|
private mapPwaXtreamRecentItem(
|
|
item: XtreamContentItem,
|
|
playlist: PlaylistMeta
|
|
): GlobalRecentItem {
|
|
return {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: this.normalizeXtreamActivityType(item.type),
|
|
playlist_id: playlist._id,
|
|
playlist_name: playlist.title || 'Xtream',
|
|
viewed_at: item.viewed_at ?? '',
|
|
category_id: item.category_id,
|
|
xtream_id: item.xtream_id,
|
|
poster_url: item.poster_url,
|
|
backdrop_url: item.backdrop_url ?? undefined,
|
|
source: 'xtream',
|
|
};
|
|
}
|
|
|
|
private mapPwaXtreamFavoriteItem(
|
|
item: XtreamContentItem,
|
|
playlist: PlaylistMeta
|
|
): DashboardFavoriteItem {
|
|
return {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: this.normalizeXtreamActivityType(item.type),
|
|
playlist_id: playlist._id,
|
|
playlist_name: playlist.title || 'Xtream',
|
|
added_at: item.added_at || item.added || new Date(0).toISOString(),
|
|
category_id: item.category_id,
|
|
xtream_id: item.xtream_id,
|
|
poster_url: item.poster_url,
|
|
backdrop_url: item.backdrop_url ?? undefined,
|
|
source: 'xtream',
|
|
};
|
|
}
|
|
|
|
private normalizeXtreamActivityType(type: string): PortalActivityType {
|
|
if (type === 'live' || type === 'series') {
|
|
return type;
|
|
}
|
|
return 'movie';
|
|
}
|
|
|
|
private async reloadPlaylistBackedGlobalFavorites(): Promise<void> {
|
|
const loaded = await this.refreshPlaylistBackedGlobalFavorites();
|
|
if (!loaded) {
|
|
return;
|
|
}
|
|
|
|
this.playlistBackedGlobalFavoritesLoadedState.set(true);
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
}
|
|
|
|
private async refreshPlaylistBackedGlobalFavorites(): Promise<boolean> {
|
|
if (!this.playlistsLoaded()) {
|
|
return false;
|
|
}
|
|
|
|
await this.reloadM3uGlobalFavorites();
|
|
return true;
|
|
}
|
|
|
|
private async reloadM3uGlobalFavorites(): Promise<void> {
|
|
const m3uPlaylists = this.playlists().filter(
|
|
(playlist) =>
|
|
!playlist.serverUrl &&
|
|
!playlist.macAddress &&
|
|
Array.isArray(playlist.favorites) &&
|
|
playlist.favorites.some(
|
|
(favorite): favorite is string =>
|
|
typeof favorite === 'string' &&
|
|
favorite.trim().length > 0
|
|
)
|
|
);
|
|
|
|
const validIds = new Set(m3uPlaylists.map((p) => p._id));
|
|
|
|
// Drop entries for M3U playlists no longer in the eligible set
|
|
// (removed playlists or playlists whose favorites were cleared).
|
|
this.m3uPlaylistFavoritesMap.update((prev) => {
|
|
let next: Map<string, DashboardFavoriteItem[]> | null = null;
|
|
for (const id of prev.keys()) {
|
|
if (!validIds.has(id)) {
|
|
next ??= new Map(prev);
|
|
next.delete(id);
|
|
}
|
|
}
|
|
return next ?? prev;
|
|
});
|
|
for (const id of Array.from(this.m3uFavoritesCache.keys())) {
|
|
if (!validIds.has(id)) {
|
|
this.m3uFavoritesCache.delete(id);
|
|
}
|
|
}
|
|
|
|
if (m3uPlaylists.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Stream per-playlist results into the map as each load resolves.
|
|
// The favorites rail re-renders incrementally — a slow playlist no
|
|
// longer pins the rest behind a Promise.all on the slowest one.
|
|
await Promise.all(
|
|
m3uPlaylists.map(async (playlist) => {
|
|
let items: DashboardFavoriteItem[];
|
|
try {
|
|
items = await this.loadM3uPlaylistFavorites(playlist);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to load M3U favorites for playlist',
|
|
playlist._id,
|
|
err
|
|
);
|
|
items = [];
|
|
}
|
|
|
|
this.m3uPlaylistFavoritesMap.update((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(playlist._id, items);
|
|
return next;
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
private finishInitialGlobalFavoritesLoadIfReady(): void {
|
|
if (
|
|
!this.playlistsLoaded() ||
|
|
!this.xtreamGlobalFavoritesLoadedState() ||
|
|
!this.playlistBackedGlobalFavoritesLoadedState()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.globalFavoritesLoadedState.set(true);
|
|
this.globalFavoritesLoadingState.set(false);
|
|
}
|
|
|
|
private finishInitialGlobalRecentLoadIfReady(): void {
|
|
if (!this.playlistsLoaded() || !this.globalRecentDbLoadedState()) {
|
|
return;
|
|
}
|
|
|
|
this.globalRecentLoadedState.set(true);
|
|
this.globalRecentLoadingState.set(false);
|
|
}
|
|
|
|
isTypeInKind(
|
|
type: PortalActivityType,
|
|
kind: DashboardContentKind
|
|
): boolean {
|
|
return isTypeInKindUtil(type, kind);
|
|
}
|
|
|
|
getPlaylistLink(playlist: PlaylistMeta): string[] {
|
|
return getPlaylistLinkUtil(playlist);
|
|
}
|
|
|
|
getPlaylistProvider(playlist: PlaylistMeta): string {
|
|
this.languageTick();
|
|
|
|
if (playlist.serverUrl) {
|
|
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
|
}
|
|
|
|
if (playlist.macAddress) {
|
|
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
|
}
|
|
|
|
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
|
}
|
|
|
|
getRecentItemProviderLabel(item: GlobalRecentItem): string {
|
|
this.languageTick();
|
|
|
|
if (item.source === 'stalker') {
|
|
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
|
}
|
|
if (item.source === 'xtream') {
|
|
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
|
}
|
|
if (item.source === 'm3u') {
|
|
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
|
}
|
|
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
|
}
|
|
|
|
getRecentItemLink(item: GlobalRecentItem): string[] {
|
|
return getRecentItemLinkUtil(item);
|
|
}
|
|
|
|
getRecentItemNavigationState(
|
|
item: GlobalRecentItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
return getRecentItemNavigationStateUtil(
|
|
item,
|
|
this.getPlaybackPositionForItem(item)
|
|
);
|
|
}
|
|
|
|
getRecentItemDetailNavigationState(
|
|
item: GlobalRecentItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
return getRecentItemDetailNavigationStateUtil(
|
|
item,
|
|
this.getPlaybackPositionForItem(item)
|
|
);
|
|
}
|
|
|
|
getRecentItemResumeNavigation(
|
|
item: GlobalRecentItem
|
|
): WorkspaceNavigationTarget | null {
|
|
return getRecentItemResumeNavigationUtil(
|
|
item,
|
|
this.getPlaybackPositionForItem(item)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mark the item's tracked position as fully watched by maxing out the
|
|
* existing row. Reusing the stored row keeps the provider-specific
|
|
* identity fields (Stalker scoped episode ids included) intact; without
|
|
* a row or a known duration there is nothing truthful to write, so the
|
|
* action reports false and the caller hides/ignores it. Uses the strict
|
|
* save boundary and rethrows persistence failures — the non-strict save
|
|
* swallows them, and reloading an unchanged position while reporting
|
|
* success would silently lie to the user.
|
|
*/
|
|
async markRecentItemWatched(item: GlobalRecentItem): Promise<boolean> {
|
|
const position = this.getPlaybackPositionForItem(item);
|
|
if (!position?.durationSeconds || position.durationSeconds <= 0) {
|
|
return false;
|
|
}
|
|
|
|
await this.playbackPositions.savePlaybackPositionOrThrow(
|
|
item.playlist_id,
|
|
{
|
|
...position,
|
|
positionSeconds: position.durationSeconds,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
);
|
|
await this.reloadPlaybackPositions();
|
|
return true;
|
|
}
|
|
|
|
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
|
|
if (item.source === 'xtream') {
|
|
if (this.hasPortalActivityStorage) {
|
|
await this.dbService.removeRecentItem(
|
|
item.id as number,
|
|
item.playlist_id
|
|
);
|
|
} else {
|
|
await this.xtreamDataSource.removeRecentItem(
|
|
{
|
|
id: Number(item.id),
|
|
type: item.type as 'live' | 'movie' | 'series',
|
|
},
|
|
item.playlist_id
|
|
);
|
|
}
|
|
await this.reloadGlobalRecentItems();
|
|
return;
|
|
}
|
|
|
|
if (item.source === 'stalker') {
|
|
const updatedPlaylist = await firstValueFrom(
|
|
this.playlistsService.removeFromPortalRecentlyViewed(
|
|
item.playlist_id,
|
|
item.id
|
|
)
|
|
);
|
|
|
|
this.store.dispatch(
|
|
PlaylistActions.updatePlaylistMeta({
|
|
playlist: {
|
|
_id: item.playlist_id,
|
|
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
|
} as unknown as PlaylistMeta,
|
|
}) as any
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (item.source === 'm3u') {
|
|
const updatedPlaylist = await firstValueFrom(
|
|
this.playlistsService.removeFromM3uRecentlyViewed(
|
|
item.playlist_id,
|
|
String(item.xtream_id ?? item.id)
|
|
)
|
|
);
|
|
|
|
this.store.dispatch(
|
|
PlaylistActions.updatePlaylistMeta({
|
|
playlist: {
|
|
_id: item.playlist_id,
|
|
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
|
} as unknown as PlaylistMeta,
|
|
}) as any
|
|
);
|
|
}
|
|
}
|
|
|
|
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
|
|
return getGlobalFavoriteLinkUtil(item);
|
|
}
|
|
|
|
getGlobalFavoriteNavigationState(
|
|
item: DashboardFavoriteItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
return getGlobalFavoriteNavigationStateUtil(item);
|
|
}
|
|
|
|
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
|
|
return getRecentlyAddedLinkUtil(item);
|
|
}
|
|
|
|
getRecentlyAddedNavigationState(
|
|
item: DashboardRecentlyAddedItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
return getRecentlyAddedNavigationStateUtil(item);
|
|
}
|
|
|
|
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
|
|
if (item.source === 'xtream') {
|
|
if (this.hasPortalActivityStorage) {
|
|
await this.dbService.removeFromFavorites(
|
|
item.id as number,
|
|
item.playlist_id
|
|
);
|
|
} else {
|
|
await this.xtreamDataSource.removeFavorite(
|
|
{
|
|
id: Number(item.id),
|
|
type: item.type as 'live' | 'movie' | 'series',
|
|
},
|
|
item.playlist_id
|
|
);
|
|
}
|
|
await this.reloadGlobalFavorites();
|
|
return;
|
|
}
|
|
|
|
if (item.source === 'stalker') {
|
|
const playlist = this.playlists().find(
|
|
(p) => p._id === item.playlist_id
|
|
);
|
|
if (!playlist) return;
|
|
|
|
const currentFavorites = Array.isArray(playlist.favorites)
|
|
? [...playlist.favorites]
|
|
: [];
|
|
const itemMatchStr = String(item.id);
|
|
|
|
const filteredFavorites = currentFavorites.filter(
|
|
(raw, index) =>
|
|
!stalkerItemMatchesId(
|
|
raw,
|
|
itemMatchStr,
|
|
playlist._id,
|
|
index
|
|
)
|
|
);
|
|
|
|
this.store.dispatch(
|
|
PlaylistActions.updatePlaylistMeta({
|
|
playlist: {
|
|
_id: item.playlist_id,
|
|
favorites: filteredFavorites,
|
|
} as unknown as PlaylistMeta,
|
|
}) as any
|
|
);
|
|
}
|
|
|
|
if (item.source === 'm3u') {
|
|
await firstValueFrom(
|
|
this.playlistsService.transformPlaylistFavorites(
|
|
item.playlist_id,
|
|
(current) =>
|
|
current.filter(
|
|
(favorite) =>
|
|
typeof favorite !== 'string' ||
|
|
favorite !== String(item.id)
|
|
)
|
|
)
|
|
);
|
|
await this.reloadGlobalFavorites();
|
|
}
|
|
}
|
|
|
|
formatTimestamp(value?: string | number): string {
|
|
this.languageTick();
|
|
|
|
const timestamp = toTimestamp(value);
|
|
if (!timestamp) {
|
|
return this.translateText('WORKSPACE.DASHBOARD.NOT_YET_SYNCED');
|
|
}
|
|
|
|
return new Date(timestamp).toLocaleString(this.getLocale());
|
|
}
|
|
|
|
private async loadM3uPlaylistFavorites(
|
|
playlistMeta: PlaylistMeta
|
|
): Promise<DashboardFavoriteItem[]> {
|
|
// Cache fingerprint covers what affects the result: the favorites
|
|
// list itself and the playlist's update timestamp (changes mean
|
|
// channels may have been added/removed by a refresh).
|
|
const fingerprint = this.buildM3uFavoritesFingerprint(playlistMeta);
|
|
const cached = this.m3uFavoritesCache.get(playlistMeta._id);
|
|
if (cached && cached.fingerprint === fingerprint) {
|
|
return cached.items;
|
|
}
|
|
|
|
const fastPathItems =
|
|
await this.loadM3uPlaylistFavoritesFromResolvedChannels(
|
|
playlistMeta,
|
|
fingerprint
|
|
);
|
|
if (fastPathItems !== null) {
|
|
return fastPathItems;
|
|
}
|
|
|
|
const playlist = (await firstValueFrom(
|
|
this.playlistsService.getPlaylistById(playlistMeta._id)
|
|
)) as Playlist & {
|
|
playlist?: {
|
|
items?: Channel[];
|
|
};
|
|
};
|
|
const favorites = Array.isArray(playlist?.favorites)
|
|
? playlist.favorites.filter(
|
|
(favorite): favorite is string =>
|
|
typeof favorite === 'string' && favorite.trim().length > 0
|
|
)
|
|
: [];
|
|
|
|
if (favorites.length === 0) {
|
|
this.m3uFavoritesCache.set(playlistMeta._id, {
|
|
fingerprint,
|
|
items: [],
|
|
});
|
|
return [];
|
|
}
|
|
|
|
const items: Channel[] = Array.isArray(playlist?.playlist?.items)
|
|
? (playlist.playlist.items as Channel[])
|
|
: [];
|
|
const fallbackTimestamp =
|
|
this.getM3uFavoriteTimestamp(playlistMeta) ??
|
|
new Date(0).toISOString();
|
|
const favoritePositions = new Map<string, number>();
|
|
|
|
favorites.forEach((favorite, index) => {
|
|
if (!favoritePositions.has(favorite)) {
|
|
favoritePositions.set(favorite, index);
|
|
}
|
|
});
|
|
|
|
const computedItems = items.reduce<DashboardFavoriteItem[]>(
|
|
(acc, channel) => {
|
|
const channelId = String(channel.id ?? '').trim();
|
|
const channelUrl = String(channel.url ?? '').trim();
|
|
const channelIdFavoritePosition =
|
|
favoritePositions.get(channelId);
|
|
const channelUrlFavoritePosition =
|
|
favoritePositions.get(channelUrl);
|
|
const matchedFavoriteId =
|
|
channelIdFavoritePosition !== undefined &&
|
|
(channelUrlFavoritePosition === undefined ||
|
|
channelIdFavoritePosition <= channelUrlFavoritePosition)
|
|
? channelId
|
|
: channelUrlFavoritePosition !== undefined
|
|
? channelUrl
|
|
: null;
|
|
|
|
if (!matchedFavoriteId) {
|
|
return acc;
|
|
}
|
|
|
|
acc.push(
|
|
this.createM3uFavoriteItem(
|
|
playlistMeta,
|
|
matchedFavoriteId,
|
|
channel,
|
|
fallbackTimestamp
|
|
)
|
|
);
|
|
return acc;
|
|
},
|
|
[]
|
|
);
|
|
|
|
this.m3uFavoritesCache.set(playlistMeta._id, {
|
|
fingerprint,
|
|
items: computedItems,
|
|
});
|
|
return computedItems;
|
|
}
|
|
|
|
private async loadM3uPlaylistFavoritesFromResolvedChannels(
|
|
playlistMeta: PlaylistMeta,
|
|
fingerprint: string
|
|
): Promise<DashboardFavoriteItem[] | null> {
|
|
let resolvedChannels: M3uFavoriteChannel[] | null;
|
|
try {
|
|
resolvedChannels = await firstValueFrom(
|
|
this.playlistsService.getM3uFavoriteChannels(playlistMeta._id)
|
|
);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to load resolved M3U favorites, falling back to full playlist payload',
|
|
err
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (resolvedChannels === null) {
|
|
return null;
|
|
}
|
|
|
|
const fallbackTimestamp =
|
|
this.getM3uFavoriteTimestamp(playlistMeta) ??
|
|
new Date(0).toISOString();
|
|
const items = resolvedChannels
|
|
.slice()
|
|
.map((favorite) =>
|
|
this.createM3uFavoriteItem(
|
|
playlistMeta,
|
|
favorite.favoriteId,
|
|
favorite.channel,
|
|
fallbackTimestamp
|
|
)
|
|
);
|
|
|
|
this.m3uFavoritesCache.set(playlistMeta._id, {
|
|
fingerprint,
|
|
items,
|
|
});
|
|
return items;
|
|
}
|
|
|
|
private createM3uFavoriteItem(
|
|
playlistMeta: PlaylistMeta,
|
|
favoriteId: string,
|
|
channel: Channel,
|
|
fallbackTimestamp: string
|
|
): DashboardFavoriteItem {
|
|
const channelId = String(channel.id ?? '').trim();
|
|
|
|
return {
|
|
id: favoriteId,
|
|
title:
|
|
channel.name?.trim() ||
|
|
channel.tvg?.name?.trim() ||
|
|
channelId ||
|
|
favoriteId,
|
|
type: 'live',
|
|
playlist_id: playlistMeta._id,
|
|
playlist_name: playlistMeta.title || playlistMeta.filename || 'M3U',
|
|
added_at: fallbackTimestamp,
|
|
category_id: 'live',
|
|
xtream_id: favoriteId,
|
|
poster_url: channel.tvg?.logo || undefined,
|
|
epg_lookup_key:
|
|
channel.tvg?.id?.trim() ||
|
|
channel.tvg?.name?.trim() ||
|
|
channel.name?.trim() ||
|
|
undefined,
|
|
source: 'm3u',
|
|
};
|
|
}
|
|
|
|
private buildM3uFavoritesFingerprint(playlist: PlaylistMeta): string {
|
|
// updateDate changes on refresh (channel list may have changed);
|
|
// favorites JSON changes on add/remove. Together they cover every
|
|
// way the result could differ.
|
|
const favoritesPart = JSON.stringify(playlist.favorites ?? []);
|
|
const updatePart = String(
|
|
playlist.updateDate ?? playlist.importDate ?? ''
|
|
);
|
|
return `${updatePart}::${favoritesPart}`;
|
|
}
|
|
|
|
private getM3uFavoriteTimestamp(playlist: PlaylistMeta): string | null {
|
|
if (playlist.updateDate) {
|
|
return new Date(playlist.updateDate).toISOString();
|
|
}
|
|
|
|
return typeof playlist.importDate === 'string'
|
|
? playlist.importDate
|
|
: null;
|
|
}
|
|
|
|
private getPlaylistActivityTimestamp(item: PlaylistMeta): number {
|
|
return this.recentPlaylistActivityTimestamps().get(item._id) ?? 0;
|
|
}
|
|
|
|
private getRecentTimestamp(item: PlaylistMeta): number {
|
|
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
|
|
}
|
|
|
|
private getLocale(): string | undefined {
|
|
return (
|
|
this.translate.currentLang ||
|
|
this.translate.defaultLang ||
|
|
undefined
|
|
);
|
|
}
|
|
|
|
private translateText(
|
|
key: string,
|
|
params?: Record<string, string | number>
|
|
): string {
|
|
return this.translate.instant(key, params);
|
|
}
|
|
}
|