diff --git a/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts index db23e316d..ed2ec4808 100644 --- a/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts @@ -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 } ); diff --git a/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts index 684c4c7d7..76c34c30b 100644 --- a/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts @@ -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( diff --git a/apps/electron-backend/src/app/database/operations/favorite-catalog.ts b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts index a78decce4..81c0c0996 100644 --- a/apps/electron-backend/src/app/database/operations/favorite-catalog.ts +++ b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts @@ -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, diff --git a/apps/electron-backend/src/app/database/operations/favorite-queries.ts b/apps/electron-backend/src/app/database/operations/favorite-queries.ts index b73851f63..09f6ac7b0 100644 --- a/apps/electron-backend/src/app/database/operations/favorite-queries.ts +++ b/apps/electron-backend/src/app/database/operations/favorite-queries.ts @@ -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`COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title})`, + title: sql`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, diff --git a/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts b/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts index ed82d193a..8fc788a59 100644 --- a/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts +++ b/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts @@ -11,6 +11,7 @@ describe('durable Xtream favorites against initialized SQLite', () => { 'restore', 'identity', 'collision', + 'snapshot', 'scope', 'lock', 'receipt', diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts index 254490928..ab8beb638 100644 --- a/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts @@ -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'); diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts new file mode 100644 index 000000000..5bac10c7b --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts @@ -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 + ); +} diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 186ba0e20..cbc6786bb 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -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 diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 9f102efb6..df292fa31 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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. diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts index 9c59d8f90..124584e15 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab-remote-control.ts @@ -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); } }); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts index 4da359c90..8f5353148 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts @@ -24,11 +24,9 @@ describe('UnifiedLiveTabComponent remote control', () => { let fixture: ComponentFixture; 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; } diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts index 28b1940e5..e04fcf02d 100644 --- a/libs/services/src/lib/database-electron.service.spec.ts +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -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', diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index ce45cc728..52b62bbf5 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -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) { diff --git a/libs/services/src/lib/xtream-pending-favorites.ts b/libs/services/src/lib/xtream-pending-favorites.ts index 061e3af3f..2418c0a86 100644 --- a/libs/services/src/lib/xtream-pending-favorites.ts +++ b/libs/services/src/lib/xtream-pending-favorites.ts @@ -10,7 +10,8 @@ export async function materializePendingXtreamFavorites( ElectronBridgeApi, 'dbGetPlaylist' | 'dbRestoreXtreamFavorites' >, - playlistId?: string + playlistId?: string, + failureMode: 'throw' | 'continue' = 'throw' ): Promise { 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.'); + } } } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-favorite-translations.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-favorite-translations.spec.ts new file mode 100644 index 000000000..b7a39f023 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-favorite-translations.spec.ts @@ -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' + ); + } + ); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 6c6e365d0..4a125ecb8 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -155,12 +155,13 @@ export class WorkspaceDashboardRailsComponent { this.continueWatchingBaseCards() ); - readonly liveFavoriteCards = computed(() => - this.data + readonly liveFavoriteCards = computed(() => { + this.languageTick(); + return this.data .globalFavoriteLiveItems() .slice(0, RAIL_ITEM_LIMIT) - .map((item) => this.toFavoriteCard(item)) - ); + .map((item) => this.toFavoriteCard(item)); + }); readonly recentLiveCards = computed(() => this.data @@ -218,13 +219,14 @@ export class WorkspaceDashboardRailsComponent { this.liveEpg.enrich(this.recentLiveCards()) ); - readonly favoriteMoviesAndSeriesCards = computed(() => - this.data + readonly favoriteMoviesAndSeriesCards = computed(() => { + 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())