fix(xtream): preserve favorites across catalog refreshes

This commit is contained in:
4gray committed 2026-10-13 23:31:25 +02:00
1 parent ec4b9286a7
commit 8ccfcb68cc
121 files changed
+4000 -398

No files matched your search

+1
View File
@@ -81,6 +81,7 @@ export * from './lib/xtream-live-stream.interface';
export * from './lib/xtream-response.interface';
export * from './lib/xtream-portal.utils';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-favorite.interface';
export * from './lib/xtream-server-timezone.util';
export * from './lib/xtream-recently-added.utils';
export * from './lib/xtream-performance-marker.interface';
@@ -564,7 +564,20 @@ export interface ElectronBridgeGlobalRecentItem extends ElectronBridgeXtreamCont
viewed_at: string;
}
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamContent {
export interface ElectronBridgeXtreamFavorite extends Omit<
ElectronBridgeXtreamContent,
'id' | 'category_id'
> {
id: number | null;
category_id: number | null;
favorite_id?: number;
catalog_status?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
tmdb_id?: number | null;
release_year?: number | null;
original_title?: string | null;
}
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamFavorite {
playlist_id: string;
playlist_name: string;
added_at: string;
@@ -577,7 +590,8 @@ export interface ElectronBridgeGlobalRecentlyAddedItem extends ElectronBridgeXtr
}
export interface ElectronBridgeFavoriteReorderUpdate {
content_id: number;
content_id?: number;
favorite_id?: number;
/** Favorites are playlist-scoped — scope the position write per playlist */
playlist_id: string;
position: number;
@@ -1033,6 +1047,11 @@ export interface ElectronBridgeApi {
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}>;
dbRestoreXtreamFavorites: (
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
restoreId?: string
) => Promise<ElectronBridgeResult>;
dbRestoreXtreamUserData: (
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
@@ -1109,15 +1128,18 @@ export interface ElectronBridgeApi {
contentId: number,
playlistId: string,
backdropUrl?: string
) => Promise<ElectronBridgeResult>;
) => Promise<ElectronBridgeErrorResult>;
dbRemoveFavorite: (
contentId: number,
contentId: import('./xtream-favorite.interface').XtreamFavoriteRemovalRef,
playlistId: string
) => Promise<ElectronBridgeResult>;
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
dbGetFavoritesForBackup: (
playlistId: string
) => Promise<ElectronBridgeXtreamFavorite[]>;
dbGetFavorites: (
playlistId: string
) => Promise<ElectronBridgeXtreamContent[]>;
) => Promise<ElectronBridgeXtreamFavorite[]>;
dbGetGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
dbGetAllGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
dbReorderGlobalFavorites: (
@@ -1,3 +1,4 @@
import type { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
import { PlaybackPositionData } from './playback-position.interface';
import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface';
import { StalkerPortalItem } from './stalker-portal-item.interface';
@@ -56,6 +57,7 @@ export interface XtreamBackupFavoriteItem {
xtreamId: number;
addedAt?: string;
position?: number | null;
snapshot?: XtreamFavoriteSnapshot;
}
export interface XtreamBackupRecentlyViewedItem {
@@ -44,6 +44,8 @@ export interface PortalActivityItem {
/** The year the PROVIDER stated, never one read out of the title */
release_year?: number;
original_title?: string;
favorite_id?: number;
catalogStatus?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
@@ -0,0 +1,30 @@
/** Catalog membership is separate from whether a stream can currently play. */
export const XTREAM_FAVORITE_CATALOG_STATUS = {
PRESENT: 'present',
MISSING: 'missing',
UNKNOWN: 'unknown',
} as const;
export type XtreamFavoriteCatalogStatus =
(typeof XTREAM_FAVORITE_CATALOG_STATUS)[keyof typeof XTREAM_FAVORITE_CATALOG_STATUS];
export interface XtreamFavoriteSnapshot {
title: string;
posterUrl?: string | null;
backdropUrl?: string | null;
tmdbId?: number | null;
releaseYear?: number | null;
originalTitle?: string | null;
}
/** Numeric references are legacy catalog row IDs, never provider IDs. */
export type XtreamFavoriteRemovalRef = number | { favoriteId: number };
export const XTREAM_FAVORITE_IDENTITY_CONFLICT =
'XTREAM_FAVORITE_IDENTITY_CONFLICT';
export function isCatalogUnavailable(item: {
catalogStatus?: XtreamFavoriteCatalogStatus;
}): boolean {
return item.catalogStatus === 'missing' || item.catalogStatus === 'unknown';
}
@@ -1,6 +1,37 @@
import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util';
describe('normalizeXtreamPendingRestoreState', () => {
it('retains only display metadata in a durable favorite snapshot', () => {
const state = normalizeXtreamPendingRestoreState({
favorites: [
{
contentType: 'movie',
xtreamId: 7,
position: 2,
snapshot: {
title: 'ABC',
posterUrl: 'https://example.test/poster.jpg',
tmdbId: 42,
password: 'private',
streamUrl: 'https://example.test/secret',
},
},
],
});
expect(state.favorites).toEqual([
{
contentType: 'movie',
xtreamId: 7,
position: 2,
snapshot: {
title: 'ABC',
posterUrl: 'https://example.test/poster.jpg',
tmdbId: 42,
},
},
]);
});
// No `sourcePins`: absent has to stay absent, or an older archive would
// read as "there are no pins" and restore would clear the user's.
const emptyState = {
@@ -5,6 +5,7 @@ import {
XtreamBackupRecentlyViewedItem,
XtreamBackupSourcePin,
} from './playlist-backup.interface';
import { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
import { PlaybackPositionData } from './playback-position.interface';
export interface XtreamPendingRestoreState {
@@ -18,6 +19,8 @@ export interface XtreamPendingRestoreState {
* can distinguish "no opinion" from an authoritative empty collection.
*/
sourcePins?: XtreamBackupSourcePin[];
/** Internal replay receipt; never exported in an archive. */
favoritesRestoreId?: string;
}
export function getXtreamPendingRestoreStorageKey(playlistId: string): string {
@@ -36,6 +39,7 @@ interface RestoreStateCandidate {
recentlyViewed?: unknown;
playbackPositions?: unknown;
sourcePins?: unknown;
favoritesRestoreId?: unknown;
}
interface RestoreEntryCandidate {
@@ -135,9 +139,12 @@ export function normalizeXtreamPendingRestoreState(
return {
hiddenCategories,
favorites: withNumericXtreamId<XtreamBackupFavoriteItem>(
toArray(candidate.favorites)
),
favorites: normalizeFavorites(candidate.favorites),
...(typeof candidate.favoritesRestoreId === 'string' &&
candidate.favoritesRestoreId.length > 0 &&
candidate.favoritesRestoreId.length <= 200
? { favoritesRestoreId: candidate.favoritesRestoreId }
: {}),
recentlyViewed: withNumericXtreamId<XtreamBackupRecentlyViewedItem>(
toArray(candidate.recentlyViewed)
),
@@ -184,3 +191,51 @@ function normalizeSourcePins(value: unknown): XtreamBackupSourcePin[] {
return pins;
}
/** Snapshot fields are display metadata, never a raw provider response. */
function normalizeFavorites(value: unknown): XtreamBackupFavoriteItem[] {
return withNumericXtreamId<XtreamBackupFavoriteItem>(toArray(value))
.filter((item) =>
['live', 'movie', 'series'].includes(item.contentType)
)
.map((item) => {
const snapshot = normalizeFavoriteSnapshot(item.snapshot);
return {
contentType: item.contentType,
xtreamId: item.xtreamId,
...(typeof item.addedAt === 'string'
? { addedAt: item.addedAt }
: {}),
...(item.position === null ||
(typeof item.position === 'number' &&
Number.isFinite(item.position))
? { position: item.position }
: {}),
...(snapshot ? { snapshot } : {}),
};
});
}
function normalizeFavoriteSnapshot(
value: unknown
): XtreamFavoriteSnapshot | undefined {
if (
!isRecord(value) ||
typeof value['title'] !== 'string' ||
!value['title'].trim()
)
return undefined;
const snapshot: XtreamFavoriteSnapshot = { title: value['title'].trim() };
for (const key of ['posterUrl', 'backdropUrl', 'originalTitle'] as const) {
const field = value[key];
if (typeof field === 'string') snapshot[key] = field;
else if (field === null) snapshot[key] = null;
}
for (const key of ['tmdbId', 'releaseYear'] as const) {
const field = value[key];
if (typeof field === 'number' && Number.isInteger(field) && field > 0)
snapshot[key] = field;
else if (field === null) snapshot[key] = null;
}
return snapshot;
}