mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
Two related dashboard fixes driven by the v0.22 mockup intent and the
UX-audit feedback that the rail labels lied about their data:
1. Live rail is favorites-first
- Source: globalFavoriteLiveItems() — the channels the user actually
starred. Falls back to globalRecentLiveItems() when no favorites
exist so fresh-install users still see something useful.
- Title flips with the source: "Live now on your favorites" when
pulling from favorites, "Continue with live TV" when pulling from
recent-watch history. The label is always honest about the data.
- "See all" link routes to the right collection page for the source
(/workspace/global-favorites vs /workspace/global-recent).
2. Mixed Global Favorites rail removed from the dashboard
- The rail had movies, series, live channels, and radio all sharing
one row — different card formats fighting for visual attention.
UX wiki principle: similar elements should look alike within a
scanning unit.
- Live favorites are promoted into the live rail above (with current
EPG). The full mixed catalogue is still one click away at
/workspace/global-favorites where the collection page can give it
proper per-type filters.
- Net dashboard density goes from 5 rails to 4 — closer to the
streaming-app sweet spot.
i18n: new WORKSPACE.DASHBOARD.LIVE_CONTINUE key, translated across all
17 locales by per-locale agents (placeholder integrity verified).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1075 lines
36 KiB
TypeScript
1075 lines
36 KiB
TypeScript
import {
|
|
Injectable,
|
|
NgZone,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
signal,
|
|
} from '@angular/core';
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
import { Store } from '@ngrx/store';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import {
|
|
PlaylistActions,
|
|
selectAllPlaylistsMeta,
|
|
selectPlaylistsLoadingFlag,
|
|
} from '@iptvnator/m3u-state';
|
|
import { firstValueFrom, startWith } from 'rxjs';
|
|
import {
|
|
DatabaseService,
|
|
GlobalRecentlyAddedKind,
|
|
PlaylistsService,
|
|
} from '@iptvnator/services';
|
|
import {
|
|
buildPlaylistRecentItems,
|
|
Channel,
|
|
Playlist,
|
|
PortalAddedItem,
|
|
PortalActivityItem,
|
|
PlaylistMeta,
|
|
PortalActivityType,
|
|
PortalFavoriteItem,
|
|
PortalRecentItem,
|
|
stalkerItemMatchesId,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import {
|
|
buildStalkerFavoriteItems,
|
|
getActivityTypeLabelKey,
|
|
mapDbFavoriteToItem,
|
|
mapDbRecentlyAddedToItem,
|
|
mapDbRecentToItem,
|
|
toDateTimestamp,
|
|
toTimestamp,
|
|
} from './dashboard-mappers';
|
|
import {
|
|
buildStalkerDetailNavigationTarget,
|
|
buildStalkerStateItem,
|
|
buildXtreamNavigationTarget,
|
|
getGlobalFavoriteNavigation,
|
|
getRecentItemNavigation,
|
|
PORTAL_PLAYBACK_POSITIONS,
|
|
WorkspaceNavigationTarget,
|
|
} from '@iptvnator/portal/shared/util';
|
|
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
|
|
|
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
|
|
|
// Compound key for looking up a playback position by recent item — a single
|
|
// playlist can contain the same xtream-id for a VOD and an episode (rare,
|
|
// but the schema allows it), so contentType is part of the key.
|
|
function playbackPositionMapKey(
|
|
playlistId: string,
|
|
contentXtreamId: number,
|
|
contentType: 'vod' | 'episode'
|
|
): string {
|
|
return `${playlistId}::${contentXtreamId}::${contentType}`;
|
|
}
|
|
|
|
/** @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 playlistsService = inject(PlaylistsService);
|
|
private readonly ngZone = inject(NgZone);
|
|
private readonly translate = inject(TranslateService);
|
|
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
|
private favoritesAutoRefreshEnabled = false;
|
|
private readonly languageTick = toSignal(
|
|
this.translate.onLangChange.pipe(startWith(null)),
|
|
{ initialValue: null }
|
|
);
|
|
|
|
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);
|
|
private readonly globalRecentDbLoadedState = signal(!window.electron);
|
|
private readonly globalFavoritesLoadingState = signal(true);
|
|
private readonly globalFavoritesLoadedState = 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();
|
|
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 prefers this
|
|
// source so the EPG enrichment lands on the channels users actually care
|
|
// about. Falls back to globalRecentLiveItems when this is empty so a
|
|
// fresh-install user still sees something useful.
|
|
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 playbackPositionsMap = signal<
|
|
Map<string, PlaybackPositionData>
|
|
>(new Map());
|
|
|
|
readonly playbackPositions$ = this.playbackPositionsMap.asReadonly();
|
|
|
|
getPlaybackPositionForItem(
|
|
item: PortalActivityItem
|
|
): PlaybackPositionData | null {
|
|
if (item.type !== 'movie' && item.type !== 'series') {
|
|
return null;
|
|
}
|
|
const positionType: 'vod' | 'episode' =
|
|
item.type === 'series' ? 'episode' : 'vod';
|
|
const xtreamId =
|
|
typeof item.xtream_id === 'number'
|
|
? item.xtream_id
|
|
: Number(item.xtream_id);
|
|
if (!Number.isFinite(xtreamId)) {
|
|
return null;
|
|
}
|
|
const key = playbackPositionMapKey(
|
|
item.playlist_id,
|
|
xtreamId,
|
|
positionType
|
|
);
|
|
return this.playbackPositionsMap().get(key) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Refresh the in-memory positions map for every playlist that owns at
|
|
* least one VOD/series recent item. Per-playlist bulk fetch is one IPC
|
|
* round-trip each (vs N+1 per content item), so this stays cheap even
|
|
* on heavy libraries.
|
|
*/
|
|
async reloadPlaybackPositions(): Promise<void> {
|
|
const playlistIds = new Set<string>();
|
|
for (const item of this.globalRecentItems()) {
|
|
if (item.type === 'movie' || item.type === 'series') {
|
|
playlistIds.add(item.playlist_id);
|
|
}
|
|
}
|
|
if (playlistIds.size === 0) {
|
|
this.playbackPositionsMap.set(new Map());
|
|
return;
|
|
}
|
|
|
|
const next = new Map<string, PlaybackPositionData>();
|
|
for (const playlistId of playlistIds) {
|
|
try {
|
|
const positions =
|
|
await this.playbackPositions.getAllPlaybackPositions(
|
|
playlistId
|
|
);
|
|
for (const position of positions) {
|
|
next.set(
|
|
playbackPositionMapKey(
|
|
playlistId,
|
|
position.contentXtreamId,
|
|
position.contentType
|
|
),
|
|
position
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to load playback positions for playlist',
|
|
playlistId,
|
|
err
|
|
);
|
|
}
|
|
}
|
|
|
|
this.ngZone.run(() => this.playbackPositionsMap.set(next));
|
|
}
|
|
|
|
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.refreshPlaylistBackedGlobalFavorites();
|
|
});
|
|
|
|
effect(() => {
|
|
this.playlistsLoaded();
|
|
this.finishInitialGlobalRecentLoadIfReady();
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
});
|
|
|
|
effect(() => {
|
|
if (this.hasXtreamPlaylists()) {
|
|
return;
|
|
}
|
|
|
|
this.ngZone.run(() => {
|
|
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 (!window.electron) {
|
|
this.xtreamGlobalRecentItems.set([]);
|
|
this.globalRecentDbLoadedState.set(true);
|
|
this.finishInitialGlobalRecentLoadIfReady();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const recentItems = await this.dbService.getGlobalRecentlyViewed();
|
|
const normalized = recentItems.map((item) =>
|
|
mapDbRecentToItem(item)
|
|
);
|
|
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to reload global recent items',
|
|
err
|
|
);
|
|
this.ngZone.run(() => this.xtreamGlobalRecentItems.set([]));
|
|
} finally {
|
|
this.globalRecentDbLoadedState.set(true);
|
|
this.finishInitialGlobalRecentLoadIfReady();
|
|
}
|
|
}
|
|
|
|
async reloadGlobalFavorites(): Promise<void> {
|
|
if (!this.globalFavoritesLoaded()) {
|
|
this.globalFavoritesLoadingState.set(true);
|
|
}
|
|
|
|
const xtreamReload = this.reloadXtreamGlobalFavorites();
|
|
const m3uReload = this.refreshPlaylistBackedGlobalFavorites();
|
|
|
|
await Promise.all([xtreamReload, m3uReload]);
|
|
this.favoritesAutoRefreshEnabled = true;
|
|
}
|
|
|
|
async getGlobalRecentlyAddedItems(
|
|
kind: DashboardRecentlyAddedFilterKind,
|
|
limit = 200
|
|
): Promise<DashboardRecentlyAddedItem[]> {
|
|
if (!window.electron) {
|
|
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 (!window.electron) {
|
|
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 (!window.electron) {
|
|
this.ngZone.run(() => {
|
|
this.xtreamRecentlyAddedItemsState.set([]);
|
|
this.xtreamRecentlyAddedLoadingState.set(false);
|
|
this.xtreamRecentlyAddedLoadedState.set(true);
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const items = await this.getXtreamRecentlyAddedItems(limit);
|
|
this.ngZone.run(() =>
|
|
this.xtreamRecentlyAddedItemsState.set(items)
|
|
);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to reload Xtream recently added items',
|
|
err
|
|
);
|
|
this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([]));
|
|
} finally {
|
|
this.ngZone.run(() => {
|
|
this.xtreamRecentlyAddedLoadingState.set(false);
|
|
this.xtreamRecentlyAddedLoadedState.set(true);
|
|
});
|
|
}
|
|
}
|
|
|
|
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
|
if (!window.electron) {
|
|
this.xtreamGlobalFavorites.set([]);
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const favorites = await this.dbService.getAllGlobalFavorites();
|
|
const normalized = favorites.map((item) =>
|
|
mapDbFavoriteToItem(item)
|
|
);
|
|
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
|
|
} catch (err) {
|
|
console.warn(
|
|
'[DashboardData] Failed to reload global favorites',
|
|
err
|
|
);
|
|
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
|
|
} finally {
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
}
|
|
}
|
|
|
|
private async refreshPlaylistBackedGlobalFavorites(): Promise<void> {
|
|
await this.reloadM3uGlobalFavorites();
|
|
this.finishInitialGlobalFavoritesLoadIfReady();
|
|
}
|
|
|
|
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.ngZone.run(() => {
|
|
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 {
|
|
items = [];
|
|
}
|
|
|
|
this.ngZone.run(() => {
|
|
this.m3uPlaylistFavoritesMap.update((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(playlist._id, items);
|
|
return next;
|
|
});
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
private finishInitialGlobalFavoritesLoadIfReady(): void {
|
|
if (!this.playlistsLoaded()) {
|
|
return;
|
|
}
|
|
|
|
this.ngZone.run(() => {
|
|
this.globalFavoritesLoadedState.set(true);
|
|
this.globalFavoritesLoadingState.set(false);
|
|
});
|
|
}
|
|
|
|
private finishInitialGlobalRecentLoadIfReady(): void {
|
|
if (!this.playlistsLoaded() || !this.globalRecentDbLoadedState()) {
|
|
return;
|
|
}
|
|
|
|
this.ngZone.run(() => {
|
|
this.globalRecentLoadedState.set(true);
|
|
this.globalRecentLoadingState.set(false);
|
|
});
|
|
}
|
|
|
|
isTypeInKind(
|
|
type: PortalActivityType,
|
|
kind: DashboardContentKind
|
|
): boolean {
|
|
if (kind === 'all') {
|
|
return true;
|
|
}
|
|
if (kind === 'channels') {
|
|
return type === 'live';
|
|
}
|
|
if (kind === 'vod') {
|
|
return type === 'movie';
|
|
}
|
|
return type === 'series';
|
|
}
|
|
|
|
getPlaylistLink(playlist: PlaylistMeta): string[] {
|
|
if (playlist.serverUrl) {
|
|
return ['/workspace', 'xtreams', playlist._id, 'vod'];
|
|
}
|
|
|
|
if (playlist.macAddress) {
|
|
return ['/workspace', 'stalker', playlist._id, 'vod'];
|
|
}
|
|
|
|
return ['/workspace', 'playlists', playlist._id];
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
getRecentItemTypeLabel(item: GlobalRecentItem): string {
|
|
this.languageTick();
|
|
return this.translateText(getActivityTypeLabelKey(item.type));
|
|
}
|
|
|
|
getRecentItemLink(item: GlobalRecentItem): string[] {
|
|
return getRecentItemNavigation(item).link;
|
|
}
|
|
|
|
getRecentItemNavigationState(
|
|
item: GlobalRecentItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
return getRecentItemNavigation(item).state;
|
|
}
|
|
|
|
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
|
|
if (item.source === 'xtream') {
|
|
await this.dbService.removeRecentItem(
|
|
item.id as number,
|
|
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
|
|
);
|
|
}
|
|
}
|
|
|
|
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
|
|
return this.getActivityItemProviderLabel(item);
|
|
}
|
|
|
|
getRecentlyAddedItemProviderLabel(
|
|
item: DashboardRecentlyAddedItem
|
|
): string {
|
|
return this.getActivityItemProviderLabel(item);
|
|
}
|
|
|
|
private getActivityItemProviderLabel(
|
|
item: Pick<PortalActivityItem, 'source'>
|
|
): 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');
|
|
}
|
|
|
|
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
|
|
return this.getActivityItemTypeLabel(item);
|
|
}
|
|
|
|
getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string {
|
|
return this.getActivityItemTypeLabel(item);
|
|
}
|
|
|
|
private getActivityItemTypeLabel(
|
|
item: Pick<PortalActivityItem, 'type'>
|
|
): string {
|
|
this.languageTick();
|
|
return this.translateText(getActivityTypeLabelKey(item.type));
|
|
}
|
|
|
|
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
|
|
return getGlobalFavoriteNavigation(item).link;
|
|
}
|
|
|
|
getGlobalFavoriteNavigationState(
|
|
item: DashboardFavoriteItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
return getGlobalFavoriteNavigation(item).state;
|
|
}
|
|
|
|
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
|
|
if (item.source === 'stalker' && item.type !== 'live') {
|
|
return buildStalkerDetailNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
item: buildStalkerStateItem(item.stalker_item, {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: item.type,
|
|
category_id: item.category_id,
|
|
poster_url: item.poster_url,
|
|
}),
|
|
}).link;
|
|
}
|
|
|
|
return buildXtreamNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
itemId: item.xtream_id,
|
|
title: item.title,
|
|
imageUrl: item.poster_url,
|
|
}).link;
|
|
}
|
|
|
|
getRecentlyAddedNavigationState(
|
|
item: DashboardRecentlyAddedItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
if (item.source === 'stalker' && item.type !== 'live') {
|
|
return buildStalkerDetailNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
item: buildStalkerStateItem(item.stalker_item, {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: item.type,
|
|
category_id: item.category_id,
|
|
poster_url: item.poster_url,
|
|
}),
|
|
}).state;
|
|
}
|
|
|
|
return buildXtreamNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
itemId: item.xtream_id,
|
|
title: item.title,
|
|
imageUrl: item.poster_url,
|
|
}).state;
|
|
}
|
|
|
|
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
|
|
if (item.source === 'xtream') {
|
|
await this.dbService.removeFromFavorites(
|
|
item.id as number,
|
|
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') {
|
|
const playlist = await firstValueFrom(
|
|
this.playlistsService.getPlaylistById(item.playlist_id)
|
|
);
|
|
const currentFavorites = Array.isArray(playlist?.favorites)
|
|
? playlist.favorites.filter(
|
|
(favorite): favorite is string =>
|
|
typeof favorite === 'string'
|
|
)
|
|
: [];
|
|
const filteredFavorites = currentFavorites.filter(
|
|
(favorite) => favorite !== String(item.id)
|
|
);
|
|
|
|
await firstValueFrom(
|
|
this.playlistsService.setFavorites(
|
|
item.playlist_id,
|
|
filteredFavorites
|
|
)
|
|
);
|
|
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). A cache hit
|
|
// skips the heavyweight getPlaylistById() call — for large M3U
|
|
// playlists that's a serialized payload of 90K+ channels avoided
|
|
// entirely on repeat dashboard mounts.
|
|
const fingerprint = this.buildM3uFavoritesFingerprint(playlistMeta);
|
|
const cached = this.m3uFavoritesCache.get(playlistMeta._id);
|
|
if (cached && cached.fingerprint === fingerprint) {
|
|
return cached.items;
|
|
}
|
|
|
|
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({
|
|
id: matchedFavoriteId,
|
|
title:
|
|
channel.name?.trim() || channel.tvg?.name || channelId,
|
|
type: 'live',
|
|
playlist_id: playlistMeta._id,
|
|
playlist_name:
|
|
playlistMeta.title || playlistMeta.filename || 'M3U',
|
|
added_at: fallbackTimestamp,
|
|
category_id: 'live',
|
|
xtream_id: matchedFavoriteId,
|
|
poster_url: channel.tvg?.logo || undefined,
|
|
source: 'm3u',
|
|
});
|
|
return acc;
|
|
},
|
|
[]
|
|
);
|
|
|
|
this.m3uFavoritesCache.set(playlistMeta._id, {
|
|
fingerprint,
|
|
items: computedItems,
|
|
});
|
|
return computedItems;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|