mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
fix(xtream): preserve favorites across catalog refreshes
This commit is contained in:
1 parent
ec4b9286a7
commit
8ccfcb68cc
121 files changed
+4000
-398
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user