fix(portals): make every alias of a pin agree, and stop losing rows

Four findings from the latest review pass.

A pin lookup accepts several aliases of the same movie, but a write only
touched the most-trusted one — so after enrichment the title alias still
pointed at whatever was pinned before, and a reopen that read it (because
TMDB had not landed yet, or its request failed) started the source the user
had just replaced. Writes now go to every alias.

That alias set was also missing one. Enrichment supplies the year as well as
the id, so a pin set before either existed is stored yearless; the candidate
list skipped that form entirely and orphaned the row.

Discovery could lose whole playlists: one playlist listing a film in dozens
of categories produces identically ranked rows that fill the window before
another playlist is read. The collapse now happens in SQL, before the limit,
rather than in TypeScript afterwards where the missing rows are already gone.

And an abandoned source pick finishing late cleared the spinner from the row
the user was actually waiting on.

Removes `isExhausted()` from the host service — no caller outside its own
tests, where the assertion above it already proved the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 00:01:05 +02:00
1 parent df30262f24
commit e3465d3a45
13 files changed
+418 -160

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** (its own duplicate 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. 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}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. 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` lets `matchedExternalPlayback` still recognise it as this page's session. 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. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` lets `matchedExternalPlayback` still recognise it as this page's session. 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. 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, 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). No ffprobe — the binary is not bundled.
- See `docs/architecture/vod-multi-source.md`
@@ -250,6 +250,23 @@ describe('title-sources.operations', () => {
);
});
it('collapses one playlist’s copies before the FTS limit applies', async () => {
// A playlist listing the film in dozens of categories produces
// identically ranked rows. Collapsing them only in TypeScript
// cannot recover the other playlists the window never reached.
const { db, all } = createDbMock([duneRow]);
await findTitleSources(db, { title: 'Dune' });
const query = compiledQuery(all);
expect(query.sql).toContain(
'GROUP BY cat.playlist_id, c.xtream_id'
);
expect(query.sql.indexOf('GROUP BY')).toBeLessThan(
query.sql.indexOf('LIMIT')
);
});
it('leaves the queries unfiltered when no playlist is excluded', async () => {
const { db, all } = createDbMock([duneRow]);
@@ -118,6 +118,15 @@ function scanCandidateQuery(base: string, excludePlaylist: SQL) {
`;
}
/**
* The relevance-ranked path, and the only one still taking a window.
*
* One playlist can list a film in dozens of categories, and those rows are
* identically ranked — enough of them would fill the window before another
* playlist was ever read. The TypeScript pass collapses them afterwards,
* which cannot recover what the query never returned, so the collapse has to
* happen first: one row per `(playlist_id, xtream_id)`, then the limit.
*/
function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) {
return sql`
SELECT
@@ -137,6 +146,7 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) {
AND cat.hidden = 0
AND p.type = 'xtream'
${excludePlaylist}
GROUP BY cat.playlist_id, c.xtream_id
ORDER BY rank, c.title
LIMIT ${CANDIDATE_LIMIT}
`;
+1 -1
View File
@@ -1036,7 +1036,7 @@
"FILTER_PLACEHOLDER": "التصفية حسب قائمة التشغيل",
"NO_MATCHES": "لا توجد قوائم تشغيل تطابق هذا الفلتر",
"COPIES": "{{count}} نسخ في قائمة التشغيل هذه",
"MORE_SOURCES": "{{count}} أخرى في قائمة المصادر"
"MORE_SOURCES": "+{{count}} أخرى في قائمة المصادر"
}
},
"INFORMATION": "معلومات",
+1 -1
View File
@@ -1035,7 +1035,7 @@
"CHECK_ALL": "全部检测",
"FILTER_PLACEHOLDER": "按播放列表筛选",
"NO_MATCHES": "未找到匹配的播放列表",
"COPIES": "此播放列表中有 {{count}} 个副本",
"COPIES": "此播放列表中有 {{count}} 个版本",
"MORE_SOURCES": "其余 {{count}} 个在播放源列表中"
}
},
+20 -5
View File
@@ -119,11 +119,21 @@ every portal, which is the problem being solved.
`buildVodSourceMatchKey()` produces `tmdb:{id}` when a usable TMDB id exists,
otherwise `title:{normalizedBase}:{year}` via the shared `normalizeTitleKeys`.
Lookups pass **every** alias, most-trusted first
(`buildVodSourceMatchKeyCandidates`). A movie pinned before TMDB enrichment
landed is stored under its title key and prefers a `tmdb:` key afterwards;
reading both means the id arriving later does not orphan the pin, and unpinning
clears every alias so a stale row cannot resurrect it.
Enrichment adds BOTH identifying fields late, so the same movie can already be
pinned under any poorer form of itself:
| pinned when | stored under |
|---|---|
| after enrichment | `tmdb:{id}` |
| before the TMDB id | `title:{base}:{year}` |
| before the year too | `title:{base}:` |
`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first, and
**writes go to every one of them**. Reading every alias is what keeps a late
TMDB id from orphaning an earlier pin; writing every alias is what stops the
lower-trust ones from still pointing at the source the user just replaced —
which a reopen before enrichment lands would then play. Unpinning clears them
all, so a stale row cannot resurrect it.
The row only changes once the write lands. A pin the database refused is worse
than no pin at all — the icon promises the preference will be there next time,
@@ -164,6 +174,11 @@ one that does not exist — the chip simply does not appear. So:
- **The current playlist is excluded in SQL**, not afterwards. It routinely
lists a film in several categories, and those rows would otherwise spend the
FTS window before a single other playlist was read.
- **Duplicates collapse in SQL too**, for the same reason. One playlist can
list a film in dozens of categories, and those rows rank identically, so
`GROUP BY cat.playlist_id, c.xtream_id` runs before the limit. Collapsing
them only in TypeScript afterwards cannot recover the playlists the window
never reached.
- **Short titles scan on a word boundary, and take no window at all.** The
trigram tokenizer cannot index tokens under three characters, so "Up", "It"
or "Us" produce an empty `MATCH` and fall back to a scan. A substring scan
@@ -0,0 +1,227 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
import {
ALT_THREE,
ALT_TWO,
CURRENT_A_ID,
MOVIE_A,
PROBE_OK,
resolveWith,
} from './vod-multi-source-host.fixtures';
/**
* "Make this the main source" as a persisted decision.
*
* A pin is not decoration: it decides where Play starts and outranks every
* other signal in failover. So what the row shows has to be what the database
* actually holds — under every alias the movie can be looked up by.
*/
describe('VodMultiSourceHostService — pinning', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie })
);
});
it('plays the pinned source instead of the route playlist', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
// Without this the persisted preference is only an icon: reopening the
// movie would still start the playlist the route is on.
await expect(service.playPinnedSource()).resolves.toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('resumes the pinned source from the 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 route knows after loading playback positions. Nothing has
// played yet, so no timeupdate has reported anything — and the button
// the user is about to press says "Resume".
service.seedResumePosition(2538);
await expect(service.playPinnedSource()).resolves.toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 2538 }
);
});
it('points every alias of the movie at the newly pinned source', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
expect(matchKeys.length).toBeGreaterThan(1);
await service.togglePin(ALT_TWO.id);
// Writing only the most-trusted key leaves the others pointing at
// whatever was pinned before, and a reopen that reads a lower-trust
// alias — because enrichment has not landed yet — starts the source
// the user just replaced.
expect(pins.set).toHaveBeenCalledTimes(matchKeys.length);
for (const matchKey of matchKeys) {
expect(pins.set).toHaveBeenCalledWith({
matchKey,
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
}
});
it('does not show a pin the database refused to store', async () => {
await loadMovie([ALT_TWO]);
pins.set.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
// The icon promises the preference survives reopening the movie. A
// write that failed makes that a lie, so the row must not change.
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('keeps the pin when clearing it fails', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
pins.clear.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
});
it('leaves Play alone when nothing is pinned', async () => {
await loadMovie([ALT_TWO]);
await expect(service.playPinnedSource()).resolves.toBe(false);
expect(startPlayback).not.toHaveBeenCalled();
});
it('prefers the pinned source when failing over', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_THREE.playlistId,
contentId: ALT_THREE.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
});
it('round-trips a pin under the movie match key', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
expect(matchKeys.length).toBeGreaterThan(0);
const pin = {
matchKey: matchKeys[0],
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
};
await service.togglePin(ALT_TWO.id);
expect(pins.set).toHaveBeenCalledWith(pin);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
await service.togglePin(ALT_TWO.id);
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
// Unpinning writes nothing further — the pin round-trip is one write
// per alias, then a single clear of all of them.
expect(pins.set).toHaveBeenCalledTimes(matchKeys.length);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
pins.get.mockResolvedValue(pin);
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isPinned).toBe(false);
});
});
@@ -177,6 +177,32 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('leaves the spinner on the row that is still resolving', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
const slowTwo = createDeferred<ReturnType<typeof resolvedFor>>();
const slowThree = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve
.mockReturnValueOnce(slowTwo.promise)
.mockReturnValueOnce(slowThree.promise);
const first = service.play(ALT_TWO.id);
const second = service.play(ALT_THREE.id);
expect(service.busySourceId()).toBe(ALT_THREE.id);
slowTwo.resolve(resolvedFor(ALT_TWO, 0));
await first;
// The abandoned pick finishing must not take the spinner off the row
// the user is actually waiting on.
expect(service.busySourceId()).toBe(ALT_THREE.id);
slowThree.resolve(resolvedFor(ALT_THREE, 0));
await second;
expect(service.busySourceId()).toBeNull();
});
it('drops a pin change whose movie was navigated away from', async () => {
const pinFor = (candidate: VodSourceCandidate) => ({
matchKey: 'title:the matrix:1999',
@@ -217,7 +217,6 @@ describe('VodMultiSourceHostService', () => {
expect(visited).toHaveLength(2);
expect(new Set(visited).size).toBe(visited.length);
expect(visited).not.toContain(CURRENT_A_ID);
expect(service.isExhausted()).toBe(true);
});
it('keeps going when the best candidate cannot be resolved', async () => {
@@ -247,121 +246,6 @@ describe('VodMultiSourceHostService', () => {
// spinning through the same candidate forever.
await expect(service.failover()).resolves.toBeNull();
expect(startPlayback).not.toHaveBeenCalled();
expect(service.isExhausted()).toBe(true);
});
it('plays the pinned source instead of the route playlist', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
// Without this the persisted preference is only an icon: reopening the
// movie would still start the playlist the route is on.
await expect(service.playPinnedSource()).resolves.toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('resumes the pinned source from the 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 route knows after loading playback positions. Nothing has
// played yet, so no timeupdate has reported anything — and the button
// the user is about to press says "Resume".
service.seedResumePosition(2538);
await expect(service.playPinnedSource()).resolves.toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 2538 }
);
});
it('does not show a pin the database refused to store', async () => {
await loadMovie([ALT_TWO]);
pins.set.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
// The icon promises the preference survives reopening the movie. A
// write that failed makes that a lie, so the row must not change.
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
});
it('keeps the pin when clearing it fails', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
pins.clear.mockResolvedValue(false);
await service.togglePin(ALT_TWO.id);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
});
it('leaves Play alone when nothing is pinned', async () => {
await loadMovie([ALT_TWO]);
await expect(service.playPinnedSource()).resolves.toBe(false);
expect(startPlayback).not.toHaveBeenCalled();
});
it('prefers the pinned source when failing over', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_THREE.playlistId,
contentId: ALT_THREE.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
});
it('round-trips a pin under the movie match key', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
expect(matchKeys.length).toBeGreaterThan(0);
const pin = {
matchKey: matchKeys[0],
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
};
await service.togglePin(ALT_TWO.id);
expect(pins.set).toHaveBeenCalledWith(pin);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
await service.togglePin(ALT_TWO.id);
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
expect(pins.set).toHaveBeenCalledTimes(1);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
pins.get.mockResolvedValue(pin);
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isPinned).toBe(false);
});
it('reports unknown, never fail, for a source it cannot check', async () => {
@@ -48,12 +48,9 @@ import {
* Wires VOD multi-source into one open movie on the Xtream detail page.
*
* Component-provided, not root-provided: the controller's "each source is
* tried at most once" set must die with the movie, or a later film would
* inherit a poisoned failover history.
*
* The host component supplies the movie identity and the two playback seams
* (start a playback, read the live position) through `bind()`, so this service
* never reaches into the route component.
* tried at most once" set must die with the movie. The host supplies the
* movie identity and the playback seam through `bind()`, so this never
* reaches into the route component.
*/
export interface VodMultiSourceBindings {
@@ -142,10 +139,9 @@ export class VodMultiSourceHostService {
}
/**
* Wire the host's playback seam and start watching the movie on screen.
*
* The effect lives here rather than in the route component because what it
* guards is this service's own lifecycle — see `load()`.
* Wire the host's playback seam and watch the movie on screen. The effect
* lives here because what it guards is this service's own lifecycle — see
* `load()`.
*/
bind(bindings: VodMultiSourceBindings): void {
this.bindings = bindings;
@@ -265,7 +261,12 @@ export class VodMultiSourceHostService {
try {
return (await this.switchTo(candidate)) === 'switched';
} finally {
this._busySourceId.set(null);
// Only if this attempt still owns the spinner: a slower first pick
// finishing after a second one would otherwise clear the row that
// is still resolving.
if (this._busySourceId() === sourceId) {
this._busySourceId.set(null);
}
}
}
@@ -324,11 +325,6 @@ export class VodMultiSourceHostService {
return switched ? this._lastSwitch() : null;
}
/** True once every alternative has been attempted this session. */
isExhausted(): boolean {
return this.controller.isExhausted();
}
/** The live position, fed ahead of the persist throttle. */
reportPosition(seconds: number): void {
this.controller.setResumeSeconds(seconds);
@@ -26,29 +26,46 @@ export async function readPin(
}
/**
* Persist the pin for `candidate`.
* Persist the pin for `candidate` under EVERY alias of the movie.
*
* Written under the most-trusted key while lookups pass every alias, so a TMDB
* id arriving after the fact does not orphan the row.
* Writing only the most-trusted key would leave the others pointing at
* whatever was pinned before: reopening the movie before enrichment lands —
* or when that request fails — reads a lower-trust alias and starts the source
* the user just replaced. Since lookups accept any alias, every alias has to
* agree.
*
* Success is the most-trusted key's: it is the one a later lookup reaches
* first, and a half-written alias set is no worse than the stale one it
* replaced (unpinning clears them all regardless).
*/
export async function writePin(
pins: Pick<VodSourcePinService, 'set'>,
matchKeys: readonly string[],
candidate: VodSourceCandidate
): Promise<boolean> {
const matchKey = matchKeys[0] ?? buildVodSourceMatchKey(candidate);
if (!matchKey) {
const keys = matchKeys.length
? matchKeys
: [buildVodSourceMatchKey(candidate)].filter(
(key): key is string => !!key
);
if (keys.length === 0) {
return false;
}
// The write can fail — no bridge, or the DB refused it. Reporting success
// then would show a pin the next visit does not have.
return pins.set({
matchKey,
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: candidate.portalType,
});
const written = await Promise.all(
keys.map((matchKey) =>
pins.set({
matchKey,
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: candidate.portalType,
})
)
);
return written[0] === true;
}
/** Clears every alias, so unpinning is not undone by a stale row. */
@@ -0,0 +1,59 @@
import {
buildVodSourceMatchKey,
buildVodSourceMatchKeyCandidates,
isTmdbMatchKey,
} from './vod-source-match-key.util';
const MOVIE = { title: 'The Matrix', year: 1999, tmdbId: 603 };
describe('buildVodSourceMatchKey', () => {
it('prefers the TMDB id when there is a usable one', () => {
expect(buildVodSourceMatchKey(MOVIE)).toBe('tmdb:603');
expect(isTmdbMatchKey('tmdb:603')).toBe(true);
});
it.each([[0], [''], [null], [undefined], ['abc']])(
'falls back to the title for the unusable TMDB id %p',
(tmdbId) => {
expect(buildVodSourceMatchKey({ ...MOVIE, tmdbId })).toBe(
'title:the matrix:1999'
);
}
);
it('refuses to key on nothing rather than writing a junk row', () => {
expect(buildVodSourceMatchKey({ title: ' ', year: null })).toBeNull();
});
});
describe('buildVodSourceMatchKeyCandidates', () => {
it('covers every poorer form the pin may already be stored under', () => {
// Enrichment adds the id AND the year, so a pin set before it landed
// is under a title key — and one set before the year was known is
// under the yearless form. Missing either loses the preference.
expect(buildVodSourceMatchKeyCandidates(MOVIE)).toEqual([
'tmdb:603',
'title:the matrix:1999',
'title:the matrix:',
]);
});
it('still offers the yearless alias without a TMDB id', () => {
expect(
buildVodSourceMatchKeyCandidates({ ...MOVIE, tmdbId: null })
).toEqual(['title:the matrix:1999', 'title:the matrix:']);
});
it('does not repeat the yearless key when the year is unknown', () => {
expect(
buildVodSourceMatchKeyCandidates({
title: 'The Matrix',
year: null,
})
).toEqual(['title:the matrix:']);
});
it('is empty when the movie cannot be identified at all', () => {
expect(buildVodSourceMatchKeyCandidates({ title: '' })).toEqual([]);
});
});
@@ -45,9 +45,12 @@ export function buildVodSourceMatchKey(input: {
/**
* Every key a movie may legitimately be stored under, most-trusted first.
*
* A pin written before TMDB enrichment lands is keyed by title; after
* enrichment the same movie prefers a `tmdb:` key. Looking up both means the
* earlier pin is not orphaned by the id arriving.
* Enrichment arrives late and adds BOTH identifying fields, so a pin can have
* been written under any earlier, poorer form of the same movie:
* - before the TMDB id → `title:{base}:{year}`
* - before the release year too → `title:{base}:`
* Each of those is a key the row may still be sitting under, and a lookup that
* skipped it would silently lose a preference the user set minutes ago.
*/
export function buildVodSourceMatchKeyCandidates(input: {
tmdbId?: number | string | null;
@@ -55,16 +58,20 @@ export function buildVodSourceMatchKeyCandidates(input: {
year?: number | null;
}): string[] {
const keys: string[] = [];
const push = (key: string | null) => {
if (key && !keys.includes(key)) {
keys.push(key);
}
};
const tmdbId = normalizeTmdbId(input.tmdbId);
if (tmdbId !== null) {
keys.push(`${TMDB_PREFIX}${tmdbId}`);
push(`${TMDB_PREFIX}${tmdbId}`);
}
const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null });
if (titleKey && !keys.includes(titleKey)) {
keys.push(titleKey);
}
push(buildVodSourceMatchKey({ ...input, tmdbId: null }));
// The yearless form, for a pin set before the release date was known.
push(buildVodSourceMatchKey({ ...input, tmdbId: null, year: null }));
return keys;
}