From 5944fcaad0937a5451b3ff6e0a6b28dd653bfedb Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Nov 2026 18:28:25 +0100 Subject: [PATCH] fix(dashboard): restore artwork for synced viewing history --- .changes/stalker-synced-history-artwork.md | 6 + .changes/xtream-synced-history-artwork.md | 6 + .../src/history-provider-sync.e2e.ts | 135 ++++++++++++++++++ .../operations/content-identity-lookup.ts | 6 +- .../testing/favorites-compatible-actions.ts | 15 ++ docs/architecture/sqlite-db-worker.md | 4 + docs/architecture/stalker-portal.md | 3 + ...talker-collection-metadata.service.spec.ts | 61 +++++++- .../stalker-collection-metadata.service.ts | 58 +++++++- .../src/lib/electron-api.interface.ts | 2 + 10 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 .changes/stalker-synced-history-artwork.md create mode 100644 .changes/xtream-synced-history-artwork.md diff --git a/.changes/stalker-synced-history-artwork.md b/.changes/stalker-synced-history-artwork.md new file mode 100644 index 0000000000..4c0cce21d0 --- /dev/null +++ b/.changes/stalker-synced-history-artwork.md @@ -0,0 +1,6 @@ +--- +type: fix +area: stalker +--- + +Stalker titles in Continue Watching use a saved favorite poster when the same title is in synced history without artwork. diff --git a/.changes/xtream-synced-history-artwork.md b/.changes/xtream-synced-history-artwork.md new file mode 100644 index 0000000000..62a55c4f4e --- /dev/null +++ b/.changes/xtream-synced-history-artwork.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Synced Xtream movies in Continue Watching show the catalog title and poster when the local catalog confirms the match. diff --git a/apps/electron-backend-e2e/src/history-provider-sync.e2e.ts b/apps/electron-backend-e2e/src/history-provider-sync.e2e.ts index 2e75f94ff7..735bd4e8a3 100644 --- a/apps/electron-backend-e2e/src/history-provider-sync.e2e.ts +++ b/apps/electron-backend-e2e/src/history-provider-sync.e2e.ts @@ -14,6 +14,7 @@ import { saveSourceDialog, sourceRowByTitle, openGlobalRecent, + goToDashboard, clearCurrentUnifiedCollection, addXtreamPortal, addStalkerPortal, @@ -363,3 +364,137 @@ for (const provider of ['xtream', 'stalker'] as const) rmSync(directory, { recursive: true, force: true }); } }); + +test('shows the catalog title and poster for existing synced Xtream history @history @electron @persistence', async ({ + browser, +}) => { + const server = await startSourceSyncServer(); + const context = await browser.newContext({ serviceWorkers: 'block' }); + const directory = mkdtempSync(join(tmpdir(), 'iptvnator-history-artwork-')); + let desktop: Awaited> | undefined; + try { + const page = await context.newPage(); + await page.route('**/assets/app-config.js', (route) => + route.fulfill({ + contentType: 'application/javascript', + body: `window.__IPTVNATOR_CONFIG__=${JSON.stringify({ BACKEND_URL: server.apiUrl })};`, + }) + ); + await page.goto(`${frontend}/workspace/sources`); + const sourceTitle = 'Synthetic shared catalog'; + await addXtreamPortal(page, { + name: sourceTitle, + username: 'marketing', + password: 'marketing', + }); + const { instanceId } = await ( + await fetch(`${server.apiUrl}/v1/instance`) + ).json(); + const inventory = await ( + await fetch(`${server.apiUrl}/v1/sources`) + ).json(); + const id = inventory.entries[0].source.id; + writeFileSync( + join(directory, 'source-server.json'), + JSON.stringify({ apiUrl: server.apiUrl, instanceId }) + ); + + desktop = await launchElectronApp(directory); + const native = desktop.mainWindow; + await openSources(native); + await sourceRowByTitle(native, sourceTitle) + .getByText(sourceTitle, { exact: true }) + .click(); + await waitForXtreamWorkspaceReady(native); + const identity = { tmdbId: 123456, releaseYear: 2024 }; + const movie = await native.evaluate( + async ({ id, identity }) => { + const [item] = await window.electron.dbGetContent(id, 'movie'); + if (!item?.poster_url) + throw new Error('Missing catalog poster'); + await window.electron.dbSetContentMetadataIfMissing( + item.id, + identity + ); + await window.electron.dbSavePlaybackPosition(id, { + contentXtreamId: item.xtream_id, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 7200, + }); + return item; + }, + { id, identity } + ); + + const headers = { 'X-IPTVnator-Instance-Id': instanceId }; + const read = async () => + await ( + await fetch(`${server.apiUrl}/v1/history/sources/${id}`, { + headers, + }) + ).json(); + const { scope, state } = await read(); + const snapshotTitle = 'Synthetic remote playback title'; + const response = await fetch(`${server.apiUrl}/v1/history/operations`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scope, + operation: { + kind: 'touch', + operationId: 'synthetic-existing-watch', + deviceId: 'synthetic-remote', + baseRevision: state.revision, + viewedAt: Date.now(), + reference: { + provider: 'xtream', + kind: 'movie', + id: String(movie.xtream_id), + }, + snapshot: { + title: snapshotTitle, + tmdbId: String(identity.tmdbId), + releaseYear: String(identity.releaseYear), + }, + }, + }), + }); + expect(response.ok).toBe(true); + const before = (await read()).state; + expect(before.items).toHaveLength(1); + + await enable(native); + await goToDashboard(native); + const rail = native.getByTestId('dashboard-continue-watching-rail'); + const card = rail + .getByTestId('dashboard-continue-watching-rail-card') + .filter({ hasText: movie.title }); + await expect(card).toBeVisible(); + const poster = card.getByRole('img', { + name: movie.title, + exact: true, + }); + await expect(poster).toHaveAttribute('src', movie.poster_url); + await expect + .poll(() => + poster.evaluate((image: HTMLImageElement) => image.naturalWidth) + ) + .toBeGreaterThan(0); + await expect( + rail.getByText(snapshotTitle, { exact: true }) + ).toHaveCount(0); + expect( + await native.evaluate( + (id) => window.electron.dbGetRecentItems(id), + id + ) + ).toEqual([]); + expect((await read()).state).toEqual(before); + } finally { + if (desktop) await closeElectronApp(desktop); + await context.close(); + await server.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/electron-backend/src/app/database/operations/content-identity-lookup.ts b/apps/electron-backend/src/app/database/operations/content-identity-lookup.ts index f443111df9..1305ad5976 100644 --- a/apps/electron-backend/src/app/database/operations/content-identity-lookup.ts +++ b/apps/electron-backend/src/app/database/operations/content-identity-lookup.ts @@ -38,7 +38,11 @@ export async function getContentByXtreamId( sql`(${schema.content.releaseYear} IS NULL OR ${schema.content.releaseYear} = ${patch.releaseYear})` ); const query = db - .select(selectContentFields()) + .select({ + ...selectContentFields(), + tmdb_id: schema.content.tmdbId, + release_year: schema.content.releaseYear, + }) .from(schema.content) .innerJoin( schema.categories, diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-compatible-actions.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-compatible-actions.ts index 8c8a3abf80..a2d55a6b4f 100644 --- a/apps/electron-backend/src/app/database/operations/testing/favorites-compatible-actions.ts +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-compatible-actions.ts @@ -40,6 +40,21 @@ export async function verifyCompatibleFavoriteActions(db: AppDatabase) { id + 2, 'identity-qualified actions must resolve the compatible duplicate' ); + assert.deepEqual( + { + title: row.title, + poster: row.poster_url, + tmdbId: row.tmdb_id, + releaseYear: row.release_year, + }, + { + title: 'Displayed original', + poster: 'https://art.invalid/poster.jpg', + tmdbId: 100, + releaseYear: 1995, + }, + 'synchronized history needs the catalog identity with the artwork' + ); assert(await isFavorite(db, row.id, 'p')); await removeFavorite(db, row.id, 'p'); assert.equal( diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index bcb211e962..f23e15f990 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -728,6 +728,10 @@ internal row ID consistently. Import completion reconciles links and saved metadata; detail enrichment also updates the snapshot. Known identity conflicts remain unresolved. See [Xtream compatibility](./xtream-portal-compatibility.md#durable-electron-favorites). +The lookup returns the catalog row's `tmdb_id` and `release_year` with its +title and poster. Synchronized Xtream history uses those returned fields to +confirm the match before showing the catalog title and artwork. + The lookup also accepts an optional local `categoryId` restriction, independent of TMDB/year evidence. Cold detail Back uses it to verify route membership before recovering another category; duplicate typed provider IDs retain their original diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 211fe7156f..3436565dd3 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -2183,3 +2183,6 @@ existing provider resolver and parental checks when reopened. Remove and clear retain durable intent, independently of resume and watched synchronization. Collection favorite membership uses typed references independently of either sync consent, so received history matches local favorites without merging kinds. +Dashboard history cards with missing artwork can use the saved favorite poster +from the same source and exact typed reference, including the series mode. +The favorite's category lock applies, and only the rendered poster is filled. diff --git a/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.spec.ts index ced3e34a2e..30e4072e6a 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.spec.ts @@ -30,9 +30,11 @@ describe('received Stalker collection metadata', () => { let metadata: jest.Mock; let current: Playlist; let version: number; + let isStalkerCategoryLocked: jest.Mock; beforeEach(() => { current = { ...source }; version = 0; + isStalkerCategoryLocked = jest.fn(() => false); metadata = jest.fn(async () => fresh); TestBed.configureTestingModule({ providers: [ @@ -43,7 +45,10 @@ describe('received Stalker collection metadata', () => { }, { provide: ParentalLockService, - useValue: { version: () => version }, + useValue: { + version: () => version, + isStalkerCategoryLocked, + }, }, { provide: StalkerFavoritePlaybackResolver, @@ -89,6 +94,60 @@ describe('received Stalker collection metadata', () => { } ); + it('shows saved favorite artwork on matching history before portal hydration', () => { + const recent = { ...sparse, added_at: '2026-01-01T00:00:00Z' }; + const playlist = { + ...source, + favorites: [{ ...fresh, cover: '/artwork/synthetic.jpg' }], + recentlyViewed: [recent], + }; + + const projected = service.project(playlist).recentlyViewed?.[0]; + expect(projected).toEqual({ + ...recent, + cover: 'https://portal.example/artwork/synthetic.jpg', + }); + expect(recent).not.toHaveProperty('cover'); + }); + + it('keeps artwork already on the history entry', () => { + const recent = { + ...sparse, + cover: 'https://artwork.example/history.jpg', + }; + const playlist = { + ...source, + favorites: [fresh], + recentlyViewed: [recent], + }; + + expect(service.project(playlist).recentlyViewed?.[0]).toBe(recent); + }); + + it.each(['kind', 'mode', 'lock'] as const)( + 'withholds the saved favorite poster when the %s differs', + (reason) => { + const favorite = { ...fresh, category_id: '99' }; + if (reason === 'kind') favorite.series = []; + if (reason === 'mode') { + favorite.series = []; + favorite.is_series = true; + } + if (reason === 'lock') { + isStalkerCategoryLocked.mockImplementation( + (_id, _kind, category) => category === '99' + ); + } + const playlist = { + ...source, + favorites: [favorite], + recentlyViewed: [sparse], + }; + + expect(service.project(playlist).recentlyViewed?.[0]).toBe(sparse); + } + ); + it.each([ [ { screenshot_uri: '/artwork/synthetic.jpg' }, diff --git a/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.ts index d8bc515ade..770cd76503 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-collection-metadata.service.ts @@ -4,9 +4,10 @@ import { ParentalLockService, PlaylistsService } from '@iptvnator/services'; import { captureHistoryOwner } from '@iptvnator/services/history-events'; import { sourceConnection } from '@iptvnator/services/source-sync/connection'; import { favoriteSourceRetirementKey } from '@iptvnator/services/favorites-sync'; -import type { - PlaylistMeta, - StalkerPortalItem, +import { + extractStalkerItemPoster, + type PlaylistMeta, + type StalkerPortalItem, } from '@iptvnator/shared/interfaces'; import { stalkerSessionFingerprint } from '@iptvnator/shared/interfaces/stalker-session'; import { @@ -44,11 +45,60 @@ export class StalkerCollectionMetadataService { typeof item !== 'object' || ('source' in item && item.source === 'm3u') ? item - : this.item(playlist, item) + : this.item( + playlist, + this.withSavedFavoriteArtwork(playlist, item) + ) ), }; } + private withSavedFavoriteArtwork( + playlist: PlaylistMeta, + item: StalkerPortalItem + ): StalkerPortalItem { + const reference = stalkerFavoriteReference(item); + if ( + !reference || + (reference.kind !== 'movie' && reference.kind !== 'series') + ) + return item; + + const base = playlist.portalUrl ?? playlist.url ?? ''; + if (resolveStalkerArtwork(base, extractStalkerItemPoster(item))) + return item; + + const favorite = playlist.favorites?.find( + (entry): entry is StalkerPortalItem => { + if (!entry || typeof entry !== 'object') return false; + const candidate = stalkerFavoriteReference(entry); + return ( + candidate?.id === reference.id && + candidate.kind === reference.kind && + candidate.mode === reference.mode + ); + } + ); + if ( + !favorite || + this.injector + .get(ParentalLockService) + .isStalkerCategoryLocked( + playlist._id, + reference.mode === 'regular' ? 'series' : 'vod', + favorite.category_id + ) + ) + return item; + + const cover = resolveStalkerArtwork( + base, + createStalkerInfo(favorite).movie_image, + extractStalkerItemPoster(favorite) + ); + return cover ? { ...item, cover } : item; + } + item(playlist: PlaylistMeta, item: T): T { this.revision(); const reference = stalkerFavoriteReference(item); diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index d5ff0b53fa..2f7befa13e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -540,6 +540,8 @@ export interface ElectronBridgeXtreamContent { added: string; poster_url: string; backdrop_url?: string | null; + tmdb_id?: number | null; + release_year?: number | null; epg_channel_id?: string | null; tv_archive?: number | null; tv_archive_duration?: number | null;