refactor(portals): lift the VOD route's orchestration out of the component

The details route had grown to 864 lines — the repository's hard maximum is
400, and while the file predates the rule, a baselined exemption is not a
budget to spend.

Three component-provided services now hold what the component was
accumulating: `VodDetailsMultiSourceUiService` (the playback-evidence latch,
the caption, the primary button's position, source actions and the failover
toast), `VodDetailsSimilarService` (the rail and its cross-portal lookup), and
`VodDetailsDownloadsService`. The component keeps its public API, so the
template and the existing specs are untouched. 864 -> 566 lines.

The downloads move also fixes a latent bug: `downloadVod` and `playFromLocal`
read `route.snapshot.params`, which is stale once the router reuses this
component for detail-to-detail navigation (the Similar rail) — so a download
started from a film reached that way fetched the previous one. They now read
the same route-params signal everything else uses, with a regression test.

Also from review: pins are applied on the FRESH-import path too. A new
playlist has no content when the archive is read, so its user state is parked
and replayed after the import — the merge path I wired first never ran there,
and every pin was dropped. A failed pin write now propagates instead of being
ignored: the backup entry is reported failed, and the parked state is kept so
a transient failure can be retried rather than silently losing the preference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 20:38:09 +02:00
1 parent 7eaa681352
commit a25412ea61
12 files changed
+761 -356

No files matched your search

@@ -867,13 +867,22 @@ export class PlaylistBackupService {
// The match key identifies the film and survives untouched; only the
// playlist has a new id in this installation.
for (const pin of state.sourcePins ?? []) {
await this.vodSourcePinService.set({
const written = await this.vodSourcePinService.set({
matchKey: pin.matchKey,
playlistId,
contentId: pin.contentId,
portalType: 'xtream',
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
});
// `set` reports a failed write rather than throwing, so ignoring
// it would drop the preference while the summary claims the
// import succeeded.
if (!written) {
throw new PlaylistBackupError(
`Restoring the pinned source for "${pin.matchKey}" failed.`
);
}
}
}
@@ -254,6 +254,31 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
});
});
it('reports a pin that could not be written', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
// `set` reports failure rather than throwing, so ignoring the
// result would drop the preference while the summary claims
// the import succeeded.
set: jest.fn().mockResolvedValue(false),
},
});
const manifest = createXtreamManifest(
[],
[{ matchKey: 'tmdb:603', contentId: 501 }]
);
const summary = await service.importBackup(JSON.stringify(manifest));
expect(summary).toEqual(
expect.objectContaining({ merged: 0, failed: 1 })
);
});
it('imports an archive written before pins existed', async () => {
const collaborators = createRestoreCollaborators();
const setPin = jest.fn().mockResolvedValue(true);