fix(portals): stop a remake matching, and let a pin survive its own playlist

Three findings from the latest review pass.

`normalizeTitleKeys` strips bracketed segments as tag noise, so "Dune (1984)"
normalizes to exactly "dune" — an EXACT match for the 2021 film, ranked above
every fuzzy one, with the year never consulted because that tier skipped the
gate. Auto-failover could switch the user to the other film entirely. The
year is now read out of brackets too, and a stated disagreement rejects the
row on either tier.

Playback positions are keyed by (playlist, stream), so watching through a
pinned alternative stores progress under ITS ids while the page loads the
route copy's row. Starting the pin therefore resumed from a position
belonging to a different copy — usually zero. It now loads its own.

And a pin can point at another copy of the film inside the playlist being
viewed, which discovery excludes wholesale: the pinned row was absent from
the list, so nothing showed as pinned and Play ignored the preference. The
pin is now read before discovery, which keeps that one row.

Moves the pin-shaped decisions into the pin module, where the persistence
helpers already live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 02:28:26 +02:00
1 parent ed442da3ca
commit f0c99fac87
12 files changed
+341 -65

No files matched your search

+2 -2
View File
@@ -823,9 +823,9 @@ engine` (restart required) or
- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The chip counts alternative **streams**; the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal.
- Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing.
- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — lookups pass every alias most-trusted-first, and writes go to **all** of them, or a lower-trust alias keeps pointing at the source the user just replaced. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — lookups pass every alias most-trusted-first, and writes go to **all** of them, or a lower-trust alias keeps pointing at the source the user just replaced. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only; it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled.
- See `docs/architecture/vod-multi-source.md`
@@ -157,6 +157,31 @@ describe('title-sources.operations', () => {
).resolves.toEqual([]);
});
it('rejects a remake whose year is written in brackets', async () => {
// "Dune (1984)" normalizes to exactly "dune" — the brackets are
// stripped as tag noise — so without reading the year out first it
// is an EXACT match for the 2021 film, ranked above every fuzzy
// one, and auto-failover would switch to the wrong movie.
const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]);
await expect(
findTitleSources(db, { title: 'Dune', year: 2021 })
).resolves.toEqual([]);
});
it('matches a bracketed year against the same film', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune (2021)' }]);
const matches = await findTitleSources(db, {
title: 'Dune',
year: 2021,
});
expect(matches).toHaveLength(1);
// Reported too, so the row can say which film it is.
expect(matches[0].year).toBe(2021);
});
it('rejects a base-tier match whose year contradicts the request', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
@@ -267,6 +292,27 @@ describe('title-sources.operations', () => {
);
});
it('keeps one named copy of the excluded playlist', async () => {
// A pin can point at another copy of the film inside the playlist
// being viewed. Excluding the playlist wholesale would drop that
// row, and the explicit preference would be silently ignored.
const { db, all } = createDbMock([
{ ...duneRow, xtream_id: 777, content_id: 9 },
]);
const matches = await findTitleSources(db, {
title: 'Dune',
excludePlaylistId: 'playlist-1',
keepContentId: 777,
});
const query = compiledQuery(all);
expect(query.sql).toContain('OR c.xtream_id = ?');
expect(query.params).toContain(777);
// And the TypeScript pass must not throw it away either.
expect(matches.map((match) => match.xtreamId)).toEqual([777]);
});
it('leaves the queries unfiltered when no playlist is excluded', async () => {
const { db, all } = createDbMock([duneRow]);
@@ -39,6 +39,28 @@ export interface FindTitleSourcesRequest {
year?: number | null;
/** The playlist the user is already on — never returned as an alternative. */
excludePlaylistId?: string | null;
/**
* One stream id inside that playlist to keep anyway.
*
* A pin can point at another copy of the film in the playlist the user is
* currently on. Excluding the playlist wholesale would drop the pinned row
* from the list, and the explicit preference would be silently ignored.
*/
keepContentId?: number | null;
}
/**
* A year written in brackets — "Dune (1984)", the commonest catalog form.
*
* `normalizeTitleKeys` strips bracketed segments wholesale, because they
* usually hold quality and language tags. A year written that way therefore
* survives in neither key, and both Dunes normalize to exactly "dune" — which
* the exact tier accepts without ever consulting the year, so failover could
* switch the user to the other film entirely.
*/
function bracketedYear(title: string): number | null {
const match = title.match(/[([{]\s*(19\d{2}|20\d{2})\s*[)\]}]/);
return match ? Number(match[1]) : null;
}
function buildFtsMatchQuery(normalizedTitle: string): string {
@@ -59,11 +81,16 @@ function buildFtsMatchQuery(normalizedTitle: string): string {
* never be read at all.
*/
function excludePlaylistClause(
excludePlaylistId: string | null | undefined
excludePlaylistId: string | null | undefined,
keepContentId: number | null | undefined
): SQL {
return excludePlaylistId
? sql`AND cat.playlist_id <> ${excludePlaylistId}`
: sql``;
if (!excludePlaylistId) {
return sql``;
}
return typeof keepContentId === 'number'
? sql`AND (cat.playlist_id <> ${excludePlaylistId} OR c.xtream_id = ${keepContentId})`
: sql`AND cat.playlist_id <> ${excludePlaylistId}`;
}
/**
@@ -166,14 +193,19 @@ export async function findTitleSources(
return [];
}
// The year the caller knows, falling back to a release tag on the title.
const wantedYear = request.year ?? wanted.trailingYear ?? null;
// The year the caller knows, falling back to a release tag on the title
// in either of the two forms providers write it.
const wantedYear =
request.year ?? wanted.trailingYear ?? bracketedYear(rawTitle);
// Search on the year-stripped form so a portal that tags the year and one
// that does not still find each other; the year gate below re-tightens it.
const matchQuery = buildFtsMatchQuery(wanted.base);
const excludePlaylist = excludePlaylistClause(request.excludePlaylistId);
const excludePlaylist = excludePlaylistClause(
request.excludePlaylistId,
request.keepContentId
);
let rows: TitleSourceRow[];
try {
@@ -196,23 +228,30 @@ export async function findTitleSources(
for (const row of rows) {
// SQL already excluded these; this keeps the guarantee a property of
// the function rather than of one WHERE clause.
// the function rather than of one WHERE clause — minus the one copy
// the caller asked to keep.
if (
request.excludePlaylistId &&
row.playlist_id === request.excludePlaylistId
row.playlist_id === request.excludePlaylistId &&
row.xtream_id !== request.keepContentId
) {
continue;
}
const rowKeys = normalizeTitleKeys(row.title);
const rowYear = rowKeys.trailingYear ?? bracketedYear(row.title);
// Two films share a title far too often for this to be optional, and
// it applies to BOTH tiers: "Dune (1984)" normalizes to exactly the
// same string as "Dune", so an exact match says nothing about which
// film it is. When each side states a year and they disagree, it is
// not the same movie. An unknown year still never blocks.
const yearsAgree = titleYearsCompatible(rowYear, wantedYear);
const exactMatch =
rowKeys.exact !== '' && rowKeys.exact === wanted.exact;
rowKeys.exact !== '' &&
rowKeys.exact === wanted.exact &&
yearsAgree;
const baseMatch =
!exactMatch &&
rowKeys.base === wanted.base &&
// A year-stripped match is only trustworthy when the two sides do
// not actively disagree about the year ("Dune" 1984 vs 2021).
titleYearsCompatible(rowKeys.trailingYear, wantedYear);
!exactMatch && rowKeys.base === wanted.base && yearsAgree;
if (!exactMatch && !baseMatch) {
continue;
@@ -232,7 +271,7 @@ export async function findTitleSources(
title: row.title,
posterUrl: row.poster_url,
matchConfidence: exactMatch ? 'exact' : 'fuzzy',
year: rowKeys.trailingYear,
year: rowYear,
});
}
+15
View File
@@ -210,6 +210,21 @@ Both remain necessary-not-sufficient filters: the two-tier normalized
confirmation still runs afterwards, so the looser query never admits "Upgrade"
for "Up".
One row inside the excluded playlist is kept when the caller names it
(`keepContentId`): a pin can point at another copy of the film in the playlist
being viewed, and dropping that row would leave the preference pointing at
nothing. The host therefore reads the pin BEFORE discovery.
### Same title, different film
The year gate applies to **both** match tiers, not just the year-stripped one.
`normalizeTitleKeys` strips bracketed segments wholesale — they usually hold
quality and language tags — so "Dune (1984)" and "Dune" normalize to the same
string and the exact tier would accept the remake without ever consulting a
year, ranked *above* every fuzzy match. Discovery reads a bracketed year out of
the raw title first, and when both sides state a year and they disagree, it is
not the same movie. An unknown year still never blocks.
## Why resolution is lazy
The `content` table stores no `container_extension`, and
@@ -22,6 +22,12 @@ export interface VodSourceDiscoveryRequest {
year?: number | null;
/** The playlist being viewed; never offered back as an alternative. */
currentPlaylistId: string;
/**
* One stream id inside it to keep anyway — a pin can point at another copy
* of the film in the playlist the user is currently on, and excluding it
* would silently ignore an explicit preference.
*/
keepContentId?: number | null;
}
export interface VodSourceDiscoveryResult {
@@ -61,6 +67,7 @@ export class VodSourceDiscoveryService {
title: request.title,
year: request.year ?? null,
excludePlaylistId: request.currentPlaylistId,
keepContentId: request.keepContentId ?? null,
});
return {
@@ -23,7 +23,10 @@ import {
VodSourcesChipComponent,
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
createLogger,
PORTAL_PLAYBACK_POSITIONS,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
type PlaybackFallbackRequest,
@@ -45,6 +48,7 @@ import {
XtreamVodInfo,
XtreamVodStream,
youtubeEmbedUrl,
type VodSourceCandidate,
} from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
@@ -90,6 +94,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
/** Alternative sources for this movie in the user's other playlists */
readonly multiSource = inject(VodMultiSourceHostService);
private readonly logger = createLogger('VodDetailsRoute');
@@ -461,13 +466,30 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// A pinned source is an explicit "play this movie from here", so it
// outranks the playlist the route happens to be on. Falls through to
// the normal path when nothing is pinned or the pin cannot resolve.
if (await this.multiSource.playPinnedSource()) {
if (await this.multiSource.playPinnedSource(this.resumeSecondsFor)) {
return;
}
this.playback.onPrimaryAction(vodItem);
}
/**
* Where a specific source was last watched.
*
* Positions are keyed by (playlist, stream), so a pinned alternative has
* its own row — the one this page loaded belongs to the route's copy.
*/
private readonly resumeSecondsFor = async (
source: VodSourceCandidate
): Promise<number | null> => {
const position = await this.playbackPositions.getPlaybackPosition(
source.playlistId,
source.contentId,
'vod'
);
return position?.positionSeconds ?? null;
};
stopExternalPlayback(): Promise<void> {
return this.playback.stopExternalPlayback();
}
@@ -125,6 +125,87 @@ describe('VodMultiSourceHostService — pinning', () => {
);
});
it('asks discovery to keep a copy pinned inside the current playlist', async () => {
// The pin was set on another copy of the film in the playlist the user
// is now opening. Excluding that playlist wholesale would leave the pin
// pointing at a row that is not in the list, so Play would ignore it.
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: MOVIE_A.playlistId,
contentId: 4242,
portalType: 'xtream',
});
await loadMovie([]);
expect(discovery.discover).toHaveBeenCalledWith(
expect.objectContaining({
currentPlaylistId: MOVIE_A.playlistId,
keepContentId: 4242,
})
);
});
it('asks for no exception when the pin is in another playlist', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
expect(discovery.discover).toHaveBeenCalledWith(
expect.objectContaining({ keepContentId: null })
);
});
it('resumes a pinned alternative from its OWN stored position', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
// What the page loaded belongs to the ROUTE's copy — a different row
// entirely from the one the user has been watching through the pin.
service.seedResumePosition(12);
const resumeFor = jest.fn().mockResolvedValue(3600);
await expect(service.playPinnedSource(resumeFor)).resolves.toBe(true);
expect(resumeFor).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id })
);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 3600 }
);
});
it('keeps the page position when the pinned source has none', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
service.seedResumePosition(2538);
await expect(
service.playPinnedSource(jest.fn().mockResolvedValue(null))
).resolves.toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 2538 }
);
});
it('retires every stale alias and writes only the canonical key', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
@@ -186,6 +186,9 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
vodAutoFailover.set(true);
const loading = service.load(MOVIE_A);
while (discovery.discover.mock.calls.length === 0) {
await Promise.resolve();
}
// The stream died faster than the database answered. Concluding
// "nowhere to go" here would strand the user on the error screen with
@@ -207,6 +210,11 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
vodAutoFailover.set(true);
const loadingA = service.load(MOVIE_A);
// The pin lookup runs first now, so wait until A is genuinely inside
// discovery before the failure arrives.
while (discovery.discover.mock.calls.length === 0) {
await Promise.resolve();
}
const failingOver = service.failover();
// The user navigates while A's discovery is still out. Running against
@@ -107,6 +107,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
title: 'The Matrix',
year: 1999,
currentPlaylistId: 'playlist-1',
keepContentId: null,
});
movie.set({ ...MOVIE_A });
@@ -187,25 +188,21 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
it('discards a discovery that resolves after the movie changed', async () => {
const first = createDeferred<DiscoveryResult>();
const second = createDeferred<DiscoveryResult>();
discovery.discover
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
discovery.discover.mockReturnValueOnce(first.promise);
const loadA = service.load(MOVIE_A);
const loadB = service.load(MOVIE_B);
// A's pin lookup runs before its discovery, so wait for the call that
// actually matters rather than guessing at microtask counts.
while (discovery.discover.mock.calls.length === 0) {
await Promise.resolve();
}
const loadB = loadMovie([ALT_THREE], MOVIE_B);
first.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadA;
expect(service.sources()).toEqual([]);
// Abandoned on arrival: it never even looks the stale pin up.
expect(pins.get).not.toHaveBeenCalled();
second.resolve({ sources: [ALT_THREE], matchKind: 'title-year' });
await loadB;
expect(pins.get).toHaveBeenCalledTimes(1);
// A's alternatives must not appear anywhere in B's session.
expect(service.sources().map((source) => source.id)).toEqual([
'playlist-1:xtream:202',
ALT_THREE.id,
@@ -31,7 +31,10 @@ import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice';
import { currentSourceRow } from './vod-multi-source-current-row';
import { probeSource } from './vod-multi-source-probe';
import {
pinnedCopyInPlaylist,
pinnedSourceAwaitingPlay,
pinnedSourceIdOf,
playPinned,
readPin,
togglePinnedSource,
} from './vod-multi-source-pin';
@@ -75,10 +78,7 @@ export class VodMultiSourceHostService {
private discoveryToken = 0;
/**
* Bumped when the FILM changes, and only then — a rediscovery of the same
* film (enrichment described it better) must not cancel a switch or probe
* in flight for it. Without this guard the continuation would activate the
* old film's source in the new session and restart it from that session's
* zero resume position.
* film must not cancel a switch or probe in flight for it.
*/
private sessionToken = 0;
/**
@@ -132,8 +132,7 @@ export class VodMultiSourceHostService {
() => this.settingsStore.vodAutoFailover?.() === true
);
/** Live getter, not a snapshot: `isAvailable` is itself a getter over
* the Electron bridge, so copying it once could disagree with it. */
/** Live getter: the bridge's own `isAvailable` can change under us. */
get isAvailable(): boolean {
return this.discovery.isAvailable;
}
@@ -176,11 +175,9 @@ export class VodMultiSourceHostService {
* the tried-source set that makes failover terminate.
*
* The SAME film arriving again is a refresh, not a new session: enrichment
* supplies its year and TMDB id after the page opened, which changes the
* movie key on purpose (discovery gets the year, a `tmdb:` pin becomes
* findable) but changes nothing about what is playing. Resetting there
* would take the film back off the source the user switched to and hand
* failover a clean tried-set for sources it has already burned.
* changes the movie key on purpose, but nothing about what is playing.
* Resetting there would take the film off the source the user switched to
* and hand failover a clean tried-set for sources it has already burned.
*/
async load(movie: VodMultiSourceMovie): Promise<void> {
const finished = this.discover(movie);
@@ -214,10 +211,17 @@ export class VodMultiSourceHostService {
return;
}
// Read FIRST, because discovery has to be told which copy to keep.
const pin = await readPin(this.pins, this.matchKeys);
if (token !== this.discoveryToken) {
return;
}
const result = await this.discovery.discover({
title: movie.title,
year: movie.year,
currentPlaylistId: movie.playlistId,
keepContentId: pinnedCopyInPlaylist(pin, movie.playlistId),
});
if (token !== this.discoveryToken) {
return;
@@ -229,11 +233,6 @@ export class VodMultiSourceHostService {
result.sources,
result.matchKind
);
const pin = await readPin(this.pins, this.matchKeys);
if (token !== this.discoveryToken) {
return;
}
if (pin) {
this.controller.setPinnedSource(pinnedSourceIdOf(pin));
}
@@ -241,32 +240,30 @@ export class VodMultiSourceHostService {
this.publish();
}
/**
* The pinned source, when it is not the one the route already plays.
*
* "Make this the main source" has to survive reopening the movie, or the
* persisted preference is just an icon. The host consults this before its
* normal Play so the pin decides where playback starts.
*/
readonly pendingPinnedSourceId = computed(() => {
const pinned = this._sources().find((source) => source.isPinned);
return pinned && !pinned.isActive ? pinned.id : null;
});
/** The pinned source, when it is not the one the route already plays. */
readonly pendingPinnedSourceId = computed(() =>
pinnedSourceAwaitingPlay(this._sources())
);
/**
* Start from the pinned source if there is one. Returns false when there is
* nothing pinned to honour, leaving the caller's own Play path in charge.
*/
async playPinnedSource(): Promise<boolean> {
// The pin arrives with discovery. Concluding "nothing is pinned"
// before the lookup returns would start the route's own source and
// make the persisted preference a coin toss on worker latency.
async playPinnedSource(
resumeFor?: (source: VodSourceCandidate) => Promise<number | null>
): Promise<boolean> {
// The pin arrives with discovery; answering before the lookup lands
// would make a persisted preference lose to worker latency.
if (!(await this.stillOwnsScreen())) {
return false;
}
const pinnedId = this.pendingPinnedSourceId();
return pinnedId ? this.play(pinnedId) : false;
return playPinned({
controller: this.controller,
pinnedSourceId: this.pendingPinnedSourceId(),
resumeFor,
play: (sourceId) => this.play(sourceId),
});
}
/**
@@ -1,7 +1,9 @@
import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-access';
import type { VodSourcePinService } from '@iptvnator/services';
import {
buildVodSourceMatchKey,
type VodSourceCandidate,
type VodSourceDescriptor,
type VodSourcePin,
} from '@iptvnator/shared/interfaces';
@@ -13,6 +15,34 @@ import {
* host needs to know how a match key is chosen.
*/
/**
* The one copy inside the playlist being viewed that discovery must keep.
*
* A pin can point at another copy of the film in that same playlist, and
* excluding the playlist wholesale would drop the pinned row from the list —
* leaving the preference pointing at nothing and silently ignored.
*/
export function pinnedCopyInPlaylist(
pin: VodSourcePin | null,
playlistId: string
): number | null {
return pin && pin.playlistId === playlistId ? pin.contentId : null;
}
/**
* The pinned row, when it is not the one already playing.
*
* "Make this the main source" has to survive reopening the movie, or the
* persisted preference is just an icon. The host consults this before its
* normal Play, so the pin decides where playback starts.
*/
export function pinnedSourceAwaitingPlay(
sources: readonly VodSourceDescriptor[]
): string | null {
const pinned = sources.find((source) => source.isPinned);
return pinned && !pinned.isActive ? pinned.id : null;
}
/** The row id the controller uses, derived from a stored pin. */
export function pinnedSourceIdOf(pin: VodSourcePin): string {
return `${pin.playlistId}:${pin.portalType}:${pin.contentId}`;
@@ -103,3 +133,35 @@ export async function togglePinnedSource(
? candidate.id
: undefined;
}
/** What starting the pinned source needs from the host. */
export interface PinnedPlayDeps {
controller: VodMultiSourceController;
pinnedSourceId: string | null;
/** Where THAT source was last watched, when the host can look it up. */
resumeFor?: (source: VodSourceCandidate) => Promise<number | null>;
play: (sourceId: string) => Promise<boolean>;
}
/**
* Start the movie from its pinned source, at the position that source was
* last left at.
*
* Playback positions are keyed by (playlist, stream), so watching through a
* pinned alternative stores progress under ITS ids. The page loaded the ROUTE
* row, which for this source is stale or missing entirely — resuming from it
* would restart the film or jump to where a different copy was left.
*/
export async function playPinned(deps: PinnedPlayDeps): Promise<boolean> {
const pinned = deps.controller.findSource(deps.pinnedSourceId ?? '');
if (!pinned) {
return false;
}
const stored = await deps.resumeFor?.(pinned);
if (stored !== null && stored !== undefined) {
deps.controller.setResumeSeconds(stored);
}
return deps.play(pinned.id);
}
@@ -875,6 +875,8 @@ export interface ElectronBridgeApi {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
/** A stream id inside the excluded playlist to keep anyway (a pin). */
keepContentId?: number | null;
}) => Promise<VodSourceCandidateRow[]>;
/** Per-movie pinned source; keys are passed most-trusted first */
dbGetVodSourcePin: (matchKeys: string[]) => Promise<VodSourcePin | null>;