mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): protect favorite snapshots and collection navigation
This commit is contained in:
1 parent
8ccfcb68cc
commit
e5a5d0581d
16 files changed
+427
-50
No files matched your search
@@ -55,16 +55,6 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit
|
||||
'movie'
|
||||
);
|
||||
if (!row) throw new Error('Expected imported content');
|
||||
const metadata =
|
||||
await window.electron.dbSetContentMetadataIfMissing(
|
||||
row.id,
|
||||
{
|
||||
tmdbId: 12345,
|
||||
releaseYear: 2000,
|
||||
}
|
||||
);
|
||||
if (!metadata.success)
|
||||
throw new Error('Could not set current movie identity');
|
||||
const restored = await window.electron.dbRestoreXtreamFavorites(
|
||||
playlistId,
|
||||
[
|
||||
@@ -82,6 +72,28 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit
|
||||
);
|
||||
if (!restored.success)
|
||||
throw new Error('Could not seed the older favorite');
|
||||
const completed = await window.electron.dbSetAppState(
|
||||
`xtream-import-status:${playlistId}:movie`,
|
||||
'completed'
|
||||
);
|
||||
if (!completed.success)
|
||||
throw new Error('Could not complete the catalog');
|
||||
const [saved] =
|
||||
await window.electron.dbGetFavoritesForBackup(playlistId);
|
||||
if (saved?.title !== oldTitle)
|
||||
throw new Error(
|
||||
'Unconfirmed catalog identity replaced the saved title'
|
||||
);
|
||||
const metadata =
|
||||
await window.electron.dbSetContentMetadataIfMissing(
|
||||
row.id,
|
||||
{
|
||||
tmdbId: 12345,
|
||||
releaseYear: 2000,
|
||||
}
|
||||
);
|
||||
if (!metadata.success)
|
||||
throw new Error('Could not set current movie identity');
|
||||
},
|
||||
{ playlistId, xtreamId: Number(item.stream_id), oldTitle }
|
||||
);
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
ContentMetadataPatch,
|
||||
normalizeContentMetadataPatch,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { favoriteMatchesContentId } from './favorite-catalog';
|
||||
import {
|
||||
favoriteMatchesContentId,
|
||||
favoriteSnapshotMatchesContent,
|
||||
} from './favorite-catalog';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
|
||||
/**
|
||||
@@ -118,7 +121,12 @@ export async function persistContentMetadataIfMissing(
|
||||
])
|
||||
)
|
||||
)
|
||||
.where(favoriteMatchesContentId(contentId));
|
||||
.where(
|
||||
and(
|
||||
favoriteMatchesContentId(contentId),
|
||||
sql`EXISTS (SELECT 1 FROM content WHERE id = ${contentId} AND ${favoriteSnapshotMatchesContent()})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function setContentMetadataIfMissing(
|
||||
|
||||
@@ -17,6 +17,12 @@ export function resolvedFavoriteContentId() {
|
||||
ORDER BY candidate.id LIMIT 1)`;
|
||||
}
|
||||
|
||||
/** Missing candidate metadata cannot authorize replacing an already known snapshot. */
|
||||
export function favoriteSnapshotMatchesContent() {
|
||||
return sql`(${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = ${schema.content.tmdbId})
|
||||
AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = ${schema.content.releaseYear})`;
|
||||
}
|
||||
|
||||
export function favoriteCatalogStatus() {
|
||||
return sql<'present' | 'missing' | 'unknown'>`CASE
|
||||
WHEN ${schema.appState.value} IN ('importing', 'failed', 'cancelled') THEN 'unknown'
|
||||
@@ -92,6 +98,7 @@ export async function reconcileCompletedFavoriteCatalog(
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
sql`EXISTS (SELECT 1 FROM content WHERE id = favorites.content_id AND ${favoriteSnapshotMatchesContent()})`,
|
||||
eq(schema.favorites.playlistId, match[1]),
|
||||
eq(
|
||||
schema.favorites.type,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AppDatabase } from '../database.types';
|
||||
import { unlockedCategoryCondition } from '../parental-lock-state';
|
||||
import {
|
||||
favoriteCatalogStatus,
|
||||
favoriteSnapshotMatchesContent,
|
||||
favoriteImportStateKey,
|
||||
resolvedFavoriteContentId,
|
||||
} from './favorite-catalog';
|
||||
@@ -20,24 +21,24 @@ export function selectFavoriteRows(
|
||||
favorite_id: schema.favorites.id,
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: sql<string>`COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title})`,
|
||||
title: sql<string>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title}) ELSE ${schema.favorites.title} END`,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: sql<
|
||||
string | null
|
||||
>`COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl})`,
|
||||
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl}) ELSE ${schema.favorites.posterUrl} END`,
|
||||
backdrop_url: sql<
|
||||
string | null
|
||||
>`COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl})`,
|
||||
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl}) ELSE ${schema.favorites.backdropUrl} END`,
|
||||
tmdb_id: sql<
|
||||
number | null
|
||||
>`COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId})`,
|
||||
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId}) ELSE ${schema.favorites.tmdbId} END`,
|
||||
release_year: sql<
|
||||
number | null
|
||||
>`COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear})`,
|
||||
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear}) ELSE ${schema.favorites.releaseYear} END`,
|
||||
original_title: sql<
|
||||
string | null
|
||||
>`COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle})`,
|
||||
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle}) ELSE ${schema.favorites.originalTitle} END`,
|
||||
xtream_id: schema.favorites.xtreamId,
|
||||
type: schema.favorites.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('durable Xtream favorites against initialized SQLite', () => {
|
||||
'restore',
|
||||
'identity',
|
||||
'collision',
|
||||
'snapshot',
|
||||
'scope',
|
||||
'lock',
|
||||
'receipt',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { verifyFavoriteSnapshot } from './favorites-snapshot';
|
||||
import { createFavoriteTestCatalog } from './favorites-test-catalog';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { closeDatabase, initDatabase } from '@iptvnator/shared/database';
|
||||
@@ -269,6 +270,8 @@ async function main() {
|
||||
});
|
||||
await status('completed');
|
||||
assert.equal((await favorites())[0]?.id, 4);
|
||||
} else if (scenario === 'snapshot') {
|
||||
await verifyFavoriteSnapshot(db);
|
||||
} else if (scenario === 'scope') {
|
||||
await catalog(2, 7, 'Other', 'movie', 'other');
|
||||
await addFavorite(db, 2, 'other');
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../../database.types';
|
||||
import { createFavoriteTestCatalog } from './favorites-test-catalog';
|
||||
import { persistContentMetadataIfMissing } from '../content-metadata.operations';
|
||||
import {
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
getFavorites,
|
||||
getFavoritesForBackup,
|
||||
} from '../favorites.operations';
|
||||
import { deleteXtreamContent } from '../xtream.operations';
|
||||
import { setAppState } from '../playlist.operations';
|
||||
|
||||
export async function verifyFavoriteSnapshot(db: AppDatabase) {
|
||||
const catalog = createFavoriteTestCatalog(db);
|
||||
await persistContentMetadataIfMissing(db, 1, {
|
||||
tmdbId: 10,
|
||||
releaseYear: 2000,
|
||||
originalTitle: 'Original',
|
||||
});
|
||||
const [original] = await db.select().from(schema.favorites);
|
||||
const snapshot = (row: typeof original) => ({
|
||||
title: row.title,
|
||||
poster: row.posterUrl,
|
||||
backdrop: row.backdropUrl,
|
||||
original: row.originalTitle,
|
||||
tmdb: row.tmdbId,
|
||||
year: row.releaseYear,
|
||||
});
|
||||
await deleteXtreamContent(db, 'p');
|
||||
await catalog(2, 7, 'Replacement', 'movie', 'p', {
|
||||
posterUrl: 'https://art.invalid/replacement.jpg',
|
||||
backdropUrl: 'https://art.invalid/replacement-wide.jpg',
|
||||
originalTitle: 'Replacement original',
|
||||
});
|
||||
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
|
||||
assert.deepEqual(
|
||||
snapshot((await db.select().from(schema.favorites))[0]),
|
||||
snapshot(original),
|
||||
'unknown catalog identity must not overwrite the durable snapshot'
|
||||
);
|
||||
for (const read of [getFavorites, getFavoritesForBackup]) {
|
||||
const [row] = await read(db, 'p');
|
||||
assert.equal(row.title, original.title);
|
||||
assert.equal(row.poster_url, original.posterUrl);
|
||||
assert.equal(row.backdrop_url, original.backdropUrl);
|
||||
assert.equal(row.original_title, original.originalTitle);
|
||||
}
|
||||
await persistContentMetadataIfMissing(db, 2, {
|
||||
tmdbId: 20,
|
||||
releaseYear: 2020,
|
||||
});
|
||||
assert.equal((await getFavorites(db, 'p'))[0].catalog_status, 'missing');
|
||||
assert.equal((await getFavorites(db, 'p'))[0].title, original.title);
|
||||
await deleteXtreamContent(db, 'p');
|
||||
await catalog(3, 7, 'ABC renamed', 'movie', 'p', { tmdbId: 10 });
|
||||
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
|
||||
assert.deepEqual(
|
||||
snapshot((await db.select().from(schema.favorites))[0]),
|
||||
snapshot(original),
|
||||
'partial identity is insufficient to replace the saved fields'
|
||||
);
|
||||
await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 });
|
||||
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
|
||||
assert.equal(
|
||||
(await db.select().from(schema.favorites))[0].title,
|
||||
'ABC renamed',
|
||||
'confirmed compatible identity may refresh the snapshot'
|
||||
);
|
||||
// A replacement's year/artwork must not fill gaps before its TMDB identity is known.
|
||||
await removeFavorite(db, 3, 'p');
|
||||
await deleteXtreamContent(db, 'p');
|
||||
await catalog(4, 7, 'Known TMDB only', 'movie', 'p', {
|
||||
tmdbId: 10,
|
||||
backdropUrl: null,
|
||||
});
|
||||
await addFavorite(db, 4, 'p');
|
||||
await deleteXtreamContent(db, 'p');
|
||||
await catalog(5, 7, 'Year without identity', 'movie', 'p', {
|
||||
releaseYear: 2020,
|
||||
backdropUrl: null,
|
||||
});
|
||||
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
|
||||
await persistContentMetadataIfMissing(db, 5, {
|
||||
backdropUrl: 'https://art.invalid/unverified.jpg',
|
||||
});
|
||||
const [partial] = await getFavoritesForBackup(db, 'p');
|
||||
assert.equal(partial.release_year, null);
|
||||
assert.equal(partial.backdrop_url, null);
|
||||
assert.equal(
|
||||
(await db.select().from(schema.favorites))[0].backdropUrl,
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -279,6 +279,9 @@ Implemented behavior:
|
||||
- Channel up/down and number select navigate `visibleChannels()` — the
|
||||
search-filtered, mode-conditionally sorted list derived by the same
|
||||
`deriveVisibleFavoriteChannels` helper the rendered sidebar uses
|
||||
- Up/down skips favorites marked missing or unknown. Number select and status
|
||||
retain the full visible list's numbering; selecting an unavailable row by
|
||||
number does nothing.
|
||||
- Remote actions play via the same path as an explicit double-click
|
||||
(`activateItem(item, false, true)`), so external MPV/VLC starts
|
||||
immediately
|
||||
|
||||
@@ -50,8 +50,10 @@ Favorite rows retain a durable `favorite_id`, saved title/artwork, added date
|
||||
and order. Their `id`/`category_id` may be null; consumers must not invent a
|
||||
catalog ID. Remove/reorder can use the stable favorite ID while detail toggles
|
||||
resolve any category row through the same typed provider identity. Completed
|
||||
imports reconnect valid identities and refresh display snapshots. Metadata
|
||||
learned in detail views is copied into the saved snapshot before catalog loss.
|
||||
imports reconnect valid identities. Display snapshots are replaced only after
|
||||
the returning row confirms every saved TMDB ID/year; incomplete identity keeps
|
||||
the saved title/artwork in collections and backups. Metadata learned in detail
|
||||
views is copied into the saved snapshot before catalog loss.
|
||||
Conflicting known TMDB IDs or release years prevent automatic reconnection;
|
||||
changed provider IDs never reconnect by title.
|
||||
Adding a conflicting replacement returns an explicit failure and retains the
|
||||
@@ -72,8 +74,10 @@ stored but are withheld while locks apply.
|
||||
|
||||
Legacy pending favorites are converted before destructive refresh, import
|
||||
requests and ordinary collection reads, even if portal-status preflight is
|
||||
offline. Backup merges convert any older pending favorites before parking the
|
||||
incoming archive. Favorite inserts and a replay receipt commit together;
|
||||
offline. Ordinary collection reads isolate failed conversions per playlist and
|
||||
still read healthy database favorites, retaining pending state for retry. Backup
|
||||
reads remain strict and fail if conversion fails. Backup merges convert any
|
||||
older pending favorites before parking the incoming archive. Favorite inserts and a replay receipt commit together;
|
||||
retries cannot resurrect a subsequently removed favorite. Deliberate backup import creates a
|
||||
fresh receipt ID, which stays internal and is never exported. Identity-only old
|
||||
backups use a labelled saved-item fallback until catalog metadata returns.
|
||||
|
||||
+5
-2
@@ -7,6 +7,7 @@ import {
|
||||
getChannelItemByNumber,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||
|
||||
/**
|
||||
@@ -65,7 +66,9 @@ export function setupUnifiedLiveTabRemoteControl(
|
||||
}
|
||||
|
||||
const nextChannel = getAdjacentChannelItem(
|
||||
[...host.visibleChannels()],
|
||||
host
|
||||
.visibleChannels()
|
||||
.filter((channel) => !isCatalogUnavailable(channel)),
|
||||
activeUid,
|
||||
data.direction,
|
||||
(channel) => channel.uid
|
||||
@@ -84,7 +87,7 @@ export function setupUnifiedLiveTabRemoteControl(
|
||||
[...host.visibleChannels()],
|
||||
command.number
|
||||
);
|
||||
if (channel) {
|
||||
if (channel && !isCatalogUnavailable(channel)) {
|
||||
host.playChannel(channel);
|
||||
}
|
||||
});
|
||||
|
||||
+47
-5
@@ -24,11 +24,9 @@ describe('UnifiedLiveTabComponent remote control', () => {
|
||||
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
|
||||
let component: UnifiedLiveTabComponent;
|
||||
let channelChangeCallback:
|
||||
| ((data: { direction: 'up' | 'down' }) => void)
|
||||
| undefined;
|
||||
((data: { direction: 'up' | 'down' }) => void) | undefined;
|
||||
let remoteCommandCallback:
|
||||
| ((command: { type: string; number?: number }) => void)
|
||||
| undefined;
|
||||
((command: { type: string; number?: number }) => void) | undefined;
|
||||
let unsubscribeChannelChange: jest.Mock;
|
||||
let unsubscribeCommand: jest.Mock;
|
||||
let updateRemoteControlStatus: jest.Mock;
|
||||
@@ -209,6 +207,47 @@ describe('UnifiedLiveTabComponent remote control', () => {
|
||||
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['missing', 'unknown'] as const)(
|
||||
'skips %s favorites in both directions while retaining visible numbering',
|
||||
async (catalogStatus) => {
|
||||
await configure();
|
||||
await setItems([
|
||||
buildItem('xtream::pl-1::1', 'Alpha'),
|
||||
{ ...buildItem('xtream::pl-1::2', 'Bravo'), catalogStatus },
|
||||
buildItem('xtream::pl-1::3', 'Charlie'),
|
||||
]);
|
||||
await activate('xtream::pl-1::1');
|
||||
for (const [direction, expectedUid, expectedNumber] of [
|
||||
['down', 'xtream::pl-1::3', 3],
|
||||
['down', 'xtream::pl-1::1', 1],
|
||||
['up', 'xtream::pl-1::3', 3],
|
||||
['up', 'xtream::pl-1::1', 1],
|
||||
] as const) {
|
||||
channelChangeCallback?.({ direction });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(component.activeUid()).toBe(expectedUid);
|
||||
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ channelNumber: expectedNumber })
|
||||
);
|
||||
}
|
||||
remoteCommandCallback?.({
|
||||
type: 'channel-select-number',
|
||||
number: 2,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(component.activeUid()).toBe('xtream::pl-1::1');
|
||||
remoteCommandCallback?.({
|
||||
type: 'channel-select-number',
|
||||
number: 3,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(component.activeUid()).toBe('xtream::pl-1::3');
|
||||
}
|
||||
);
|
||||
|
||||
it('wraps upward from the first visible channel to the last', async () => {
|
||||
await configure();
|
||||
await setItems([
|
||||
@@ -335,7 +374,10 @@ function buildItem(
|
||||
...(sourceType === 'xtream'
|
||||
? { xtreamId: Number(sourceItemId) }
|
||||
: sourceType === 'stalker'
|
||||
? { stalkerId: sourceItemId, stalkerCmd: `ffmpeg http://s/${sourceItemId}` }
|
||||
? {
|
||||
stalkerId: sourceItemId,
|
||||
stalkerCmd: `ffmpeg http://s/${sourceItemId}`,
|
||||
}
|
||||
: { streamUrl: `https://example.com/${sourceItemId}.m3u8` }),
|
||||
} as UnifiedCollectionItem;
|
||||
}
|
||||
@@ -86,6 +86,64 @@ describe('DatabaseService browser guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'getGlobalFavorites',
|
||||
'getAllGlobalFavorites',
|
||||
'getFavorites',
|
||||
] as const)(
|
||||
'%s still reads healthy favorites when a pending conversion fails',
|
||||
async (method) => {
|
||||
const pending = JSON.stringify({
|
||||
favorites: [{ contentType: 'movie', xtreamId: 7.5 }],
|
||||
});
|
||||
localStorage.setItem('xtream-restore-broken', pending);
|
||||
localStorage.setItem(
|
||||
'xtream-restore-healthy',
|
||||
JSON.stringify({
|
||||
favorites: [{ contentType: 'movie', xtreamId: 9 }],
|
||||
})
|
||||
);
|
||||
const rows = [{ favorite_id: 1, title: 'Saved favorite' }];
|
||||
const read = jest.fn().mockResolvedValue(rows);
|
||||
const restore = jest.fn(async (id: string) => {
|
||||
if (id === 'broken')
|
||||
throw new Error('Invalid favorite identity');
|
||||
return { success: true };
|
||||
});
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dbGetPlaylist: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'xtream' }),
|
||||
dbRestoreXtreamFavorites: restore,
|
||||
dbGetGlobalFavorites: read,
|
||||
dbGetAllGlobalFavorites: read,
|
||||
dbGetFavorites: read,
|
||||
dbGetFavoritesForBackup: jest.fn(),
|
||||
},
|
||||
});
|
||||
await expect(service[method]('broken')).resolves.toEqual(rows);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
if (method !== 'getFavorites') {
|
||||
expect(restore).toHaveBeenCalledWith(
|
||||
'healthy',
|
||||
[{ contentType: 'movie', xtreamId: 9 }],
|
||||
undefined
|
||||
);
|
||||
}
|
||||
expect(localStorage.getItem('xtream-restore-broken')).toBe(pending);
|
||||
restore.mockResolvedValueOnce({ success: false });
|
||||
await expect(service.getFavorites('broken')).resolves.toEqual(rows);
|
||||
await expect(service.getFavoritesOrThrow('broken')).rejects.toThrow(
|
||||
'Invalid favorite identity'
|
||||
);
|
||||
expect(
|
||||
window.electron.dbGetFavoritesForBackup
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
|
||||
localStorage.setItem(
|
||||
'xtream-restore-orphan',
|
||||
|
||||
@@ -741,7 +741,11 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
await materializePendingXtreamFavorites(window.electron);
|
||||
await materializePendingXtreamFavorites(
|
||||
window.electron,
|
||||
undefined,
|
||||
'continue'
|
||||
);
|
||||
const items = await window.electron.dbGetGlobalFavorites();
|
||||
return items || [];
|
||||
} catch (error) {
|
||||
@@ -759,7 +763,11 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
await materializePendingXtreamFavorites(window.electron);
|
||||
await materializePendingXtreamFavorites(
|
||||
window.electron,
|
||||
undefined,
|
||||
'continue'
|
||||
);
|
||||
const items = await window.electron.dbGetAllGlobalFavorites();
|
||||
return items || [];
|
||||
} catch (error) {
|
||||
@@ -901,7 +909,8 @@ export class DatabaseService {
|
||||
try {
|
||||
await materializePendingXtreamFavorites(
|
||||
window.electron,
|
||||
playlistId
|
||||
playlistId,
|
||||
'continue'
|
||||
);
|
||||
return await window.electron.dbGetFavorites(playlistId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,7 +10,8 @@ export async function materializePendingXtreamFavorites(
|
||||
ElectronBridgeApi,
|
||||
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
|
||||
>,
|
||||
playlistId?: string
|
||||
playlistId?: string,
|
||||
failureMode: 'throw' | 'continue' = 'throw'
|
||||
): Promise<void> {
|
||||
const prefix = getXtreamPendingRestoreStorageKey('');
|
||||
const ids = playlistId
|
||||
@@ -35,14 +36,20 @@ export async function materializePendingXtreamFavorites(
|
||||
if (!state.favorites.length) continue;
|
||||
// Old localStorage can outlive deletion of its owning SQLite playlist.
|
||||
// Never recreate a playlist or let an orphan block other saved entries.
|
||||
const playlist = await bridge.dbGetPlaylist(id);
|
||||
if (!playlist || playlist.type !== 'xtream') continue;
|
||||
const result = await bridge.dbRestoreXtreamFavorites(
|
||||
id,
|
||||
state.favorites,
|
||||
state.favoritesRestoreId
|
||||
);
|
||||
if (!result.success)
|
||||
throw new Error('Recovering saved favorites failed.');
|
||||
try {
|
||||
const playlist = await bridge.dbGetPlaylist(id);
|
||||
if (!playlist || playlist.type !== 'xtream') continue;
|
||||
const result = await bridge.dbRestoreXtreamFavorites(
|
||||
id,
|
||||
state.favorites,
|
||||
state.favoritesRestoreId
|
||||
);
|
||||
if (!result.success)
|
||||
throw new Error('Recovering saved favorites failed.');
|
||||
} catch (error) {
|
||||
if (failureMode === 'throw') throw error;
|
||||
// Keep pending state for retry while ordinary reads show healthy data.
|
||||
console.warn('A pending favorites conversion could not complete.');
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
|
||||
import {
|
||||
PlaylistDeleteActionService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
DashboardDataService,
|
||||
DashboardSourceExpiryService,
|
||||
DashboardTrendingService,
|
||||
DashboardRecommendationsService,
|
||||
type DashboardFavoriteItem,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import { WorkspaceDashboardRailsComponent } from './workspace-dashboard-rails.component';
|
||||
|
||||
describe('dashboard favorite status translations', () => {
|
||||
it.each([
|
||||
'onLangChange',
|
||||
'onDefaultLangChange',
|
||||
'onTranslationChange',
|
||||
] as const)(
|
||||
'refreshes both rails on %s without reloading favorites',
|
||||
(event) => {
|
||||
let language = 'en';
|
||||
const events = {
|
||||
onLangChange: new Subject(),
|
||||
onDefaultLangChange: new Subject(),
|
||||
onTranslationChange: new Subject(),
|
||||
};
|
||||
const saved: DashboardFavoriteItem = {
|
||||
id: 1,
|
||||
favorite_id: 1,
|
||||
source: 'xtream',
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: 7,
|
||||
type: 'movie',
|
||||
title: 'Saved',
|
||||
added_at: '2026-01-01',
|
||||
catalogStatus: 'missing',
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
...[
|
||||
MatDialog,
|
||||
MatSnackBar,
|
||||
Router,
|
||||
Store,
|
||||
DialogService,
|
||||
PlaylistDeleteActionService,
|
||||
PlaylistRefreshActionService,
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
DashboardTrendingService,
|
||||
DashboardRecommendationsService,
|
||||
].map((provide) => ({ provide, useValue: {} })),
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
...events,
|
||||
instant: (key: string) => `${language}:${key}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: {
|
||||
globalFavoriteItems: signal([saved]),
|
||||
globalFavoriteLiveItems: signal([
|
||||
{
|
||||
...saved,
|
||||
type: 'live',
|
||||
catalogStatus: 'unknown',
|
||||
},
|
||||
]),
|
||||
getGlobalFavoriteLink: () => [],
|
||||
getGlobalFavoriteNavigationState: () => undefined,
|
||||
reloadGlobalRecentItems: jest.fn(),
|
||||
reloadGlobalFavorites: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DashboardLiveEpgPresenter,
|
||||
useValue: { connect: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: DashboardSourceExpiryService,
|
||||
useValue: { facts: signal(new Map()) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const component = TestBed.runInInjectionContext(
|
||||
() => new WorkspaceDashboardRailsComponent()
|
||||
);
|
||||
expect(component.liveFavoriteCards()[0].subtitle).toBe(
|
||||
'en:WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
|
||||
);
|
||||
expect(component.favoriteMoviesAndSeriesCards()[0].subtitle).toBe(
|
||||
'en:WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
|
||||
);
|
||||
language = 'de';
|
||||
events[event].next({});
|
||||
expect(component.liveFavoriteCards()[0].subtitle).toBe(
|
||||
'de:WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
|
||||
);
|
||||
expect(component.favoriteMoviesAndSeriesCards()[0].subtitle).toBe(
|
||||
'de:WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
+10
-8
@@ -155,12 +155,13 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.continueWatchingBaseCards()
|
||||
);
|
||||
|
||||
readonly liveFavoriteCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
readonly liveFavoriteCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.data
|
||||
.globalFavoriteLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toFavoriteCard(item))
|
||||
);
|
||||
.map((item) => this.toFavoriteCard(item));
|
||||
});
|
||||
|
||||
readonly recentLiveCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
@@ -218,13 +219,14 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.liveEpg.enrich(this.recentLiveCards())
|
||||
);
|
||||
|
||||
readonly favoriteMoviesAndSeriesCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
readonly favoriteMoviesAndSeriesCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.data
|
||||
.globalFavoriteItems()
|
||||
.filter((item) => item.type === 'movie' || item.type === 'series')
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toFavoriteCard(item))
|
||||
);
|
||||
.map((item) => this.toFavoriteCard(item));
|
||||
});
|
||||
|
||||
readonly continueWatchingSeeAllState = computed(() =>
|
||||
buildDashboardRailSeeAllState(this.continueWatchingBaseCards())
|
||||
|
||||
Reference in new issue
Block a user