Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/stalker-synced-history-artwork.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changes/xtream-synced-history-artwork.md
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 135 additions & 0 deletions apps/electron-backend-e2e/src/history-provider-sync.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
saveSourceDialog,
sourceRowByTitle,
openGlobalRecent,
goToDashboard,
clearCurrentUnifiedCollection,
addXtreamPortal,
addStalkerPortal,
Expand Down Expand Up @@ -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<ReturnType<typeof launchElectronApp>> | 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 });
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture/sqlite-db-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture/stalker-portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -43,7 +45,10 @@ describe('received Stalker collection metadata', () => {
},
{
provide: ParentalLockService,
useValue: { version: () => version },
useValue: {
version: () => version,
isStalkerCategoryLocked,
},
},
{
provide: StalkerFavoritePlaybackResolver,
Expand Down Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<T extends StalkerPortalItem>(playlist: PlaylistMeta, item: T): T {
this.revision();
const reference = stalkerFavoriteReference(item);
Expand Down
2 changes: 2 additions & 0 deletions libs/shared/interfaces/src/lib/electron-api.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading