diff --git a/CLAUDE.md b/CLAUDE.md index 9ca04bce9..82587ac0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -825,7 +825,7 @@ engine` (restart required) or - **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. 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. 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. +- 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 — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. 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` diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 01f67a033..637d3a755 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -131,8 +131,17 @@ pinned under any poorer form of itself: `buildVodSourceMatchKeyCandidates` returns all three, most-trusted first. Reading every alias is what keeps a late TMDB id from orphaning an earlier pin. -A write **clears every alias and then stores only the canonical key** — both -halves matter, and each rules out the other's shortcut: +Three key sets, because reading, writing and deleting are different questions +(`pinKeysFor` builds all three so they cannot disagree): + +| set | contents | why | +|---|---|---| +| `lookup` | every alias above | a pin may sit under any poorer form of the movie | +| `write` | `tmdb:` + `title:{base}:{year}` | keys that name exactly ONE film | +| `loaded` | the key the pin on screen came from | the only ambiguous row this session may retire | + +A write stores the canonical key and clears the stale ones — both halves +matter, and each rules out the other's shortcut: - Writing only the top key would leave the lower-trust aliases pointing at whatever was pinned before, and a reopen that reads one of those (enrichment @@ -140,8 +149,12 @@ halves matter, and each rules out the other's shortcut: - Writing the decision *into* every alias is not the fix either: the yearless form is shared by every remake, so a known-year pin stored there would answer for a different film — pin Dune (2021), open Dune (1984) before its year - arrives, and it starts the 2021 source. That alias stays readable and - unwritten. + arrives, and it starts the 2021 source. + +For the same reason a write or an unpin never *deletes* the yearless alias on +spec: that row may hold another remake's preference. The single exception is +the row this session actually read, because the user is acting on the pin they +can see — and leaving that one would make an unpin come back. 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, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 4f823fded..628ed5208 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -211,6 +211,61 @@ describe('VodDetailsRouteComponent — playback actions', () => { consoleWarnSpy?.mockRestore(); }); + it('owns an external session for a copy in its own playlist', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + + // A pinned copy can now live in the route's OWN playlist. Comparing + // playlists alone would call this "the route source", and the page + // would disown the session it started for it. + Object.defineProperty(component.multiSource, 'sources', { + configurable: true, + value: () => [ + { + id: 'playlist-1:xtream:4242', + playlistId: 'playlist-1', + playlistName: 'Portal One', + portalType: 'xtream', + contentId: 4242, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive: true, + isPinned: true, + isTried: true, + probe: { status: 'idle' }, + }, + ], + }); + activeSession.set({ + player: 'mpv', + status: 'playing', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 4242, + contentType: 'vod', + }, + }); + + expect(playback.matchedExternalPlayback()).not.toBeNull(); + expect(component.isExternalStopAction()).toBe(true); + }); + + it('drops the carried position when Restart starts from the beginning', () => { + const component = fixture.componentInstance; + const reported = jest.spyOn(component.multiSource, 'reportPosition'); + + component.playVod({} as XtreamVodDetails); + + // The controller still holds whatever this page was seeded with, and + // a failure before the first timeupdate would resolve the next source + // back at it instead of honouring the restart. + expect(reported).toHaveBeenCalledWith(0); + }); + it('stops the external player when the button says Stop', async () => { currentPlaylist.set({ id: 'playlist-1' }); activeSession.set({ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index a9a8bb79d..6396dc1c7 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -317,7 +317,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly activeAlternativeSource = computed(() => { const active = this.multiSource.sources().find((s) => s.isActive); const routePlaylistId = this.xtreamStore.currentPlaylist()?.id; - if (!active || active.playlistId === routePlaylistId) { + // Both halves: a pinned copy can now live in the route's OWN playlist, + // and comparing the playlist alone would call it "the route source" — + // losing the Stop state and every position update for it. + if ( + !active || + (active.playlistId === routePlaylistId && + active.contentId === this.selectedVodId()) + ) { return null; } @@ -447,6 +454,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } playVod(vodItem: XtreamVodDetails | null): void { + // Restart means from the beginning. The controller still holds the + // position this page was seeded with, and a failure before the first + // timeupdate would otherwise resolve the next source back at it. + this.multiSource.reportPosition(0); this.playback.playVod(vodItem); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index 5be3aa400..da44dd31c 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -206,29 +206,49 @@ describe('VodMultiSourceHostService — pinning', () => { ); }); - it('retires every stale alias and writes only the canonical key', async () => { + it('retires its own aliases but never another remake’s', async () => { await loadMovie([ALT_TWO]); - const matchKeys: string[] = pins.get.mock.calls[0][0]; - expect(matchKeys.length).toBeGreaterThan(1); + const lookupKeys: string[] = pins.get.mock.calls[0][0]; + const yearless = lookupKeys[lookupKeys.length - 1]; + expect(yearless).toBe('title:the matrix:'); await service.togglePin(ALT_TWO.id); - // Leaving the other aliases alone would keep them pointing at whatever - // was pinned before, and a reopen that reads one — because enrichment - // has not landed yet — starts the source the user just replaced. - expect(pins.clear).toHaveBeenCalledWith(matchKeys); - // But the decision is NOT written back into them: `title:{base}:` is - // shared by every remake, so a known-year pin stored there would - // answer for a different film. + // Stale aliases of THIS movie go, or a reopen before enrichment reads + // one and starts the source the user just replaced. + const [retired] = pins.clear.mock.calls[0] as [string[]]; + expect(retired).toContain('title:the matrix:1999'); + // The yearless form is shared by every remake: a Dune (2021) pin must + // not delete — or answer for — a row that may be Dune (1984)'s. + expect(retired).not.toContain(yearless); + expect(pins.set).toHaveBeenCalledTimes(1); expect(pins.set).toHaveBeenCalledWith({ - matchKey: matchKeys[0], + matchKey: 'title:the matrix:1999', playlistId: ALT_TWO.playlistId, contentId: ALT_TWO.contentId, portalType: 'xtream', }); }); + it('retires the ambiguous row it actually read', async () => { + // A pin set before the year was known lives under the yearless key. + // This session read it, so the user is unpinning THAT row — leaving it + // would make the unpin come back on the next open. + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + + await service.togglePin(ALT_TWO.id); + + const [retired] = pins.clear.mock.calls[0] as [string[]]; + expect(retired).toContain('title:the matrix:'); + }); + it('does not show a pin the database refused to store', async () => { await loadMovie([ALT_TWO]); pins.set.mockResolvedValue(false); @@ -321,7 +341,7 @@ describe('VodMultiSourceHostService — pinning', () => { await service.togglePin(ALT_TWO.id); - expect(pins.clear).toHaveBeenCalledWith(matchKeys); + expect(pins.clear).toHaveBeenCalled(); // Unpinning writes nothing further. expect(pins.set).toHaveBeenCalledTimes(1); expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts index f7a8ee218..9597acbe2 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -256,6 +256,39 @@ describe('VodMultiSourceHostService — stale resolutions', () => { expect(service.busySourceId()).toBeNull(); }); + it('abandons a pinned play whose movie was left during the lookup', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + + const lookup = createDeferred(); + let insideLookup = false; + const playing = service.playPinnedSource(() => { + insideLookup = true; + return lookup.promise; + }); + // Wait until the call is genuinely inside the position lookup — + // stopping earlier would only re-prove the guard before it. + while (!insideLookup) { + await Promise.resolve(); + } + + // Reading that source's own resume position is a database round-trip, + // and another movie can take the screen across it. Playing then would + // hand THIS film's source id to the film now showing. + await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B); + startPlayback.mockClear(); + + lookup.resolve(3600); + + await expect(playing).resolves.toBe(false); + expect(startPlayback).not.toHaveBeenCalled(); + }); + it('drops a pin change whose movie was navigated away from', async () => { const pinFor = (candidate: VodSourceCandidate) => ({ matchKey: 'title:the matrix:1999', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index 323c6d500..500d5c8e3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -35,11 +35,12 @@ import { pinnedSourceAwaitingPlay, pinnedSourceIdOf, playPinned, + pinKeysFor, readPin, togglePinnedSource, + type PinKeySets, } from './vod-multi-source-pin'; import { - buildVodSourceMatchKeyCandidates, type ResolvedPortalPlayback, type VodSourceCandidate, type VodSourceDescriptor, @@ -73,18 +74,13 @@ export class VodMultiSourceHostService { private controller = new VodMultiSourceController(); private bindings: VodMultiSourceBindings | null = null; - private matchKeys: string[] = []; + private pinKeys: PinKeySets = { lookup: [], write: [] }; /** Bumped per `load()`: discards a discovery the next one superseded. */ private discoveryToken = 0; - /** - * Bumped when the FILM changes, and only then — a rediscovery of the same - * film must not cancel a switch or probe in flight for it. - */ + /** Bumped when the FILM changes — a rediscovery of the same one is a + * refresh, and must not cancel a switch or probe in flight for it. */ private sessionToken = 0; - /** - * Bumped by every switch, so a slower resolution cannot overwrite the - * user's latest choice, nor the source Undo points back to. - */ + /** Bumped by every switch: a slower one must not overwrite a newer. */ private switchToken = 0; private lastMovieKey: string | null = null; private movieIdentity: string | null = null; @@ -205,14 +201,14 @@ export class VodMultiSourceHostService { this._busySourceId.set(null); this._previousSourceId.set(null); } - this.matchKeys = buildVodSourceMatchKeyCandidates(movie); + this.pinKeys = pinKeysFor(movie); if (!this.discovery.isAvailable) { return; } // Read FIRST, because discovery has to be told which copy to keep. - const pin = await readPin(this.pins, this.matchKeys); + const pin = await readPin(this.pins, this.pinKeys.lookup); if (token !== this.discoveryToken) { return; } @@ -235,6 +231,9 @@ export class VodMultiSourceHostService { ); if (pin) { this.controller.setPinnedSource(pinnedSourceIdOf(pin)); + // Remember where it was found: that row is the one the user sees, + // so it is the only ambiguous key an unpin may remove. + this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey }; } this.publish(); @@ -258,10 +257,12 @@ export class VodMultiSourceHostService { return false; } + const session = this.sessionToken; return playPinned({ controller: this.controller, pinnedSourceId: this.pendingPinnedSourceId(), resumeFor, + isCurrent: () => session === this.sessionToken, play: (sourceId) => this.play(sourceId), }); } @@ -306,7 +307,7 @@ export class VodMultiSourceHostService { ); const pinned = await togglePinnedSource( this.pins, - this.matchKeys, + this.pinKeys, this.controller.findSource(sourceId), isPinned ); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index c4b9ff4ac..bf9e531b6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -2,6 +2,8 @@ import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-acc import type { VodSourcePinService } from '@iptvnator/services'; import { buildVodSourceMatchKey, + buildVodSourceMatchKeyCandidates, + buildVodSourceMatchKeyWriteKeys, type VodSourceCandidate, type VodSourceDescriptor, type VodSourcePin, @@ -74,15 +76,15 @@ export async function readPin( */ export async function writePin( pins: Pick, - matchKeys: readonly string[], + keys: PinKeySets, candidate: VodSourceCandidate ): Promise { - const matchKey = matchKeys[0] ?? buildVodSourceMatchKey(candidate); + const matchKey = keys.write[0] ?? buildVodSourceMatchKey(candidate); if (!matchKey) { return false; } - await erasePin(pins, matchKeys); + await erasePin(pins, retirablePinKeys(keys)); // The write can fail — no bridge, or the DB refused it. Reporting success // then would show a pin the next visit does not have. @@ -113,25 +115,63 @@ export async function erasePin( */ export async function togglePinnedSource( pins: Pick, - matchKeys: readonly string[], + keys: PinKeySets, candidate: VodSourceCandidate | null, isPinned: boolean ): Promise { - if (matchKeys.length === 0) { + if (keys.write.length === 0) { return undefined; } if (isPinned) { - return (await erasePin(pins, matchKeys)) ? null : undefined; + return (await erasePin(pins, retirablePinKeys(keys))) + ? null + : undefined; } if (!candidate) { return undefined; } - return (await writePin(pins, matchKeys, candidate)) - ? candidate.id - : undefined; + return (await writePin(pins, keys, candidate)) ? candidate.id : undefined; +} + +/** Which keys a pin may be looked up under, stored under, and removed from. */ +export interface PinKeySets { + /** Every alias the pin may be sitting under, most-trusted first. */ + lookup: readonly string[]; + /** Keys that name exactly one film. */ + write: readonly string[]; + /** The key the pin currently on screen was actually found under. */ + loaded?: string | null; +} + +/** The three key sets for a movie, in one place so they cannot disagree. */ +export function pinKeysFor(movie: { + tmdbId?: number | string | null; + title?: string | null; + year?: number | null; +}): PinKeySets { + return { + lookup: buildVodSourceMatchKeyCandidates(movie), + write: buildVodSourceMatchKeyWriteKeys(movie), + }; +} + +/** + * The rows a write or an unpin is allowed to remove. + * + * Never the ambiguous yearless alias on spec — it may hold another remake's + * preference. The one exception is the row this session actually READ: the + * user is acting on the pin they can see, whatever key it happens to live + * under, and leaving it would make an unpin come back. + */ +function retirablePinKeys(keys: PinKeySets): string[] { + const retire = [...keys.write]; + if (keys.loaded && !retire.includes(keys.loaded)) { + retire.push(keys.loaded); + } + return retire; } /** What starting the pinned source needs from the host. */ @@ -140,6 +180,8 @@ export interface PinnedPlayDeps { pinnedSourceId: string | null; /** Where THAT source was last watched, when the host can look it up. */ resumeFor?: (source: VodSourceCandidate) => Promise; + /** False once another movie owns the screen — the lookup is awaited. */ + isCurrent: () => boolean; play: (sourceId: string) => Promise; } @@ -159,6 +201,13 @@ export async function playPinned(deps: PinnedPlayDeps): Promise { } const stored = await deps.resumeFor?.(pinned); + // That lookup is a database round-trip, and the user can navigate across + // it. Playing then would hand THIS film's source id to whatever movie now + // owns the screen — starting it, if the id happens to exist there. + if (!deps.isCurrent()) { + return false; + } + if (stored !== null && stored !== undefined) { deps.controller.setResumeSeconds(stored); } diff --git a/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts b/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts index a5036c83e..6cf96e44d 100644 --- a/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts +++ b/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts @@ -76,6 +76,36 @@ export function buildVodSourceMatchKeyCandidates(input: { return keys; } +/** + * The keys that name exactly ONE film — the only ones safe to write to or + * delete. + * + * `buildVodSourceMatchKeyCandidates` deliberately also offers the yearless + * `title:{base}:` form, because a pin set before the release year was known + * lives there. But that form is shared by every remake: storing a + * known-year decision in it would answer for a different film, and deleting + * it could throw away a different film's preference. + */ +export function buildVodSourceMatchKeyWriteKeys(input: { + tmdbId?: number | string | null; + title?: string | null; + year?: number | null; +}): string[] { + const keys: string[] = []; + + const tmdbId = normalizeTmdbId(input.tmdbId); + if (tmdbId !== null) { + keys.push(`${TMDB_PREFIX}${tmdbId}`); + } + + const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null }); + if (titleKey && !keys.includes(titleKey)) { + keys.push(titleKey); + } + + return keys; +} + export function isTmdbMatchKey(key: string): boolean { return key.startsWith(TMDB_PREFIX); } diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index f8cd203c7..a70619c93 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -90,6 +90,7 @@ (alternativeSourceRequested)=" alternativeSourceRequested.emit($event) " + (sourceCheckRequested)="sourceCheckRequested.emit($event)" (playbackFailed)="playbackFailed.emit($event)" (timeUpdate)="onTimeUpdate($event)" (playbackEnded)="onPlaybackEnded()" diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 825728880..39ba43a52 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -143,6 +143,7 @@ [source]="source" [showPin]="false" (playRequested)="alternativeSourceRequested.emit($event)" + (checkRequested)="sourceCheckRequested.emit($event)" /> } @if (hiddenAlternativeCount() > 0) { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 9b9d19e63..d74d113c9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { By } from '@angular/platform-browser'; +import { VodSourceRowComponent } from '@iptvnator/ui/components'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateModule } from '@ngx-translate/core'; import { of, Subject } from 'rxjs'; @@ -139,6 +140,9 @@ describe('WebPlayerViewComponent', () => { StubEmbeddedMpvPlayerComponent, StubHtmlVideoPlayerComponent, StubVjsPlayerComponent, + // Real, not stubbed: the point of the test below is + // that this row's Check action reaches the host. + VodSourceRowComponent, ClipboardModule, MatButtonModule, MatIconModule, @@ -210,6 +214,41 @@ describe('WebPlayerViewComponent', () => { }); }); + it('relays the alternative row’s Check action out of the error screen', () => { + const checked: string[] = []; + component.sourceCheckRequested.subscribe((id) => checked.push(id)); + fixture.componentRef.setInput('alternativeSources', [ + { + id: 'playlist-2:xtream:991', + playlistId: 'playlist-2', + playlistName: 'Portal Two', + portalType: 'xtream', + contentId: 991, + rawTitle: 'Example Movie', + matchConfidence: 'exact', + year: null, + isActive: false, + isPinned: false, + isTried: false, + probe: { status: 'idle' }, + }, + ]); + + fixture.detectChanges(); + component.handlePlaybackIssue(createUnsupportedContainerDiagnostic()); + fixture.detectChanges(); + + const check = fixture.debugElement.query( + By.css('app-vod-source-row .source-tag--action') + ); + expect(check).not.toBeNull(); + check.nativeElement.click(); + + // Without the relay the button is visibly actionable and inert: the + // row emits into an output nobody observes, so no probe ever runs. + expect(checked).toEqual(['playlist-2:xtream:991']); + }); + it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => { const requests: unknown[] = []; runtimeCapabilities.supportsManagedExternalPlayers = true; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index e43279836..b024a43c7 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -116,6 +116,8 @@ export class WebPlayerViewComponent { */ readonly alternativeSources = input([]); readonly alternativeSourceRequested = output(); + /** The row's Check action, which has to reach the host that can probe. */ + readonly sourceCheckRequested = output(); /** * A playback failure the host may be able to recover from by switching * source. Emitted alongside showing the overlay, never instead of it: if