diff --git a/.changes/portals-vod-multi-source.md b/.changes/portals-vod-multi-source.md new file mode 100644 index 000000000..ffc37abdc --- /dev/null +++ b/.changes/portals-vod-multi-source.md @@ -0,0 +1,10 @@ +--- +type: feature +area: portals +--- + +Movies that exist in several of your playlists now show a "Sources" chip on the +detail page. Switch playlist mid-film and playback continues from the same +timecode, pin a preferred source per movie, and when a stream dies the error +screen offers the alternatives instead of a dead end. Optional auto-switching is +off by default. diff --git a/CLAUDE.md b/CLAUDE.md index 4f82f2bfb..5090c4a66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,7 +280,7 @@ This is an Nx monorepo with the following structure: - **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components - **portal/catalog/feature** - Portal catalog UI - **portal/downloads/feature** - Download manager UI - - **portal/shared/{data-access,ui,util}** - Cross-portal shared code + - **portal/shared/{data-access,ui,util}** - Cross-portal shared code (incl. the VOD multi-source discovery/resolve/ranking layer in `data-access/src/lib/multi-source/`) - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) - **shared/logging** - Dependency-free structured redaction for diagnostic logs @@ -631,6 +631,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - `downloads` - Download manager state - `appState` - Key-value app state (also tracks one-off data migrations) - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language) + - `vodSourcePins` (`vod_source_pins`) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in `vod-source-pins.schema.ts`, re-exported by `schema.ts`) - **Connection**: `libs/shared/database/src/lib/connection.ts` - `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`) - Provides full read-write access for `electron-backend` and a read-only mode @@ -891,6 +892,20 @@ engine` (restart required) or - Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only. - See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout") +**VOD Multi-Source** (alternative sources for a movie): + +- 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 — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying `api` provenance. The route's OWN row is never resolved, so it takes its facts from the `get_vod_info` the page already loaded (`providerVodMetadataOf`, shared with the resolver) and picks them up via `refreshRouteFacts()` even when they arrive without changing the movie identity — otherwise `audioDiffersFactually` has nothing on one side and the dub warning cannot fire on a route-to-alternative switch. +- 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 an ASCII 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 — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. 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 — 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 write stores the decision under **every** key in `write` (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`: one upsert per key plus the leftover retirement, in a single transaction), because a movie's identity grows — recorded only under the enriched `tmdb:` key, a pin is invisible to the next reopen, which starts out with just a title and a year, and stays invisible for good if enrichment is off or never answers. 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. The primary button says nothing at all until that row is in, and "is it in" is answered by comparing the loaded pin **id** rather than mere presence, or re-pinning would leave the button wearing the previous copy's timecode. 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. +- Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`. +- Pins are included in playlist backup as the optional `sourcePins` collection, carried under the playlist they point at; `matchKey` survives untouched and only the playlist id is remapped on restore (older archives simply lack the field). +- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); 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 — but SELECTION is not an attempt: `setActiveSource` only selects, `markPlaying` spends the turn, and `runFailover` retires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; 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 a spoken **language** as fact — `audioLanguage`, never `audio`. The latter holds the codec whenever the fact came from the API, and a codec cannot answer that question: AAC and AC3 routinely carry the same dub while two AC3 tracks can carry different ones, so comparing codecs fired on identical-language re-encodes and stayed silent on real dub changes. Few panels tag a language, so the warning is usually silent — which is the honest state. +- 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` + **Radio Player**: - Dedicated audio player for channels with `radio="true"` M3U attribute diff --git a/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts b/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts new file mode 100644 index 000000000..0cb3e6e65 --- /dev/null +++ b/apps/electron-backend-e2e/src/vod-multi-source.e2e.ts @@ -0,0 +1,131 @@ +import { + addXtreamPortal, + clickCategoryByNameExact, + clickGridListCardByTitle, + closeElectronApp, + expect, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + fetchXtreamVodFixture, + getXtreamTitle, + pickDistinctTitles, +} from './portal-mock-fixtures'; + +/** + * `multisrc1` and `multisrc2` are two distinct credential pairs that share one + * faker seed, so both portals generate an IDENTICAL catalog. That overlap is + * the whole premise of this feature: the same movie in two playlists. + */ +const portalA = { username: 'multisrc1', password: 'multisrc1' }; +const portalB = { username: 'multisrc2', password: 'multisrc2' }; + +test.describe('VOD multi-source', () => { + test('offers the same movie from another playlist and switches to it', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodFixture = await fetchXtreamVodFixture(request, portalA); + const [movieTitle] = pickDistinctTitles( + vodFixture.items, + getXtreamTitle + ); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + ...portalA, + name: 'Multi Source A', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + // The second portal is what makes an alternative exist at all. + await addXtreamPortal(app.mainWindow, { + ...portalB, + name: 'Multi Source B', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); + await clickGridListCardByTitle(app.mainWindow, movieTitle); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ + ); + + // Discovery is lazy, so the chip appears after the detail view. + const chip = app.mainWindow.locator('app-vod-sources-chip button'); + await expect(chip).toBeVisible({ timeout: 20000 }); + // Exactly one alternative: the same film in the other playlist. + await expect(chip).toContainText('1'); + + await chip.click(); + + // Both the playing source and the alternative are listed. + const rows = app.mainWindow.locator('app-vod-source-row'); + await expect(rows).toHaveCount(2); + // Portal B was added last and is active, so A is the alternative. + await expect( + rows.filter({ hasText: 'Multi Source A' }) + ).toHaveCount(1); + // The playing row must name its playlist, never a raw UUID. + await expect( + rows.filter({ hasText: 'Multi Source B' }) + ).toHaveCount(1); + } finally { + await closeElectronApp(app); + } + }); + + test('shows no sources chip when only one playlist has the movie', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodFixture = await fetchXtreamVodFixture(request, portalA); + const [movieTitle] = pickDistinctTitles( + vodFixture.items, + getXtreamTitle + ); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + ...portalA, + name: 'Multi Source A', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); + await clickGridListCardByTitle(app.mainWindow, movieTitle); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ + ); + + // The action row must look exactly as it did before this feature. + await expect( + app.mainWindow.locator('.play-btn').first() + ).toBeVisible({ timeout: 20000 }); + await expect( + app.mainWindow.locator('app-vod-sources-chip') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index effaa9c1d..73a72dc30 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -50,6 +50,18 @@ const tmdbCacheEntry = { tmdbId: 603, payload: '{"id":603}', }; +const vodSourceRequest = { + title: 'The Matrix', + year: 1999, + excludePlaylistId: playlistId, +}; +const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999']; +const vodSourcePin = { + matchKey: 'tmdb:603', + playlistId, + contentId: 42, + portalType: 'xtream', +}; export const dbPreloadCases: PreloadInvokeCase[] = [ { @@ -388,6 +400,53 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_MATCH_TITLES', forwardedArgs: [['The Matrix', 'Inception']], }, + { + method: 'dbFindTitleSources', + args: [vodSourceRequest], + channel: 'DB_FIND_TITLE_SOURCES', + forwardedArgs: [vodSourceRequest], + }, + { + method: 'dbGetVodSourcePin', + args: [vodSourceMatchKeys], + channel: 'DB_GET_VOD_SOURCE_PIN', + forwardedArgs: [vodSourceMatchKeys], + }, + { + method: 'dbListVodSourcePins', + args: ['playlist-1'], + channel: 'DB_LIST_VOD_SOURCE_PINS', + forwardedArgs: ['playlist-1'], + }, + { + method: 'dbClearVodSourcePinsForPlaylist', + args: ['playlist-1'], + channel: 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', + forwardedArgs: ['playlist-1'], + }, + { + method: 'dbSetVodSourcePin', + args: [vodSourcePin, ['title:dune:'], ['title:dune:2021']], + channel: 'DB_SET_VOD_SOURCE_PIN', + // Both key lists ride along, so the write, the extra keys it is also + // stored under, and the retirement are one transaction rather than + // separate calls that can half-apply. + forwardedArgs: [vodSourcePin, ['title:dune:'], ['title:dune:2021']], + }, + { + method: 'dbReplaceVodSourcePins', + args: ['playlist-1', [vodSourcePin]], + channel: 'DB_REPLACE_VOD_SOURCE_PINS', + // One statement for the whole playlist: restore cannot afford a clear + // and a write that can half-apply. + forwardedArgs: ['playlist-1', [vodSourcePin]], + }, + { + method: 'dbClearVodSourcePin', + args: [vodSourceMatchKeys], + channel: 'DB_CLEAR_VOD_SOURCE_PIN', + forwardedArgs: [vodSourceMatchKeys], + }, ]; export const epgPreloadCases: PreloadInvokeCase[] = [ diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index d65a15ab6..28927ef91 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -57,6 +57,8 @@ import type { Settings, TmdbCacheEntry, TmdbCacheMediaType, + StreamProbeHeaders, + VodSourcePin, XtreamCategory, } from '@iptvnator/shared/interfaces'; import { @@ -660,6 +662,11 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId), xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') => ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }), + probeStreamUrl: ( + url: string, + method?: 'GET' | 'HEAD', + headers?: StreamProbeHeaders + ) => ipcRenderer.invoke('STREAM_PROBE_URL', { url, method, ...headers }), refreshPlaylist: (payload: PlaylistRefreshPayload) => ipcRenderer.invoke('PLAYLIST:REFRESH', payload), cancelPlaylistRefresh: (operationId: string) => @@ -886,6 +893,33 @@ const electronApi: ElectronBridgeApi = { dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'), dbMatchTitles: (titles: string[]) => ipcRenderer.invoke('DB_MATCH_TITLES', titles), + // VOD multi-source + dbFindTitleSources: (request: { + title: string; + year?: number | null; + excludePlaylistId?: string | null; + }) => ipcRenderer.invoke('DB_FIND_TITLE_SOURCES', request), + dbGetVodSourcePin: (matchKeys: string[]) => + ipcRenderer.invoke('DB_GET_VOD_SOURCE_PIN', matchKeys), + dbListVodSourcePins: (playlistId: string) => + ipcRenderer.invoke('DB_LIST_VOD_SOURCE_PINS', playlistId), + dbClearVodSourcePinsForPlaylist: (playlistId: string) => + ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', playlistId), + dbSetVodSourcePin: ( + pin: VodSourcePin, + retireKeys?: string[], + aliasKeys?: string[] + ) => + ipcRenderer.invoke( + 'DB_SET_VOD_SOURCE_PIN', + pin, + retireKeys ?? [], + aliasKeys ?? [] + ), + dbReplaceVodSourcePins: (playlistId: string, pins: VodSourcePin[]) => + ipcRenderer.invoke('DB_REPLACE_VOD_SOURCE_PINS', playlistId, pins), + dbClearVodSourcePin: (matchKeys: string[]) => + ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys), // Playback Positions dbSavePlaybackPosition: ( playlistId: string, diff --git a/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts index 6a9c6904f..3c0ef5af8 100644 --- a/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts +++ b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts @@ -109,7 +109,18 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) { return query; }); - const insertValues = jest.fn().mockResolvedValue(undefined); + // Same `.run()` requirement as the update below: an insert executed inside + // a synchronous transaction callback has to be the synchronous form. + const insertRun = jest.fn(); + const insertChain: Record = { + run: insertRun, + then: ( + resolve: (value: unknown) => void, + reject: (reason: unknown) => void + ) => Promise.resolve(undefined).then(resolve, reject), + }; + insertChain.onConflictDoUpdate = jest.fn().mockReturnValue(insertChain); + const insertValues = jest.fn().mockReturnValue(insertChain); const insert = jest.fn().mockReturnValue({ values: insertValues }); // Prepared-statement writes MUST use `.run()` (synchronous) rather than @@ -138,6 +149,8 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) { .fn() .mockReturnValue({ execute: deleteExecute, run: deleteRun }); const deleteResult = { + // Inside a transaction the delete must be the synchronous form too. + run: deleteRun, prepare: deletePrepare, then: ( resolve: (value: unknown) => void, @@ -170,6 +183,7 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) { deleteFn, deletePrepare, deleteRun, + insertRun, deleteWhere, insert, insertValues, diff --git a/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts new file mode 100644 index 000000000..4bd780071 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts @@ -0,0 +1,278 @@ +import { findTitleSources } from './title-sources.operations'; +import { + compiledQuery, + createDbMock, + duneRow, +} from './title-sources.spec-data'; + +/** + * What survives the normalized confirmation, and which playlists are in scope. + * Split from the query-shape suite so both stay inside the file-size rule. + */ +describe('title-sources.operations — confirmation and scoping', () => { + describe('match confirmation', () => { + it('maps a confirmed row onto the candidate shape as an exact match', async () => { + const { db } = createDbMock([duneRow]); + + await expect( + findTitleSources(db, { title: 'Dune' }) + ).resolves.toEqual([ + { + playlistId: 'playlist-1', + playlistName: 'Portal One', + categoryId: 12, + xtreamId: 501, + title: 'Dune', + posterUrl: 'https://cdn.example.com/dune.jpg', + matchConfidence: 'exact', + year: null, + }, + ]); + }); + + it('confirms a year-stripped candidate as a fuzzy match', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]); + + const matches = await findTitleSources(db, { title: 'Dune' }); + + expect(matches).toHaveLength(1); + expect(matches[0].matchConfidence).toBe('fuzzy'); + expect(matches[0].year).toBe(1984); + }); + + it('drops candidates whose normalized title differs', async () => { + const { db } = createDbMock([ + { ...duneRow, title: 'Dune Part Two' }, + ]); + + await expect( + findTitleSources(db, { title: 'Dune' }) + ).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' }]); + + // Same base title, but the user is watching the 2021 film — a + // year-stripped match is only trustworthy when the two sides do + // not actively disagree. + await expect( + findTitleSources(db, { title: 'Dune', year: 2021 }) + ).resolves.toEqual([]); + }); + + it('keeps an identical title whose tail is part of its name', async () => { + const { db } = createDbMock([ + { ...duneRow, title: 'Blade Runner 2049' }, + ]); + + // Enrichment supplies the real release year, 2017, while the title + // ends in a four-digit number that is part of the NAME. Reading it + // as a release tag makes the years "disagree" and the genuine copy + // disappears the moment metadata lands — the one point at which the + // user has most reason to expect it. + const matches = await findTitleSources(db, { + title: 'Blade Runner 2049', + year: 2017, + }); + + expect(matches).toHaveLength(1); + expect(matches[0].matchConfidence).toBe('exact'); + }); + + it('still rejects a bracketed remake of an otherwise identical title', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]); + + // The relaxation above must not reach a BRACKETED year: brackets + // are unambiguously a tag, never part of the name, so a stated + // disagreement still means a different film. + await expect( + findTitleSources(db, { title: 'Dune', year: 2021 }) + ).resolves.toEqual([]); + }); + + it('keeps a base-tier match when one side has no known year', async () => { + const { db } = createDbMock([duneRow]); + + // Query carries the year tag, the candidate does not: an unknown + // year must never block the match. + const matches = await findTitleSources(db, { title: 'Dune 2021' }); + + expect(matches).toHaveLength(1); + expect(matches[0].matchConfidence).toBe('fuzzy'); + expect(matches[0].year).toBeNull(); + }); + + it('tolerates a one-year drift between the two sides', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune 2021' }]); + + const matches = await findTitleSources(db, { + title: 'Dune', + year: 2020, + }); + + expect(matches).toHaveLength(1); + expect(matches[0].matchConfidence).toBe('fuzzy'); + }); + }); + + describe('playlist scoping and duplicates', () => { + it('never returns the playlist the user is already on', async () => { + const { db } = createDbMock([ + duneRow, + { + ...duneRow, + content_id: 2, + xtream_id: 777, + playlist_id: 'playlist-2', + playlist_name: 'Portal Two', + }, + ]); + + const matches = await findTitleSources(db, { + title: 'Dune', + excludePlaylistId: 'playlist-1', + }); + + expect(matches).toHaveLength(1); + expect(matches[0].playlistId).toBe('playlist-2'); + }); + + it('excludes the current playlist inside both queries, not after them', async () => { + // Filtering afterwards is not enough: the current playlist often + // lists the film several times, and those rows would spend the row + // budget the alternatives need — the chip then vanishes even + // though other playlists have the movie. + const fts = createDbMock([duneRow]); + await findTitleSources(fts.db, { + title: 'Dune', + excludePlaylistId: 'playlist-1', + }); + + const ftsQuery = compiledQuery(fts.all); + expect(ftsQuery.sql).toContain('cat.playlist_id <> ?'); + expect(ftsQuery.params).toContain('playlist-1'); + expect(ftsQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan( + ftsQuery.sql.indexOf('LIMIT') + ); + + // The short-title scan is a separate statement and needs it too. + const scan = createDbMock([duneRow]); + await findTitleSources(scan.db, { + title: 'It', + excludePlaylistId: 'playlist-1', + }); + + const scanQuery = compiledQuery(scan.all); + expect(scanQuery.sql).toContain('cat.playlist_id <> ?'); + expect(scanQuery.params).toContain('playlist-1'); + // The scan takes no window, so the cost it saves here is the rows + // read rather than the rows kept. + expect(scanQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan( + scanQuery.sql.indexOf('ORDER BY') + ); + }); + + 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('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]); + + await findTitleSources(db, { title: 'Dune' }); + + expect(compiledQuery(all).sql).not.toContain('cat.playlist_id <>'); + }); + + it('collapses the same film listed in several categories', async () => { + const { db } = createDbMock([ + duneRow, + { ...duneRow, content_id: 2, category_xtream_id: 13 }, + { ...duneRow, content_id: 3, category_xtream_id: 14 }, + ]); + + const matches = await findTitleSources(db, { title: 'Dune' }); + + expect(matches).toHaveLength(1); + expect(matches[0].categoryId).toBe(12); + }); + + it('keeps the same film when it lives in different playlists', async () => { + const { db } = createDbMock([ + duneRow, + { + ...duneRow, + playlist_id: 'playlist-2', + playlist_name: 'Portal Two', + }, + ]); + + const matches = await findTitleSources(db, { title: 'Dune' }); + + expect(matches.map((match) => match.playlistId)).toEqual([ + 'playlist-1', + 'playlist-2', + ]); + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts new file mode 100644 index 000000000..8a7cc29ca --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts @@ -0,0 +1,174 @@ +import { findTitleSources } from './title-sources.operations'; +import { + compiledQuery, + createDbMock, + createFailingDbMock, + duneRow, +} from './title-sources.spec-data'; + +describe('title-sources.operations', () => { + describe('unusable queries', () => { + it('returns nothing for a blank or non-string title', async () => { + const { db, all } = createDbMock([duneRow]); + + await expect(findTitleSources(db, { title: '' })).resolves.toEqual( + [] + ); + await expect( + findTitleSources(db, { title: ' ' }) + ).resolves.toEqual([]); + await expect( + findTitleSources(db, { + title: null as unknown as string, + }) + ).resolves.toEqual([]); + expect(all).not.toHaveBeenCalled(); + }); + + it('still searches when no token survives the trigram minimum', async () => { + // "up" is two chars, so the trigram tokenizer cannot index it. That + // is a real movie title, and it must not silently return nothing — + // the query falls back to a scan instead of being discarded. + const upRow = { ...duneRow, title: 'Up', xtream_id: 77 }; + const { db, all } = createDbMock([upRow]); + + const result = await findTitleSources(db, { title: 'Up' }); + + expect(all).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ xtreamId: 77, matchConfidence: 'exact' }) + ); + }); + + it('scans on a word boundary, shortest first, and never truncates', async () => { + // A substring scan for "It" matches "Titanic" and "The Italian + // Job"; capped and sorted by title, those can push the real "It" + // out of the result set and discovery reports no sources at all. + // The scan therefore asks for the token as a WORD and orders by + // title length (a match is the title plus decoration). + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'It' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.sql).toContain('GLOB ?'); + expect(scanQuery.sql).not.toContain('LIKE ?'); + // Each letter is a class of its own forms, so the assertion is + // about the SHAPE: a word boundary either side of one class per + // character. + expect(scanQuery.params).toContainEqual( + expect.stringMatching( + /^\*\[\^a-z0-9\](\[[^\]]*i[^\]]*\])(\[[^\]]*t[^\]]*\])\[\^a-z0-9\]\*$/ + ) + ); + expect(scanQuery.sql).toContain('ORDER BY LENGTH(c.title)'); + // No window at all: unlike FTS this cannot rank, so a limit would + // silently decide which valid sources the user may see — and the + // GLOB reads every row regardless, so it would not even save work. + expect(scanQuery.sql).not.toContain('LIMIT'); + + const fts = createDbMock([]); + await findTitleSources(fts.db, { title: 'Dune' }); + expect(compiledQuery(fts.all).sql).toContain('LIMIT'); + }); + + it('can still find a short non-ASCII title', async () => { + // SQLite's LOWER() is ASCII-only, so folding "Он" to "он" never + // happened and the film stayed invisible in the Sources chip. + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'Он' }); + const scanQuery = compiledQuery(scan.all); + + // GLOB character classes are NOT ASCII-only — they compare UTF-8 + // code points — so the case is folded in JavaScript and handed + // over one class per character. Without this a title stored in + // any case other than the two below is simply never found. + expect(scanQuery.params).toContain('*[Оо][нН]*'); + expect(scanQuery.sql).toContain('instr'); + // Both the folded and the as-typed form, since the class is built + // from the raw token and cannot cover a diacritic difference. + expect(scanQuery.params).toContain('он'); + expect(scanQuery.params).toContain('Он'); + }); + + it('does not treat a diacritic-folded token as ASCII', async () => { + // "Ça" normalizes to "ca", which LOOKS like plain ASCII — but the + // stored title still reads "Ça" and SQLite folds neither the + // cedilla nor the case, so the GLOB branch filtered it out before + // the confirmation could run. + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'Ça' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.sql).toContain('instr'); + expect(scanQuery.params).toContain('Ça'); + }); + + it('pairs raw tokens by meaning, not by position', async () => { + // Normalization drops whole words, so "FR: Ça" becomes just "ca". + // Pairing positionally would hand "ca" the raw token "FR:" and + // send it down the ASCII branch, where it cannot match "Ça". + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'FR: Ça' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.sql).toContain('instr'); + expect(scanQuery.params).toContain('Ça'); + }); + + it('keeps the word boundary for ASCII tokens', async () => { + // The looser substring test must not leak into the ASCII path, + // where it would let "it" match "Titanic". + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'It' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.sql).toContain('GLOB ?'); + expect(scanQuery.sql).not.toContain('instr'); + }); + + it('requires every token of an all-short multiword title', async () => { + // "I Am" reaches the scan too — no token survives the trigram + // minimum. Matching on the first token alone returns every catalog + // row containing the word "i", which the TypeScript pass then + // throws away: a full scan of a large catalog on the single DB + // worker, just to open a detail page. + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'I Am' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.params).toContainEqual( + expect.stringMatching( + /^\*\[\^a-z0-9\]\[[^\]]*i[^\]]*\]\[\^a-z0-9\]\*$/ + ) + ); + expect(scanQuery.params).toContainEqual( + expect.stringMatching( + /^\*\[\^a-z0-9\]\[[^\]]*a[^\]]*\]\[[^\]]*m[^\]]*\]\[\^a-z0-9\]\*$/ + ) + ); + expect(scanQuery.sql.match(/GLOB \?/g)).toHaveLength(2); + }); + + it('does not offer a scan hit whose title merely contains the query', async () => { + // The scan is deliberately loose; the two-tier normalized check + // afterwards is what keeps "Up" from matching "Upgrade". + const { db } = createDbMock([ + { ...duneRow, title: 'Upgrade', xtream_id: 78 }, + ]); + + await expect( + findTitleSources(db, { title: 'Up' }) + ).resolves.toEqual([]); + }); + + it('returns nothing instead of throwing when the FTS query fails', async () => { + const { db, all } = createFailingDbMock(); + + await expect( + findTitleSources(db, { title: 'Weird "Title"' }) + ).resolves.toEqual([]); + expect(all).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts new file mode 100644 index 000000000..932661e1f --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -0,0 +1,364 @@ +import { sql, type SQL } from 'drizzle-orm'; +import { + normalizeTitleKeys, + titleYearsCompatible, + type VodSourceCandidateRow, +} from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { + caseInsensitiveGlobBody, + caseInsensitiveGlobPattern, +} from './title-token-glob'; + +/** + * VOD multi-source discovery: find the SAME movie in the user's other + * playlists. + * + * Deliberately not built on `buildTitleMatchIndex()` — that helper keeps one + * match per title key because the Similar rail only needs "does this exist + * somewhere". Here every copy in every playlist is a distinct, selectable + * source, so all of them are returned. + * + * Scope is Xtream-only: Stalker items never reach the `content` table (they + * would need a live authenticated portal search), and M3U playlists are stored + * as a JSON blob whose search path forces `content_type: 'live'`. + */ + +const CANDIDATE_LIMIT = 60; + +/** A row as it comes back from SQLite, before match confirmation. */ +interface TitleSourceRow { + content_id: number; + title: string; + xtream_id: number; + poster_url: string | null; + category_xtream_id: number; + playlist_id: string; + playlist_name: string; +} + +export interface FindTitleSourcesRequest { + title: string; + /** Known release year of the movie being viewed, when available. */ + 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 { + const tokens = normalizedTitle + .split(' ') + // The trigram tokenizer cannot match tokens shorter than 3 chars + .filter((token) => token.length >= 3) + .map((token) => `"${token.replace(/"/g, '""')}"`); + return tokens.join(' AND '); +} + +/** + * The playlist the user is already on, removed IN SQL. + * + * Filtering it out afterwards is not enough: a playlist that lists the film in + * a dozen categories would spend the whole row budget on rows that are then + * all discarded, and the alternatives it was supposed to make room for would + * never be read at all. + */ +function excludePlaylistClause( + excludePlaylistId: string | null | undefined, + keepContentId: number | null | undefined +): 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}`; +} + +/** + * The trigram tokenizer cannot index tokens shorter than three characters, so + * a title like "Up", "It" or "Us" produces an empty MATCH expression. Those are + * real movies, and silently returning nothing for them means the chip never + * appears. This fallback scans instead — slower, and only reachable for the + * handful of titles FTS structurally cannot serve. + * + * The predicate is a WORD-boundary match, not a substring one: `LIKE '%it%'` + * matches "Titanic" and "The Italian Job", so noise could crowd out the real + * "It" entirely. Padding both sides lets one GLOB pattern cover the start, + * middle and end of the title at once. + * + * And it takes NO row limit, unlike the FTS path. FTS ranks by relevance, so + * a window keeps the best rows; a scan can only order by title, so a window + * silently decides which valid sources the user is allowed to see. It is also + * a false economy — the GLOB cannot use an index, so SQLite reads every row + * either way and a `LIMIT` saves only transfer. What bounds this instead is + * the predicate: reaching here means the movie's whole title is one or two + * characters, and only films carrying that exact word come back. Rows arrive + * shortest-title-first because a match is the title plus decoration + * ("IT (2017) 1080p"). + * + * Still a necessary-not-sufficient filter — `normalizeTitleKeys` below is what + * confirms a match. + */ +/** + * One token's presence test. + * + * SQLite's `LOWER()` is ASCII-only — `LOWER('Он')` is `'Он'` — so a Cyrillic or + * Greek token cannot be folded by SQL, and matching "он" against a stored "Он" + * simply failed: the film stayed invisible in the Sources chip. + * + * GLOB character classes are NOT ASCII-only, though; they compare UTF-8 code + * points. So the non-ASCII branch folds the case in JavaScript, where Unicode + * case mapping is real, and hands SQLite one `[lowerUpper]` class per + * character. The two substring tests stay alongside it, because the class is + * built from the RAW token and cannot match a title whose diacritics differ + * ("Ça" vs a stored "Ca"), which the folded-token test does cover. + * + * ASCII tokens keep the word-boundary GLOB, which is what stops "it" matching + * "Titanic". The non-ASCII branch has no word boundary — `[^a-z0-9]` would + * treat any Cyrillic letter as a separator — so it is looser, and the + * normalized confirmation afterwards is what makes that safe: a looser filter + * here costs transfer, never a wrong match. + */ +function tokenPredicate(token: string, rawToken: string): SQL { + // Decided from the RAW token, not the normalized one. Normalization folds + // diacritics, so "Ça" arrives here as "ca" — which looks like plain ASCII + // while the stored title still reads "Ça", and SQLite folds neither the + // cedilla nor the case. Branching on the folded form sent that title down + // the GLOB path and filtered it out before the confirmation ever ran. + // + // Normalized ASCII tokens hold letters and digits only, so nothing here + // is a GLOB metacharacter. + if (/^[\x20-\x7e]*$/.test(rawToken) && /^[a-z0-9]+$/.test(token)) { + // Each letter also carries its accented forms. The token has already + // had its own diacritics folded, so without this an ASCII "ca" cannot + // find a stored "Ça" while "Ça" finds "Ca" through the branch below — + // making discovery depend on which playlist the user happens to have + // open. The word boundary is kept: it is what stops "it" matching + // "Titanic". + const anyForm = + caseInsensitiveGlobBody(token, { foldDiacritics: true }) ?? token; + return sql`' ' || LOWER(c.title) || ' ' GLOB ${`*[^a-z0-9]${anyForm}[^a-z0-9]*`}`; + } + + const substring = sql`(instr(LOWER(c.title), ${token}) > 0 OR instr(c.title, ${rawToken}) > 0)`; + const anyCase = caseInsensitiveGlobPattern(rawToken); + // `null` means the token holds a GLOB metacharacter or a case mapping that + // changes length, and the substring tests alone are the honest answer. + return anyCase ? sql`(c.title GLOB ${anyCase} OR ${substring})` : substring; +} + +function scanCandidateQuery( + base: string, + rawTitle: string, + excludePlaylist: SQL +) { + // EVERY token has to appear, not just the first. This path is reached + // whenever no token survives FTS's minimum length, which includes + // multiword titles like "I Am" — matching on "i" alone would return most + // of the catalog and hand it all to the TypeScript pass to throw away. + const tokens = base.split(' ').filter(Boolean); + // Paired by NORMALIZED form, not by position: normalization drops whole + // words ("FR: Ça" -> "ca"), so a positional pairing hands "ca" the raw + // token "FR:" and picks the wrong branch for it. + const rawByBase = new Map(); + for (const raw of rawTitle.split(/\s+/).filter(Boolean)) { + const normalized = normalizeTitleKeys(raw).base; + if (normalized && !rawByBase.has(normalized)) { + rawByBase.set(normalized, raw); + } + } + + const wordMatches = sql.join( + tokens.map((token) => + tokenPredicate(token, rawByBase.get(token) ?? token) + ), + sql` AND ` + ); + return sql` + SELECT + c.id AS content_id, + c.title AS title, + c.xtream_id AS xtream_id, + c.poster_url AS poster_url, + cat.xtream_id AS category_xtream_id, + cat.playlist_id AS playlist_id, + p.name AS playlist_name + FROM content AS c + INNER JOIN categories AS cat ON c.category_id = cat.id + INNER JOIN playlists AS p ON cat.playlist_id = p.id + WHERE c.type = 'movie' + AND cat.hidden = 0 + AND p.type = 'xtream' + AND ${wordMatches} + ${excludePlaylist} + ORDER BY LENGTH(c.title), c.title + `; +} + +/** + * 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 + c.id AS content_id, + c.title AS title, + c.xtream_id AS xtream_id, + c.poster_url AS poster_url, + cat.xtream_id AS category_xtream_id, + cat.playlist_id AS playlist_id, + p.name AS playlist_name + FROM content_title_fts + INNER JOIN content AS c ON c.id = content_title_fts.rowid + INNER JOIN categories AS cat ON c.category_id = cat.id + INNER JOIN playlists AS p ON cat.playlist_id = p.id + WHERE content_title_fts MATCH ${matchQuery} + AND c.type = 'movie' + 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} + `; +} + +export async function findTitleSources( + db: AppDatabase, + request: FindTitleSourcesRequest +): Promise { + const rawTitle = request?.title; + if (typeof rawTitle !== 'string' || rawTitle.trim() === '') { + return []; + } + + const wanted = normalizeTitleKeys(rawTitle); + if (!wanted.base) { + return []; + } + + // 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, + request.keepContentId + ); + + let rows: TitleSourceRow[]; + try { + rows = matchQuery + ? ((await db.all( + ftsCandidateQuery(matchQuery, excludePlaylist) + )) as TitleSourceRow[]) + : ((await db.all( + scanCandidateQuery(wanted.base, rawTitle, excludePlaylist) + )) as TitleSourceRow[]); + } catch { + // Malformed FTS query for exotic titles — no sources, not a crash + return []; + } + + const matches: VodSourceCandidateRow[] = []; + // One playlist can list the same film in several categories; the user + // thinks of that as one source. + const seen = new Set(); + + for (const row of rows) { + // SQL already excluded these; this keeps the guarantee a property of + // 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.xtream_id !== request.keepContentId + ) { + continue; + } + + const rowKeys = normalizeTitleKeys(row.title); + const rowBracketedYear = bracketedYear(row.title); + const rowYear = rowKeys.trailingYear ?? rowBracketedYear; + // 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); + // The exact tier reads only the BRACKETED year, though. Reaching it + // means both titles are the same string, so any trailing four digits + // belong to both — and comparing a number that is part of the NAME + // against a release year from metadata rejects the very copy it was + // meant to confirm: "Blade Runner 2049" against a stated 2017 vanishes + // the moment enrichment lands. Brackets are never part of a name, so + // that form still decides. + const exactYearsAgree = titleYearsCompatible( + rowBracketedYear, + wantedYear + ); + const exactMatch = + rowKeys.exact !== '' && + rowKeys.exact === wanted.exact && + exactYearsAgree; + const baseMatch = + !exactMatch && rowKeys.base === wanted.base && yearsAgree; + + if (!exactMatch && !baseMatch) { + continue; + } + + const dedupeKey = `${row.playlist_id}:${row.xtream_id}`; + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + + matches.push({ + playlistId: row.playlist_id, + playlistName: row.playlist_name, + categoryId: row.category_xtream_id, + xtreamId: row.xtream_id, + title: row.title, + posterUrl: row.poster_url, + matchConfidence: exactMatch ? 'exact' : 'fuzzy', + year: rowYear, + }); + } + + return matches; +} diff --git a/apps/electron-backend/src/app/database/operations/title-sources.spec-data.ts b/apps/electron-backend/src/app/database/operations/title-sources.spec-data.ts new file mode 100644 index 000000000..cce0055d0 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-sources.spec-data.ts @@ -0,0 +1,42 @@ +import type { SQL } from 'drizzle-orm'; +import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; +import type { AppDatabase } from '../database.types'; + +/** + * Shared scaffolding for the title-source suites, so the query-shape tests and + * the match-confirmation tests can live in separate files without each + * carrying its own copy. + */ + +export function createDbMock(rows: unknown[] = []) { + const all = jest.fn().mockResolvedValue(rows); + return { db: { all } as unknown as AppDatabase, all }; +} + +/** + * The statement as SQLite will actually see it. + * + * `better-sqlite3` is built against the Electron ABI and cannot be loaded by + * Jest, so the row-shaping tests below run against a mock that returns rows + * whatever the query says. What rows the DATABASE is asked for is therefore + * only observable here — and it is exactly what the window-crowding fixes + * changed. + */ +export function compiledQuery(all: jest.Mock, call = 0) { + return new SQLiteSyncDialect().sqlToQuery(all.mock.calls[call][0] as SQL); +} + +export function createFailingDbMock() { + const all = jest.fn().mockRejectedValue(new Error('fts syntax')); + return { db: { all } as unknown as AppDatabase, all }; +} + +export const duneRow = { + content_id: 1, + title: 'Dune', + xtream_id: 501, + poster_url: 'https://cdn.example.com/dune.jpg', + category_xtream_id: 12, + playlist_id: 'playlist-1', + playlist_name: 'Portal One', +}; diff --git a/apps/electron-backend/src/app/database/operations/title-token-glob.spec.ts b/apps/electron-backend/src/app/database/operations/title-token-glob.spec.ts new file mode 100644 index 000000000..de9a80922 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-token-glob.spec.ts @@ -0,0 +1,169 @@ +import { execFileSync } from 'node:child_process'; +import { caseInsensitiveGlobPattern } from './title-token-glob'; + +/** + * Case folding for the tokens SQLite cannot fold. + * + * The patterns are only worth anything if SQLite agrees with them, so the + * behavioural cases below are asserted against a real `sqlite3` rather than + * against a restatement of the builder's own logic. + */ + +/** `true` when the running SQLite says `value GLOB pattern`. */ +function sqliteGlob(value: string, pattern: string): boolean { + const out = execFileSync( + 'sqlite3', + [':memory:', `SELECT ${quote(value)} GLOB ${quote(pattern)};`], + { encoding: 'utf8' } + ); + return out.trim() === '1'; +} + +function quote(literal: string): string { + return `'${literal.replace(/'/g, "''")}'`; +} + +/** + * The suite asserts against the sqlite3 CLI, which is present on macOS and on + * the CI images but is not a declared dependency of this workspace. + */ +function hasSqlite(): boolean { + try { + execFileSync('sqlite3', ['-version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const describeWithSqlite = hasSqlite() ? describe : describe.skip; + +describe('caseInsensitiveGlobPattern', () => { + it('builds a case class for each cased character', () => { + expect(caseInsensitiveGlobPattern('Он')).toBe('*[Оо][нН]*'); + }); + + it('leaves uncased characters alone', () => { + expect(caseInsensitiveGlobPattern('7')).toBe('*7*'); + }); + + it('covers a letter with two lowercase spellings', () => { + // Greek Σ lowercases to σ, but a word-final sigma is written ς and is + // just as much a lowercase of it. Going back down from the uppercase + // form reaches the spelling the character in hand does not name. + expect(caseInsensitiveGlobPattern('ς')).toBe('*[ςΣσ]*'); + }); + + it('reaches the second spelling in one direction only', () => { + // σ → Σ → σ never arrives at ς, so a request spelled with a medial + // sigma does not match a stored final one. Left as is: ς is only ever + // correct at the end of a word, which is exactly where the request's + // own last character sits, so the pair that occurs in real titles is + // the one above. Closing the other direction needs a fold table. + expect(caseInsensitiveGlobPattern('σ')).toBe('*[σΣ]*'); + }); + + it('refuses a token holding a GLOB metacharacter', () => { + // SQLite GLOB has no escape character, so an unescaped `*` here would + // silently become a wildcard and match every row in the table. + expect(caseInsensitiveGlobPattern('о*')).toBeNull(); + expect(caseInsensitiveGlobPattern('[о')).toBeNull(); + expect(caseInsensitiveGlobPattern('о-н')).toBeNull(); + }); + + it('refuses a case mapping that changes length', () => { + // 'ß'.toUpperCase() is 'SS' — there is no single-character class that + // means the same thing, and guessing one would be a wrong pattern + // rather than an absent one. + expect(caseInsensitiveGlobPattern('ß')).toBeNull(); + }); + + it('refuses an empty token', () => { + expect(caseInsensitiveGlobPattern('')).toBeNull(); + }); +}); + +describeWithSqlite('caseInsensitiveGlobPattern against SQLite', () => { + it('matches a Cyrillic title in any case', () => { + const pattern = caseInsensitiveGlobPattern('Он') as string; + + // The bug: LOWER() is ASCII-only, so a request for "Он" never found a + // stored "ОН" and the film was missing from the Sources chip. + expect(sqliteGlob('Он', pattern)).toBe(true); + expect(sqliteGlob('ОН', pattern)).toBe(true); + expect(sqliteGlob('он (2017)', pattern)).toBe(true); + }); + + it('is exactly what SQLite cannot do on its own', () => { + // Guards the premise the whole helper rests on: if a future SQLite + // folded Cyrillic in LOWER(), this indirection would be dead weight. + const out = execFileSync( + 'sqlite3', + [':memory:', `SELECT LOWER('Он') = 'он';`], + { encoding: 'utf8' } + ); + expect(out.trim()).toBe('0'); + }); + + it('still refuses a title that does not hold the token', () => { + const pattern = caseInsensitiveGlobPattern('Он') as string; + + expect(sqliteGlob('Дюна', pattern)).toBe(false); + }); + + it('matches a Greek word written with either sigma', () => { + const pattern = caseInsensitiveGlobPattern('ος') as string; + + expect(sqliteGlob('ος', pattern)).toBe(true); + expect(sqliteGlob('ΟΣ', pattern)).toBe(true); + // The spelling a class built from the request alone would miss. + expect(sqliteGlob('οσ', pattern)).toBe(true); + }); + + it('finds an accented title from its folded ASCII token', () => { + // The token has already had its own diacritics folded, so "ca" is what + // a search for "Ça" arrives as. Without the accented forms this + // direction fails while the reverse works, and whether two playlists + // can see each other then depends on which one is open. + const pattern = caseInsensitiveGlobPattern('ca', { + foldDiacritics: true, + }) as string; + + expect(sqliteGlob('Ça', pattern)).toBe(true); + expect(sqliteGlob('ÇA', pattern)).toBe(true); + expect(sqliteGlob('ca', pattern)).toBe(true); + expect(sqliteGlob('Ca', pattern)).toBe(true); + }); + + it('reaches beyond Latin Extended-B for the fold', () => { + // "Bố" (U+1ED1) is Vietnamese and normalizes to "bo" like any other + // accented o. A range that stops at Latin Extended-B looks tidy and + // leaves a whole language filtered out before confirmation. + const pattern = caseInsensitiveGlobPattern('bo', { + foldDiacritics: true, + }) as string; + + expect(sqliteGlob('Bố', pattern)).toBe(true); + expect(sqliteGlob('bồ', pattern)).toBe(true); + expect(sqliteGlob('Bo', pattern)).toBe(true); + }); + + it('does not fold diacritics unless asked', () => { + const pattern = caseInsensitiveGlobPattern('ca') as string; + + // The non-ASCII branch pairs the raw token with the folded one and + // relies on that, so widening this by default would be a silent + // behaviour change for every caller. + expect(sqliteGlob('Ça', pattern)).toBe(false); + expect(sqliteGlob('Ca', pattern)).toBe(true); + }); + + it('matches Greek and accented Latin in any case', () => { + expect( + sqliteGlob('ΟΙ', caseInsensitiveGlobPattern('οι') as string) + ).toBe(true); + expect( + sqliteGlob('ÇA', caseInsensitiveGlobPattern('Ça') as string) + ).toBe(true); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/title-token-glob.ts b/apps/electron-backend/src/app/database/operations/title-token-glob.ts new file mode 100644 index 000000000..299743ab6 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-token-glob.ts @@ -0,0 +1,151 @@ +/** + * Case-insensitive GLOB patterns for tokens SQLite cannot fold itself. + * + * `LOWER()` in stock SQLite is ASCII-only — `LOWER('Он')` is `'Он'` — so a + * Cyrillic or Greek token can never be folded in SQL. GLOB character classes, + * however, are NOT ASCII-only: `patternCompare` reads them as UTF-8 code + * points, so `'Он' GLOB '*[Оо][Нн]*'` is true. Folding the case in JavaScript, + * where Unicode case mapping is real, and handing SQLite one class per + * character gets the match SQL cannot compute on its own. + */ + +/** + * GLOB's own metacharacters. SQLite has no escape character in GLOB patterns + * (only the `[*]` class form), so a token containing one of these is left to + * the caller's substring fallback rather than escaped by hand. + */ +const GLOB_METACHARACTERS = /[*?[\]^-]/; + +/** + * Every accented Latin letter, bucketed by the plain letter it folds to. + * + * Derived rather than tabulated: decomposing a character and dropping its + * combining marks IS the definition of the base letter, so this cannot drift + * out of step with `normalizeTitleKeys`, which folds the same way. + */ +const ACCENTED_BY_BASE = ((): Map => { + const byBase = new Map(); + // Latin-1 Supplement through Latin Extended-B, PLUS Latin Extended + // Additional — which is where Vietnamese lives (ố is U+1ED1). Stopping at + // Latin Extended-B looks tidy and silently leaves a whole language out, + // while the normalizer folds it perfectly well. The filter below decides + // what actually belongs, so a range only has to be wide enough. + const ranges: ReadonlyArray<[number, number]> = [ + [0x00c0, 0x024f], + [0x1e00, 0x1eff], + ]; + for (const [first, last] of ranges) { + for (let codePoint = first; codePoint <= last; codePoint += 1) { + addAccentedForm(byBase, String.fromCodePoint(codePoint)); + } + } + return byBase; +})(); + +/** Files one character under the plain ASCII letter it decomposes to. */ +function addAccentedForm( + byBase: Map, + character: string +): void { + const base = character.normalize('NFD').replace(/\p{M}/gu, ''); + if (base.length !== 1 || !/[a-z]/i.test(base)) { + return; + } + + const key = base.toLowerCase(); + const forms = byBase.get(key) ?? []; + forms.push(character); + byBase.set(key, forms); +} + +/** + * A `*…*` containment pattern matching `token` in any case, or `null` when the + * token cannot be expressed safely. + * + * `null` is returned rather than a best-effort pattern for two reasons: a + * metacharacter would silently turn into a wildcard, and a character whose case + * mapping changes length (`ß` uppercases to `SS`) has no single-character class + * that means the same thing. Both are rare enough that the caller's plain + * substring test is a better answer than a subtly wrong pattern. + */ +export function caseInsensitiveGlobPattern( + token: string, + options: { foldDiacritics?: boolean } = {} +): string | null { + const body = caseInsensitiveGlobBody(token, options); + return body === null ? null : `*${body}*`; +} + +/** + * The class sequence alone, for callers that supply their own surroundings — + * the scan's word-boundary form needs `[^a-z0-9]` on each side rather than a + * wildcard. + */ +export function caseInsensitiveGlobBody( + token: string, + options: { foldDiacritics?: boolean } = {} +): string | null { + if (token === '' || GLOB_METACHARACTERS.test(token)) { + return null; + } + + let pattern = ''; + for (const character of token) { + const upper = character.toUpperCase(); + // Going back down from the uppercase form catches letters with more + // than one lowercase spelling: Greek Σ lowercases to σ, but ς is an + // equally valid lowercase of it, and a class built only from the + // character in hand would know just one of the two. + const forms = new Set([ + character, + character.toLowerCase(), + upper, + upper.toLowerCase(), + ]); + + // These are the forms the pattern MUST carry, so one it cannot express + // means there is no honest pattern to build. + if ( + [...forms].some( + (form) => + [...form].length !== 1 || GLOB_METACHARACTERS.test(form) + ) + ) { + return null; + } + + // A normalized token has already had its diacritics folded away, so + // "ca" has to be able to find a stored "Ça" — otherwise discovery runs + // one way only, depending on which playlist happens to be open. + // + // These are additions, never requirements: an accented form whose own + // case mapping is multi-character (ǰ uppercases to two code points) + // is simply left out, rather than costing the whole token its pattern. + if (options.foldDiacritics) { + const accents = ACCENTED_BY_BASE.get(character.toLowerCase()) ?? []; + for (const accented of accents) { + for (const form of [ + accented, + accented.toUpperCase(), + accented.toLowerCase(), + ]) { + if ( + [...form].length === 1 && + !GLOB_METACHARACTERS.test(form) + ) { + forms.add(form); + } + } + } + } + + if (forms.size === 1) { + pattern += character; + continue; + } + + pattern += `[${[...forms].join('')}]`; + } + + return pattern; +} diff --git a/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.spec.ts b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.spec.ts new file mode 100644 index 000000000..62f2fb23a --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.spec.ts @@ -0,0 +1,384 @@ +import { + createDbMock, + mockDrizzle, + mockDrizzleOrmModule, + resetDrizzleMocks, +} from './operations.test-helpers'; + +jest.mock('drizzle-orm', () => mockDrizzleOrmModule()); + +import * as schema from '@iptvnator/shared/database/schema'; +import type { VodSourcePin } from '@iptvnator/shared/interfaces'; +import { + clearVodSourcePin, + getVodSourcePin, + setVodSourcePin, + clearVodSourcePinsForPlaylist, + replaceVodSourcePinsForPlaylist, +} from './vod-source-pin.operations'; + +const tmdbRow = { + id: 1, + matchKey: 'tmdb:603', + playlistId: 'playlist-1', + contentId: 501, + portalType: 'xtream' as const, + updatedAt: '2026-07-20T10:00:00.000Z', +}; + +const titleRow = { + id: 2, + matchKey: 'title:the matrix:1999', + playlistId: 'playlist-2', + contentId: 902, + portalType: 'xtream' as const, + updatedAt: '2026-07-01T10:00:00.000Z', +}; + +/** + * Pins upsert, and run inside a transaction — so the chain has to end in a + * synchronous `.run()` rather than resolving, exactly as the driver requires. + */ +function createUpsertDbMock() { + const mock = createDbMock(); + const insertRun = jest.fn(); + const onConflictDoUpdate = jest.fn().mockReturnValue({ run: insertRun }); + mock.insertValues.mockReturnValue({ onConflictDoUpdate }); + return { ...mock, onConflictDoUpdate, insertRun }; +} + +describe('vod-source-pin.operations', () => { + beforeEach(() => { + resetDrizzleMocks(); + }); + + describe('getVodSourcePin', () => { + it('returns null without querying for an empty key list', async () => { + const { db, select } = createDbMock(); + + await expect(getVodSourcePin(db, [])).resolves.toBeNull(); + expect(select).not.toHaveBeenCalled(); + }); + + it('returns null when no pin row exists', async () => { + const { db } = createDbMock([[]]); + + await expect(getVodSourcePin(db, ['tmdb:603'])).resolves.toBeNull(); + }); + + it('honours caller key order, not the order SQLite returned', async () => { + // SQLite hands back the older title-keyed row first; the caller + // passes the tmdb key first because it is the more trusted one. + // Reading in row order would let a stale title pin shadow it. + const { db } = createDbMock([[titleRow, tmdbRow]]); + + await expect( + getVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999']) + ).resolves.toEqual({ + matchKey: 'tmdb:603', + playlistId: 'playlist-1', + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-20T10:00:00.000Z', + }); + }); + + it('falls back to the next key when the first has no row', async () => { + const { db } = createDbMock([[titleRow]]); + + const pin = await getVodSourcePin(db, [ + 'tmdb:603', + 'title:the matrix:1999', + ]); + + expect(pin?.matchKey).toBe('title:the matrix:1999'); + }); + + it('reports a missing timestamp as undefined', async () => { + const { db } = createDbMock([[{ ...tmdbRow, updatedAt: null }]]); + + const pin = await getVodSourcePin(db, ['tmdb:603']); + + expect(pin?.updatedAt).toBeUndefined(); + }); + + it('drops junk keys and caps the lookup at eight keys', async () => { + const { db } = createDbMock([[]]); + const keys = [ + '', + null as unknown as string, + undefined as unknown as string, + 42 as unknown as string, + 'k1', + 'k2', + 'k3', + 'k4', + 'k5', + 'k6', + 'k7', + 'k8', + 'k9', + ]; + + await getVodSourcePin(db, keys); + + expect(mockDrizzle.inArray).toHaveBeenCalledWith( + schema.vodSourcePins.matchKey, + ['k1', 'k2', 'k3', 'k4', 'k5', 'k6', 'k7', 'k8'] + ); + }); + }); + + describe('setVodSourcePin', () => { + const pin: VodSourcePin = { + matchKey: 'tmdb:603', + playlistId: 'playlist-1', + contentId: 501, + portalType: 'xtream', + }; + + it('upserts on the match key', async () => { + const { db, insert, insertValues, onConflictDoUpdate } = + createUpsertDbMock(); + + await expect(setVodSourcePin(db, pin)).resolves.toEqual({ + success: true, + }); + + expect(insert).toHaveBeenCalledWith(schema.vodSourcePins); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + matchKey: 'tmdb:603', + playlistId: 'playlist-1', + contentId: 501, + portalType: 'xtream', + updatedAt: expect.any(String), + }) + ); + expect(onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + target: schema.vodSourcePins.matchKey, + set: expect.objectContaining({ + playlistId: 'playlist-1', + contentId: 501, + portalType: 'xtream', + updatedAt: expect.any(String), + }), + }) + ); + }); + + it('retires the aliases in the same transaction as the write', async () => { + const { db, transaction, insertRun, deleteRun, deleteWhere } = + createUpsertDbMock(); + + await setVodSourcePin(db, pin, [ + 'title:the matrix:1999', + 'title:the matrix:', + ]); + + // Two calls could half-apply, and neither half-outcome has an + // honest answer: a surviving alias wins the next lookup, while a + // rolled-back-looking failure leaves the canonical row durable. + expect(transaction).toHaveBeenCalledTimes(1); + expect(insertRun).toHaveBeenCalledTimes(1); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(deleteWhere).toHaveBeenCalled(); + }); + + it('never retires the key it just wrote', async () => { + const { db, deleteRun } = createUpsertDbMock(); + + await setVodSourcePin(db, pin, ['tmdb:603']); + + expect(deleteRun).not.toHaveBeenCalled(); + }); + + it('stores the pin under every alias it was given', async () => { + const { db, insertValues, insertRun, transaction } = + createUpsertDbMock(); + + await setVodSourcePin(db, pin, [], ['title:the matrix:1999']); + + // A movie's identity grows: the film keyed `tmdb:603` today was + // `title:the matrix:1999` before enrichment, and reopening it + // cold asks for the poorer key first. One row would answer + // nothing until enrichment landed — if it ever did. + expect(transaction).toHaveBeenCalledTimes(1); + expect(insertRun).toHaveBeenCalledTimes(2); + expect( + insertValues.mock.calls.map(([row]) => row.matchKey) + ).toEqual(['tmdb:603', 'title:the matrix:1999']); + }); + + it('writes each key once, however often it is passed', async () => { + const { db, insertRun } = createUpsertDbMock(); + + await setVodSourcePin(db, pin, [], ['tmdb:603', 'tmdb:603']); + + expect(insertRun).toHaveBeenCalledTimes(1); + }); + + it('never retires an alias it just wrote', async () => { + const { db, deleteRun } = createUpsertDbMock(); + + // The same key can legitimately appear in both lists — it was an + // alias of the PREVIOUS pin and is a write key of this one. Taking + // the retirement literally would delete the row just written. + await setVodSourcePin( + db, + pin, + ['title:the matrix:1999'], + ['title:the matrix:1999'] + ); + + expect(deleteRun).not.toHaveBeenCalled(); + }); + + it('refuses a pin with no usable key rather than reporting success', async () => { + const { db, insertRun, transaction } = createUpsertDbMock(); + + await expect( + setVodSourcePin(db, { ...pin, matchKey: '' }) + ).resolves.toEqual({ success: false }); + + expect(transaction).not.toHaveBeenCalled(); + expect(insertRun).not.toHaveBeenCalled(); + }); + + it('touches nothing else when there is no alias to retire', async () => { + const { db, deleteRun } = createUpsertDbMock(); + + await setVodSourcePin(db, pin); + + expect(deleteRun).not.toHaveBeenCalled(); + }); + + it('writes an ISO timestamp on both the insert and the update', async () => { + const { db, insertValues, onConflictDoUpdate } = + createUpsertDbMock(); + + await setVodSourcePin(db, pin); + + const inserted = insertValues.mock.calls[0][0] as { + updatedAt: string; + }; + const updated = onConflictDoUpdate.mock.calls[0][0] as { + set: { updatedAt: string }; + }; + + expect(new Date(inserted.updatedAt).toISOString()).toBe( + inserted.updatedAt + ); + expect(updated.set.updatedAt).toBe(inserted.updatedAt); + }); + }); + + describe('clearVodSourcePin', () => { + it('reports failure without touching the database for no keys', async () => { + const { db, deleteFn } = createDbMock(); + + await expect(clearVodSourcePin(db, [])).resolves.toEqual({ + success: false, + }); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('deletes every alias of the movie in one statement', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect( + clearVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999']) + ).resolves.toEqual({ success: true }); + + expect(deleteFn).toHaveBeenCalledWith(schema.vodSourcePins); + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(mockDrizzle.inArray).toHaveBeenCalledWith( + schema.vodSourcePins.matchKey, + ['tmdb:603', 'title:the matrix:1999'] + ); + }); + }); + + describe('replaceVodSourcePinsForPlaylist', () => { + const pin: VodSourcePin = { + matchKey: 'tmdb:603', + playlistId: 'playlist-1', + contentId: 501, + portalType: 'xtream', + }; + + it('clears and rewrites inside ONE transaction', async () => { + const { db, transaction, deleteRun, insertRun } = + createUpsertDbMock(); + + await expect( + replaceVodSourcePinsForPlaylist(db, 'playlist-1', [pin, pin]) + ).resolves.toEqual({ success: true }); + + // Split into a clear and per-pin writes, a write failing partway + // leaves the old pins gone and the archive half-applied — a state + // belonging to neither, which the user cannot undo. + expect(transaction).toHaveBeenCalledTimes(1); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(insertRun).toHaveBeenCalledTimes(2); + }); + + it('clears without writing for an empty set', async () => { + const { db, deleteRun, insertRun } = createUpsertDbMock(); + + // Present-but-empty means the user had none; the clear still runs. + await replaceVodSourcePinsForPlaylist(db, 'playlist-1', []); + + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(insertRun).not.toHaveBeenCalled(); + }); + + it('refuses a blank playlist id rather than clearing everything', async () => { + const { db, transaction } = createUpsertDbMock(); + + await expect( + replaceVodSourcePinsForPlaylist(db, '', [pin]) + ).resolves.toEqual({ success: false }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('drops rows that name no film', async () => { + const { db, insertRun } = createUpsertDbMock(); + + await replaceVodSourcePinsForPlaylist(db, 'playlist-1', [ + { ...pin, matchKey: '' }, + { ...pin, contentId: undefined as unknown as number }, + pin, + ]); + + expect(insertRun).toHaveBeenCalledTimes(1); + }); + }); + + describe('clearVodSourcePinsForPlaylist', () => { + it('deletes by playlist, with no key cap to truncate', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect( + clearVodSourcePinsForPlaylist(db, 'playlist-1') + ).resolves.toEqual({ success: true }); + + // The keyed clear caps its IN list at MAX_KEYS_PER_LOOKUP, so a + // playlist with more pinned movies than that kept the surplus — + // and still reported success. + expect(deleteFn).toHaveBeenCalledWith(schema.vodSourcePins); + expect(deleteWhere).toHaveBeenCalled(); + }); + + it('refuses a blank playlist id rather than clearing everything', async () => { + const { db, deleteFn } = createDbMock(); + + await expect( + clearVodSourcePinsForPlaylist(db, '') + ).resolves.toEqual({ success: false }); + + expect(deleteFn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts new file mode 100644 index 000000000..25695313e --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts @@ -0,0 +1,272 @@ +import { eq, inArray } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { VodSourcePin } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; + +/** + * VOD multi-source pins — the user's per-movie choice of preferred playlist. + * + * Lookups take SEVERAL keys rather than one: a movie pinned before TMDB + * enrichment landed is stored under its title key, and the same movie prefers + * a `tmdb:` key afterwards. Reading both means the id arriving later does not + * orphan an existing pin. + */ + +/** Guards against an unbounded IN list if a caller ever passes junk. */ +const MAX_KEYS_PER_LOOKUP = 8; + +export async function getVodSourcePin( + db: AppDatabase, + matchKeys: string[] +): Promise { + const keys = (matchKeys ?? []) + .filter((key) => typeof key === 'string' && key !== '') + .slice(0, MAX_KEYS_PER_LOOKUP); + + if (keys.length === 0) { + return null; + } + + const rows = await db + .select() + .from(schema.vodSourcePins) + .where(inArray(schema.vodSourcePins.matchKey, keys)); + + if (rows.length === 0) { + return null; + } + + // Callers pass keys most-trusted first (tmdb before title), so honour that + // order rather than whatever SQLite returned. + for (const key of keys) { + const row = rows.find((candidate) => candidate.matchKey === key); + if (row) { + return toPin(row); + } + } + + return null; +} + +/** + * Every pin pointing AT this playlist. + * + * A pin belongs to a movie but names one playlist, so the playlist it points + * at is the one that owns it for backup purposes — exporting it anywhere else + * would restore a preference for a portal that is not in the archive. + */ +export async function listVodSourcePinsForPlaylist( + db: AppDatabase, + playlistId: string +): Promise { + if (typeof playlistId !== 'string' || playlistId === '') { + return []; + } + + const rows = await db + .select() + .from(schema.vodSourcePins) + .where(eq(schema.vodSourcePins.playlistId, playlistId)); + + return rows.map(toPin); +} + +/** + * Store the pin under every key that names this film, and retire its old + * aliases, as ONE unit. + * + * `aliasKeys` exist because a movie's identity GROWS: the same film is + * `title:dune:2021` before TMDB enrichment lands and `tmdb:438631` after, and + * a decision recorded only under the richer key is invisible to the next + * reopen that has not been enriched yet. Callers pass every key that names + * exactly this one film — never the ambiguous yearless form, which every + * remake shares. + * + * Split across two calls there is no honest answer to a half-failure: report + * success and a surviving alias silently wins the next lookup; report failure + * and the canonical row is already durable, so a later reopen selects the + * source the UI said was rejected. A transaction removes the question. + */ +export async function setVodSourcePin( + db: AppDatabase, + pin: VodSourcePin, + retireKeys: string[] = [], + aliasKeys: string[] = [] +): Promise<{ success: boolean }> { + const updatedAt = new Date().toISOString(); + const write = [ + ...new Set( + [pin.matchKey, ...(aliasKeys ?? [])].filter( + (key) => typeof key === 'string' && key !== '' + ) + ), + ].slice(0, MAX_KEYS_PER_LOOKUP); + + // Nothing identifying to store it under. Reporting success here would + // promise a preference that was never written. + if (write.length === 0) { + return { success: false }; + } + + const retire = (retireKeys ?? []) + .filter( + (key) => + typeof key === 'string' && key !== '' && !write.includes(key) + ) + .slice(0, MAX_KEYS_PER_LOOKUP); + + const inserts = write.map((matchKey) => + db + .insert(schema.vodSourcePins) + .values({ + matchKey, + playlistId: pin.playlistId, + contentId: pin.contentId, + portalType: pin.portalType, + updatedAt, + }) + .onConflictDoUpdate({ + target: schema.vodSourcePins.matchKey, + set: { + playlistId: pin.playlistId, + contentId: pin.contentId, + portalType: pin.portalType, + updatedAt, + }, + }) + ); + + await db.transaction(() => { + // `.run()`, not `.execute()`: on better-sqlite3 the latter defers the + // write to a promise that never settles inside this synchronous + // callback, so the statement would be a silent no-op. + for (const insert of inserts) { + insert.run(); + } + + if (retire.length > 0) { + db.delete(schema.vodSourcePins) + .where(inArray(schema.vodSourcePins.matchKey, retire)) + .run(); + } + }); + + return { success: true }; +} + +export async function clearVodSourcePin( + db: AppDatabase, + matchKeys: string[] +): Promise<{ success: boolean }> { + const keys = (matchKeys ?? []) + .filter((key) => typeof key === 'string' && key !== '') + .slice(0, MAX_KEYS_PER_LOOKUP); + + if (keys.length === 0) { + return { success: false }; + } + + // Clears every alias of the movie, so unpinning is not undone by a stale + // row under the other key form. + await db + .delete(schema.vodSourcePins) + .where(inArray(schema.vodSourcePins.matchKey, keys)); + + return { success: true }; +} + +/** + * Drop every pin pointing at one playlist. + * + * NOT expressible through `clearVodSourcePin`: that takes match keys and caps + * them at `MAX_KEYS_PER_LOOKUP` to bound an IN list, so a playlist with more + * pinned movies than the cap would have had the rest silently survive — while + * the call still reported success. + */ +export async function clearVodSourcePinsForPlaylist( + db: AppDatabase, + playlistId: string +): Promise<{ success: boolean }> { + if (typeof playlistId !== 'string' || playlistId === '') { + return { success: false }; + } + + await db + .delete(schema.vodSourcePins) + .where(eq(schema.vodSourcePins.playlistId, playlistId)); + + return { success: true }; +} + +/** + * Make this playlist's pins exactly `pins`, in ONE transaction. + * + * Restore cannot do this as a clear followed by writes. If a write fails + * partway the user's previous pins are already gone and only a prefix of the + * archive has landed — the import reports failure, and the state it leaves + * behind belongs to neither the backup nor what was there before. One + * statement group means the playlist either ends up as the archive describes + * it or exactly as it was. + */ +export async function replaceVodSourcePinsForPlaylist( + db: AppDatabase, + playlistId: string, + pins: VodSourcePin[] +): Promise<{ success: boolean }> { + if (typeof playlistId !== 'string' || playlistId === '') { + return { success: false }; + } + + const updatedAt = new Date().toISOString(); + const rows = (pins ?? []) + .filter( + (pin) => + typeof pin?.matchKey === 'string' && + pin.matchKey !== '' && + Number.isInteger(pin?.contentId) + ) + .map((pin) => ({ + matchKey: pin.matchKey, + playlistId, + contentId: pin.contentId, + portalType: pin.portalType, + updatedAt: pin.updatedAt ?? updatedAt, + })); + + const inserts = rows.map((row) => + db + .insert(schema.vodSourcePins) + .values(row) + .onConflictDoUpdate({ + target: schema.vodSourcePins.matchKey, + set: { + playlistId: row.playlistId, + contentId: row.contentId, + portalType: row.portalType, + updatedAt: row.updatedAt, + }, + }) + ); + + await db.transaction(() => { + db.delete(schema.vodSourcePins) + .where(eq(schema.vodSourcePins.playlistId, playlistId)) + .run(); + + for (const insert of inserts) { + insert.run(); + } + }); + + return { success: true }; +} + +function toPin(row: schema.VodSourcePinRow): VodSourcePin { + return { + matchKey: row.matchKey, + playlistId: row.playlistId, + contentId: row.contentId, + portalType: row.portalType, + updatedAt: row.updatedAt ?? undefined, + }; +} diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index 59c37e2b3..54c15c51a 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -14,6 +14,7 @@ import './database/playback-position.events'; import './database/playlist.events'; import './database/recently-viewed.events'; import './database/tmdb.events'; +import './database/vod-sources.events'; import './database/xtream.events'; export default class DatabaseEvents { diff --git a/apps/electron-backend/src/app/events/database/vod-sources.events.ts b/apps/electron-backend/src/app/events/database/vod-sources.events.ts new file mode 100644 index 000000000..90b512f07 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/vod-sources.events.ts @@ -0,0 +1,48 @@ +/** + * VOD multi-source IPC event handlers + * Cross-playlist movie discovery plus the per-movie pinned source. + */ + +import type { VodSourcePin } from '@iptvnator/shared/interfaces'; +import type { FindTitleSourcesRequest } from '../../database/operations/title-sources.operations'; +import { handleWorkerRequest } from './worker-events.utils'; + +handleWorkerRequest( + 'DB_FIND_TITLE_SOURCES', + (request: FindTitleSourcesRequest) => ({ request }) +); + +handleWorkerRequest('DB_GET_VOD_SOURCE_PIN', (matchKeys: string[]) => ({ + matchKeys, +})); + +handleWorkerRequest('DB_LIST_VOD_SOURCE_PINS', (playlistId: string) => ({ + playlistId, +})); + +handleWorkerRequest( + 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', + (playlistId: string) => ({ playlistId }) +); + +handleWorkerRequest( + 'DB_SET_VOD_SOURCE_PIN', + ( + pin: VodSourcePin, + retireKeys: string[] = [], + aliasKeys: string[] = [] + ) => ({ + pin, + retireKeys, + aliasKeys, + }) +); + +handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({ + matchKeys, +})); + +handleWorkerRequest( + 'DB_REPLACE_VOD_SOURCE_PINS', + (playlistId: string, pins: VodSourcePin[] = []) => ({ playlistId, pins }) +); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index f03e24b3f..7980c2840 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -31,6 +31,18 @@ const tmdbCacheEntry = { tmdbId: 603, payload: '{"id":603}', }; +const vodSourceRequest = { + title: 'The Matrix', + year: 1999, + excludePlaylistId: playlistId, +}; +const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999']; +const vodSourcePin = { + matchKey: 'tmdb:603', + playlistId, + contentId: 42, + portalType: 'xtream', +}; export const workerIpcContractCases: WorkerIpcContractCase[] = [ { @@ -338,4 +350,43 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [['The Matrix', 'Inception']], payload: { titles: ['The Matrix', 'Inception'] }, }, + { + operation: 'DB_FIND_TITLE_SOURCES', + args: [vodSourceRequest], + payload: { request: vodSourceRequest }, + }, + { + operation: 'DB_GET_VOD_SOURCE_PIN', + args: [vodSourceMatchKeys], + payload: { matchKeys: vodSourceMatchKeys }, + }, + { + operation: 'DB_LIST_VOD_SOURCE_PINS', + args: ['playlist-1'], + payload: { playlistId: 'playlist-1' }, + }, + { + operation: 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', + args: ['playlist-1'], + payload: { playlistId: 'playlist-1' }, + }, + { + operation: 'DB_SET_VOD_SOURCE_PIN', + args: [vodSourcePin, ['title:dune:'], ['title:dune:2021']], + payload: { + pin: vodSourcePin, + retireKeys: ['title:dune:'], + aliasKeys: ['title:dune:2021'], + }, + }, + { + operation: 'DB_REPLACE_VOD_SOURCE_PINS', + args: ['playlist-1', [vodSourcePin]], + payload: { playlistId: 'playlist-1', pins: [vodSourcePin] }, + }, + { + operation: 'DB_CLEAR_VOD_SOURCE_PIN', + args: [vodSourceMatchKeys], + payload: { matchKeys: vodSourceMatchKeys }, + }, ]; diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts index 55e58730f..e42dc4e84 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts @@ -45,6 +45,7 @@ async function importDatabaseEventModules(): Promise { await import('./playlist.events'); await import('./recently-viewed.events'); await import('./tmdb.events'); + await import('./vod-sources.events'); await import('./xtream.events'); } diff --git a/apps/electron-backend/src/app/events/stream-probe.spec.ts b/apps/electron-backend/src/app/events/stream-probe.spec.ts new file mode 100644 index 000000000..c7ac98ce0 --- /dev/null +++ b/apps/electron-backend/src/app/events/stream-probe.spec.ts @@ -0,0 +1,226 @@ +const registeredHandlers = new Map unknown>(); +const axiosMock = Object.assign(jest.fn(), { + isAxiosError: jest.fn(), +}); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn( + (channel: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(channel, handler); + } + ), + }, +})); + +jest.mock('axios', () => ({ + __esModule: true, + default: axiosMock, +})); + +describe('stream probe', () => { + beforeEach(async () => { + jest.resetModules(); + registeredHandlers.clear(); + axiosMock.mockReset(); + axiosMock.isAxiosError.mockReset(); + + const { registerStreamProbeHandlers } = await import('./stream-probe'); + registerStreamProbeHandlers(); + }); + + it('registers both the generic and the legacy Xtream channel', () => { + expect(registeredHandlers.get('STREAM_PROBE_URL')).toBeDefined(); + // The catchup variant probe still calls the old channel. + expect(registeredHandlers.get('XTREAM_PROBE_URL')).toBeDefined(); + }); + + it('defaults to HEAD and reports latency', async () => { + const probeHandler = registeredHandlers.get('STREAM_PROBE_URL'); + axiosMock.mockImplementation((config: { url?: string }) => + Promise.resolve({ status: 200, headers: {}, config }) + ); + + const result = (await probeHandler?.( + {}, + { url: 'http://example.com/movie.mkv' } + )) as { status: number; latencyMs?: number }; + + expect(result.status).toBe(200); + expect(typeof result.latencyMs).toBe('number'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + + const request = axiosMock.mock.calls[0][0] as { + method?: string; + headers?: Record; + responseType?: string; + }; + expect(request.method).toBe('HEAD'); + // No Range header on HEAD — that is only the GET fallback's job. + expect(request.headers?.['Range']).toBeUndefined(); + expect(request.responseType).toBeUndefined(); + }); + + it('probes with the playlist headers the stream needs', async () => { + const probeHandler = registeredHandlers.get('STREAM_PROBE_URL'); + axiosMock.mockImplementation((config: { url?: string }) => + Promise.resolve({ status: 200, headers: {}, config }) + ); + + await probeHandler?.( + {}, + { + url: 'http://example.com/movie.mkv', + userAgent: 'MyPlayer/2.0', + referer: 'http://example.com/', + origin: 'http://example.com', + } + ); + + // A panel configured to require these answers 401/403 without them, + // and reporting a stream that plays fine as dead is the one thing + // this probe must not do. + const request = axiosMock.mock.calls[0][0] as { + headers?: Record; + }; + expect(request.headers?.['User-Agent']).toBe('MyPlayer/2.0'); + expect(request.headers?.['Referer']).toBe('http://example.com/'); + expect(request.headers?.['Origin']).toBe('http://example.com'); + }); + + it('keeps its own client User-Agent when the playlist sets none', async () => { + const probeHandler = registeredHandlers.get('STREAM_PROBE_URL'); + axiosMock.mockImplementation((config: { url?: string }) => + Promise.resolve({ status: 200, headers: {}, config }) + ); + + await probeHandler?.( + {}, + { url: 'http://example.com/movie.mkv', userAgent: ' ' } + ); + + const request = axiosMock.mock.calls[0][0] as { + headers?: Record; + }; + expect(request.headers?.['User-Agent']).toContain('VLC'); + expect(request.headers?.['Referer']).toBeUndefined(); + }); + + it('follows validated redirects for range GET media probes', async () => { + const probeHandler = registeredHandlers.get('STREAM_PROBE_URL'); + const destroyProbeBody = jest.fn(); + expect(probeHandler).toBeDefined(); + + axiosMock + .mockImplementationOnce((config: { url?: string }) => + Promise.resolve({ + status: 302, + headers: { location: '/media.ts' }, + config, + }) + ) + .mockImplementationOnce((config: { url?: string }) => + Promise.resolve({ + status: 206, + headers: {}, + data: { destroy: destroyProbeBody }, + config, + }) + ); + + const result = (await probeHandler?.( + {}, + { + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + method: 'GET', + } + )) as { status: number; url: string }; + + expect(result.status).toBe(206); + expect(result.url).toBe('http://localhost:3211/media.ts'); + expect(axiosMock).toHaveBeenCalledTimes(2); + const firstRequest = axiosMock.mock.calls[0][0] as { + headers?: Record; + maxRedirects?: number; + method?: string; + responseType?: string; + }; + expect(firstRequest.method).toBe('GET'); + expect(firstRequest.headers).toEqual( + expect.objectContaining({ Range: 'bytes=0-4095' }) + ); + expect(firstRequest.headers?.['User-Agent']).not.toBe( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + ); + expect(firstRequest.maxRedirects).toBe(0); + expect(firstRequest.responseType).toBe('stream'); + // The ranged GET opens a stream nobody reads — it must be released. + expect(destroyProbeBody).toHaveBeenCalledTimes(1); + }); + + it('rejects private cross-origin redirects for range GET media probes', async () => { + const probeHandler = registeredHandlers.get('STREAM_PROBE_URL'); + expect(probeHandler).toBeDefined(); + + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { location: 'http://127.0.0.1/admin' }, + config: { + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + }, + }); + + const result = (await probeHandler?.( + {}, + { + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + method: 'GET', + } + )) as { error?: string; status: number; url: string }; + + // A policy rejection carries no latency: it never left the process, + // and callers must be able to tell it apart from a real timing. + expect(result).toEqual({ + error: 'URL points to a private or local network address', + status: 0, + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + }); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + it('reports a refusal status rather than treating it as unreachable', async () => { + const probeHandler = registeredHandlers.get('STREAM_PROBE_URL'); + const refusal = Object.assign(new Error('forbidden'), { + response: { status: 403 }, + }); + axiosMock.mockRejectedValueOnce(refusal); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === refusal + ); + + const result = (await probeHandler?.( + {}, + { url: 'http://example.com/movie.mkv' } + )) as { status: number; latencyMs?: number }; + + // 403 answers "is anything there" — it is a fact, not a failure to check. + expect(result.status).toBe(403); + expect(typeof result.latencyMs).toBe('number'); + }); + + it('returns status 0 without latency when no response is obtained', async () => { + const probeHandler = registeredHandlers.get('STREAM_PROBE_URL'); + axiosMock.mockRejectedValueOnce(new Error('ETIMEDOUT')); + axiosMock.isAxiosError.mockReturnValue(false); + + const result = (await probeHandler?.( + {}, + { url: 'http://example.com/movie.mkv' } + )) as { status: number; url: string; latencyMs?: number }; + + expect(result).toEqual({ + status: 0, + url: 'http://example.com/movie.mkv', + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/stream-probe.ts b/apps/electron-backend/src/app/events/stream-probe.ts new file mode 100644 index 000000000..760b6f20f --- /dev/null +++ b/apps/electron-backend/src/app/events/stream-probe.ts @@ -0,0 +1,143 @@ +/** + * Stream reachability probe. + * + * A cheap "is this URL alive" check that runs in the main process because the + * renderer cannot do it: a browser request to an arbitrary IPTV host is + * CORS-blocked, and `no-cors` yields an opaque response where 200, 403 and 404 + * are indistinguishable. Electron does not escape this either — `webSecurity` + * stays on — so the honest answer has to come from Node. + * + * Originally inline in `xtream.events.ts` for catchup URL checks; extracted so + * VOD multi-source can reuse it, and so that file stays inside its line budget. + */ + +import axios, { AxiosRequestConfig } from 'axios'; +import { ipcMain } from 'electron'; +import { UnsafeUrlError } from './url-safety'; +import { requestWithValidatedRedirects } from '../util/validated-axios'; + +// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to +// challenge generic browser-looking User-Agents while allowlisting known +// IPTV player clients. A VLC-style User-Agent reliably passes those checks. +const PROBE_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18'; +const PROBE_TIMEOUT_MS = 10000; + +export interface StreamProbePayload { + url: string; + method?: 'GET' | 'HEAD'; + /** + * The headers the playlist would actually play this stream with. + * + * A panel that requires its configured User-Agent, Referer or Origin + * answers 401/403 to a request without them — and reporting that as a + * dead source would be a lie about a stream that plays fine. Probing the + * way playback requests is the only honest check. + */ + userAgent?: string | null; + referer?: string | null; + origin?: string | null; +} + +export interface StreamProbeResponse { + /** + * The HTTP status, or 0 when no response was obtained at all. + * + * 0 is NOT "offline" — it also covers a timeout and a URL rejected by the + * redirect-safety policy. Callers must render it as "could not check", + * never as a dead source. + */ + status: number; + url: string; + /** Round-trip time; present whenever the request completed either way. */ + latencyMs?: number; + error?: string; +} + +export async function runStreamProbe( + payload: StreamProbePayload +): Promise { + const method = payload.method ?? 'HEAD'; + const config: AxiosRequestConfig = { + method, + url: payload.url, + headers: { + // The playlist's own User-Agent wins when it has one: the default + // below is a guess that merely gets past most WAFs, while that + // one is what the server was configured to expect. + 'User-Agent': payload.userAgent?.trim() || PROBE_CLIENT_USER_AGENT, + ...(payload.referer?.trim() + ? { Referer: payload.referer.trim() } + : {}), + ...(payload.origin?.trim() + ? { Origin: payload.origin.trim() } + : {}), + // Some servers reject a bare HEAD but answer a tiny ranged GET. + ...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}), + }, + timeout: PROBE_TIMEOUT_MS, + responseType: method === 'GET' ? 'stream' : undefined, + validateStatus: () => true, + }; + + // Monotonic: a wall-clock change mid-probe must not yield a negative latency. + const startedAt = performance.now(); + const elapsed = () => Math.round(performance.now() - startedAt); + + try { + const response = await requestWithValidatedRedirects( + payload.url, + config, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + pinAllowedPrivateNetworkHosts: true, + } + ); + // A ranged GET opens a stream we never read — release it immediately. + const responseBody = response.data as + | { destroy?: () => void } + | undefined; + responseBody?.destroy?.(); + return { + status: response.status, + url: response.config?.url ?? payload.url, + latencyMs: elapsed(), + }; + } catch (error) { + // A refusal still answers the question "is anything there". + if (axios.isAxiosError(error) && error.response) { + return { + status: error.response.status, + url: payload.url, + latencyMs: elapsed(), + }; + } + + if (error instanceof UnsafeUrlError) { + return { + status: 0, + url: payload.url, + error: error.message, + }; + } + + return { + status: 0, + url: payload.url, + }; + } +} + +export function registerStreamProbeHandlers(): void { + ipcMain.handle( + 'STREAM_PROBE_URL', + async (_event, payload: StreamProbePayload) => runStreamProbe(payload) + ); + + // Retained for the Xtream catchup variant probe, which predates this + // module and whose renderer call site still uses the old channel. + ipcMain.handle( + 'XTREAM_PROBE_URL', + async (_event, payload: StreamProbePayload) => runStreamProbe(payload) + ); +} diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 7fb7a6a3e..9fd9830f1 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -136,85 +136,6 @@ describe('XtreamEvents session cancellation', () => { expect(userAgent).not.toMatch(/^Mozilla\/5\.0 /); }); - it('follows validated redirects for range GET media probes', async () => { - const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL'); - const destroyProbeBody = jest.fn(); - expect(probeHandler).toBeDefined(); - - axiosMock - .mockImplementationOnce((config: { url?: string }) => - Promise.resolve({ - status: 302, - headers: { location: '/media.ts' }, - config, - }) - ) - .mockImplementationOnce((config: { url?: string }) => - Promise.resolve({ - status: 206, - headers: {}, - data: { destroy: destroyProbeBody }, - config, - }) - ); - - const result = (await probeHandler?.( - {}, - { - url: 'http://localhost:3211/streaming/timeshift.php?stream=45', - method: 'GET', - } - )) as { status: number; url: string }; - - expect(result.status).toBe(206); - expect(result.url).toBe('http://localhost:3211/media.ts'); - expect(axiosMock).toHaveBeenCalledTimes(2); - const firstRequest = axiosMock.mock.calls[0][0] as { - headers?: Record; - maxRedirects?: number; - method?: string; - responseType?: string; - }; - expect(firstRequest.method).toBe('GET'); - expect(firstRequest.headers).toEqual( - expect.objectContaining({ Range: 'bytes=0-4095' }) - ); - expect(firstRequest.headers?.['User-Agent']).not.toBe( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - ); - expect(firstRequest.maxRedirects).toBe(0); - expect(firstRequest.responseType).toBe('stream'); - expect(destroyProbeBody).toHaveBeenCalledTimes(1); - }); - - it('rejects private cross-origin redirects for range GET media probes', async () => { - const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL'); - expect(probeHandler).toBeDefined(); - - axiosMock.mockResolvedValueOnce({ - status: 302, - headers: { location: 'http://127.0.0.1/admin' }, - config: { - url: 'http://localhost:3211/streaming/timeshift.php?stream=45', - }, - }); - - const result = (await probeHandler?.( - {}, - { - url: 'http://localhost:3211/streaming/timeshift.php?stream=45', - method: 'GET', - } - )) as { error?: string; status: number; url: string }; - - expect(result).toEqual({ - error: 'URL points to a private or local network address', - status: 0, - url: 'http://localhost:3211/streaming/timeshift.php?stream=45', - }); - expect(axiosMock).toHaveBeenCalledTimes(1); - }); - it('aborts requests that were registered with only a session id', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 87ae81574..c78ba43f8 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -13,7 +13,6 @@ import { } from '@iptvnator/shared/interfaces'; import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; -import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { createXtreamMainPerformanceCaptureForRequest, @@ -323,65 +322,3 @@ type ActiveXtreamRequest = { const activeXtreamRequests = new Map(); -ipcMain.handle( - 'XTREAM_PROBE_URL', - async ( - _event, - payload: { - url: string; - method?: 'GET' | 'HEAD'; - } - ) => { - const method = payload.method ?? 'HEAD'; - const config: AxiosRequestConfig = { - method, - url: payload.url, - headers: { - 'User-Agent': XTREAM_CLIENT_USER_AGENT, - ...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}), - }, - timeout: 10000, - responseType: method === 'GET' ? 'stream' : undefined, - validateStatus: () => true, - }; - - try { - const response = await requestWithValidatedRedirects( - payload.url, - config, - { - allowPrivateNetworkRedirects: false, - allowPrivateNetworks: true, - pinAllowedPrivateNetworkHosts: true, - } - ); - const responseBody = response.data as - { destroy?: () => void } | undefined; - responseBody?.destroy?.(); - return { - status: response.status, - url: response.config?.url ?? payload.url, - }; - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - return { - status: error.response.status, - url: payload.url, - }; - } - - if (error instanceof UnsafeUrlError) { - return { - status: 0, - url: payload.url, - error: error.message, - }; - } - - return { - status: 0, - url: payload.url, - }; - } - } -); diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 40f9d787a..7b4c07c60 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -56,6 +56,13 @@ export const DB_WORKER_OPERATIONS = [ 'DB_GET_TMDB_CACHE_STATS', 'DB_CLEAR_TMDB_METADATA', 'DB_MATCH_TITLES', + 'DB_FIND_TITLE_SOURCES', + 'DB_GET_VOD_SOURCE_PIN', + 'DB_LIST_VOD_SOURCE_PINS', + 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', + 'DB_SET_VOD_SOURCE_PIN', + 'DB_CLEAR_VOD_SOURCE_PIN', + 'DB_REPLACE_VOD_SOURCE_PINS', ] as const; export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number]; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 003dcb037..00f5f185f 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -4,6 +4,7 @@ import { } from './database.worker-connection'; import { parentPort } from 'worker_threads'; import type { + VodSourcePin, XtreamBackupFavoriteItem, XtreamBackupRecentlyViewedItem, } from '@iptvnator/shared/interfaces'; @@ -80,12 +81,24 @@ import { removeRecentItemsBatch, } from '../database/operations/recently-viewed.operations'; import { matchTitles } from '../database/operations/title-match.operations'; +import { + findTitleSources, + type FindTitleSourcesRequest, +} from '../database/operations/title-sources.operations'; import { clearTmdbMetadata, getTmdbCacheStats, getTmdbMetadata, setTmdbMetadata, } from '../database/operations/tmdb.operations'; +import { + clearVodSourcePin, + getVodSourcePin, + clearVodSourcePinsForPlaylist, + replaceVodSourcePinsForPlaylist, + listVodSourcePinsForPlaylist, + setVodSourcePin, +} from '../database/operations/vod-source-pin.operations'; import { deleteXtreamContent, restoreXtreamUserData, @@ -782,6 +795,59 @@ async function executeRequest( return matchTitles(db, payload.titles); } + case 'DB_FIND_TITLE_SOURCES': { + const payload = message.payload as { + request: FindTitleSourcesRequest; + }; + return findTitleSources(db, payload.request); + } + + case 'DB_GET_VOD_SOURCE_PIN': { + const payload = message.payload as { matchKeys: string[] }; + return getVodSourcePin(db, payload.matchKeys); + } + + case 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST': { + const payload = message.payload as { playlistId: string }; + return clearVodSourcePinsForPlaylist(db, payload.playlistId); + } + + case 'DB_LIST_VOD_SOURCE_PINS': { + const payload = message.payload as { playlistId: string }; + return listVodSourcePinsForPlaylist(db, payload.playlistId); + } + + case 'DB_SET_VOD_SOURCE_PIN': { + const payload = message.payload as { + pin: VodSourcePin; + retireKeys?: string[]; + aliasKeys?: string[]; + }; + return setVodSourcePin( + db, + payload.pin, + payload.retireKeys ?? [], + payload.aliasKeys ?? [] + ); + } + + case 'DB_REPLACE_VOD_SOURCE_PINS': { + const payload = message.payload as { + playlistId: string; + pins: VodSourcePin[]; + }; + return replaceVodSourcePinsForPlaylist( + db, + payload.playlistId, + payload.pins ?? [] + ); + } + + case 'DB_CLEAR_VOD_SOURCE_PIN': { + const payload = message.payload as { matchKeys: string[] }; + return clearVodSourcePin(db, payload.matchKeys); + } + case 'DB_DELETE_ALL_PLAYLISTS': { const payload = message.payload as { operationId?: string }; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index dde0be3e6..1dfc50e9e 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -30,6 +30,7 @@ import { registerStaticHeaderShims } from './app/services/request-header-overrid import { AppUpdateService } from './app/services/app-update.service'; import { databaseWorkerClient } from './app/services/database-worker-client'; import WindowEvents from './app/events/window.events'; +import { registerStreamProbeHandlers } from './app/events/stream-probe'; import XtreamEvents from './app/events/xtream.events'; import { environment } from './environments/environment'; import { @@ -152,6 +153,7 @@ export default class Main { SettingsEvents.bootstrapSettingsEvents(); StalkerEvents.bootstrapStalkerEvents(); XtreamEvents.bootstrapXtreamEvents(); + registerStreamProbeHandlers(); DatabaseEvents.bootstrapDatabaseEvents(); EpgEvents.bootstrapEpgEvents(); RemoteControlEvents.bootstrapRemoteControlEvents(); diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 7cde29550..5f2c99915 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -34,6 +34,7 @@ export function createSettingsForm( webPlayerSharedControls: false, playerAmbientMode: false, playerUpNextRail: true, + vodAutoFailover: false, ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), @@ -121,6 +122,7 @@ export function createSettingsFromFormValue( webPlayerSharedControls: value.webPlayerSharedControls ?? false, playerAmbientMode: value.playerAmbientMode ?? false, playerUpNextRail: value.playerUpNextRail ?? true, + vodAutoFailover: value.vodAutoFailover ?? false, streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false, language: value.language ?? Language.ENGLISH, diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts new file mode 100644 index 000000000..82cbdc36e --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { StreamFormat, VideoPlayer } from '@iptvnator/shared/interfaces'; +import { TranslateModule } from '@ngx-translate/core'; +import { SettingsPlaybackSectionComponent } from './settings-playback-section.component'; + +/** + * Who gets offered VOD auto-failover. + * + * Only the built-in web players raise the playback diagnostic that triggers a + * source switch: Embedded MPV has its diagnostics suppressed and MPV/VLC play + * outside the app entirely. Offering the control there would let a user enable + * a feature that can never fire. + */ +describe('SettingsPlaybackSectionComponent — VOD auto-failover', () => { + let fixture: ComponentFixture; + + const settingRow = (): Element | null => + fixture.nativeElement.querySelector( + '[data-test-id="vod-auto-failover-setting"]' + ); + + function render(player: VideoPlayer, supported = true): void { + fixture.componentRef.setInput('form', createForm(player)); + fixture.componentRef.setInput('supportsVodMultiSource', supported); + fixture.detectChanges(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + SettingsPlaybackSectionComponent, + NoopAnimationsModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsPlaybackSectionComponent); + fixture.componentRef.setInput('activeSection', 'playback'); + fixture.componentRef.setInput('players', []); + fixture.componentRef.setInput('streamFormatEnum', StreamFormat); + }); + + it.each([ + VideoPlayer.Html5Player, + VideoPlayer.VideoJs, + VideoPlayer.ArtPlayer, + ])('offers the toggle on %s', (player) => { + render(player); + + expect(settingRow()).not.toBeNull(); + }); + + it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])( + 'hides the toggle on %s, which never reports a playback failure', + (player) => { + render(player); + + expect(settingRow()).toBeNull(); + } + ); + + it('stays hidden in the PWA, where there is nothing to fail over to', () => { + render(VideoPlayer.Html5Player, false); + + expect(settingRow()).toBeNull(); + }); +}); + +function createForm(player: VideoPlayer): FormGroup { + return new FormGroup({ + player: new FormControl(player), + webPlayerSharedControls: new FormControl(false), + playerAmbientMode: new FormControl(false), + playerUpNextRail: new FormControl(true), + streamFormat: new FormControl(StreamFormat.AutoStreamFormat), + openStreamOnDoubleClick: new FormControl(false), + showExternalPlaybackBar: new FormControl(true), + embeddedMpvFrameCopy: new FormControl(false), + mpvPlayerPath: new FormControl(''), + mpvPlayerArguments: new FormControl(''), + mpvReuseInstance: new FormControl(false), + vlcPlayerPath: new FormControl(''), + vlcPlayerArguments: new FormControl(''), + vlcReuseInstance: new FormControl(false), + recordingFolder: new FormControl(''), + vodAutoFailover: new FormControl(false), + }); +} diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 72f64e43f..453179207 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -133,6 +133,28 @@ } + + @if (supportsVodMultiSource() && isWebPlayerSelected()) { +
+
+

+ {{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }} +

+

+ {{ 'SETTINGS.VOD_AUTO_FAILOVER_DESCRIPTION' | translate }} +

+
+
+ +
+
+ } + @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
(); isWebPlayerSelected(): boolean { - const player = this.form().value.player; - return ( - player === VideoPlayer.VideoJs || - player === VideoPlayer.Html5Player || - player === VideoPlayer.ArtPlayer - ); + return reportsPlaybackFailures(this.form().value.player); } isExternalPlayerSelected(): boolean { diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 0bdbf2bd0..6170972ad 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -50,6 +50,7 @@ [isDesktop]="isDesktop" [supportsManagedExternalPlayers]="supportsManagedExternalPlayers" [supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings" + [supportsVodMultiSource]="supportsVodMultiSource" [frameCopyAvailable]="embeddedMpv.frameCopyAvailable()" [frameCopyActive]="embeddedMpv.frameCopyActive()" (selectRecordingFolder)="selectRecordingFolder()" diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index f93a24fac..c6be7539e 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -20,6 +20,7 @@ import { Router } from '@angular/router'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; import { Language, StreamFormat } from '@iptvnator/shared/interfaces'; import { BUILD_COMMIT } from '../../environments/build-commit'; import { SettingsAboutSectionComponent } from './settings-about-section.component'; @@ -104,6 +105,7 @@ export class SettingsComponent implements OnInit, OnDestroy { private readonly settingsCtx = inject(SettingsContextService); private readonly settingsSnackbar = inject(SettingsSnackbarService); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService); private readonly matDialog = inject(MatDialog); private readonly router = inject(Router); private readonly translate = inject(TranslateService); @@ -129,6 +131,7 @@ export class SettingsComponent implements OnInit, OnDestroy { this.runtime.supportsManagedExternalPlayers; readonly supportsExternalPlayerPathSettings = this.runtime.supportsExternalPlayerPathSettings; + readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable; readonly supportsRemoteControl = this.runtime.supportsRemoteControl; readonly activeSection = this.settingsCtx.activeSection; diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index abac1e9bd..5249bd628 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -81,6 +81,7 @@ export const DEFAULT_SETTINGS = { webPlayerSharedControls: false, playerAmbientMode: false, playerUpNextRail: true, + vodAutoFailover: false, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 7d0c0ee5f..ff289e07b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق.", + "VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل", + "VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة." }, "THEMES": { "DARK_THEME": "داكن", @@ -1001,7 +1003,45 @@ "ITEM": "عنصر", "ITEMS": "عناصر", "NO_EPISODES": "لا توجد حلقات متاحة", - "ALL_RADIO": "كل الراديو" + "ALL_RADIO": "كل الراديو", + "MULTI_SOURCE": { + "SOURCES": "المصادر", + "SOURCE": "المصدر", + "SOURCES_FOR": "مصادر “{{title}}”", + "OPEN_SOURCES": "عرض المصادر البديلة", + "MATCHED_BY_TMDB": "تمت المطابقة عبر TMDB", + "MATCHED_BY_TITLE": "تمت المطابقة حسب العنوان", + "MATCH_BY_TITLE": "حسب العنوان", + "PLAYING": "قيد التشغيل", + "CURRENT": "الحالي", + "PLAY_FROM_SOURCE": "التشغيل من هذا المصدر", + "PIN_SOURCE": "اجعله المصدر الرئيسي", + "UNPIN_SOURCE": "إلغاء تثبيت هذا المصدر", + "CHECK": "فحص", + "CHECK_TOOLTIP": "التحقق من التوفر", + "PROBE_OK": "متاح", + "PROBE_FAIL": "غير متاح", + "PROBE_CHECKING": "جارٍ التحقق", + "AUTO_FAILOVER": "التبديل التلقائي عند الخطأ", + "PLAYING_FROM": "التشغيل من:", + "SWITCH_TITLE": "المصدر غير متاح — جارٍ التبديل إلى {{playlist}}", + "SWITCH_DETAIL": "الاستئناف عند {{timecode}}", + "SWITCH_AUDIO_WARNING": "قد تختلف الدبلجة", + "SWITCHED_TO": "تم التبديل إلى {{playlist}}", + "UNDO": "تراجع", + "CHOOSE_OTHER": "اختر مصدرًا آخر", + "TRY_ANOTHER_SOURCE": "جرّب مصدرًا آخر", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} لا توفّر هذا البث. هذا الفيلم موجود في قوائم التشغيل الأخرى لديك:", + "RESUME_KEPT": "سيتم الاحتفاظ بموضع التشغيل · {{timecode}}", + "ALL_SOURCES_TRIED": "تمت تجربة جميع المصادر.", + "ALSO_FOUND_IN_ONE": "— موجود أيضًا في قائمة تشغيل أخرى", + "ALSO_FOUND_IN_OTHER": "— موجود أيضًا في {{count}} من قوائم التشغيل الأخرى", + "CHECK_ALL": "فحص الكل", + "FILTER_PLACEHOLDER": "التصفية حسب قائمة التشغيل", + "NO_MATCHES": "لا توجد قوائم تشغيل تطابق هذا الفلتر", + "COPIES": "{{count}} نسخ في قائمة التشغيل هذه", + "MORE_SOURCES": "+{{count}} أخرى في قائمة المصادر" + } }, "INFORMATION": "معلومات", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 6904a9681..977afedec 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق.", + "VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل", + "VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف." }, "THEMES": { "DARK_THEME": "داكن", @@ -1001,7 +1003,45 @@ "ITEM": "عنصر", "ITEMS": "عناصر", "NO_EPISODES": "ما كاينش حلقات متوفرة", - "ALL_RADIO": "All radio" + "ALL_RADIO": "All radio", + "MULTI_SOURCE": { + "SOURCES": "مصادر", + "SOURCE": "مصدر", + "SOURCES_FOR": "مصادر ديال “{{title}}”", + "OPEN_SOURCES": "وري المصادر البديلة", + "MATCHED_BY_TMDB": "تطابق عن طريق TMDB", + "MATCHED_BY_TITLE": "تطابق عن طريق العنوان", + "MATCH_BY_TITLE": "بالعنوان", + "PLAYING": "كيتشغل", + "CURRENT": "الحالي", + "PLAY_FROM_SOURCE": "شغل من هاد المصدر", + "PIN_SOURCE": "دير هاد المصدر هو الرئيسي", + "UNPIN_SOURCE": "حيد هاد المصدر من الرئيسي", + "CHECK": "تحقق", + "CHECK_TOOLTIP": "تحقق من التوفر", + "PROBE_OK": "متوفر", + "PROBE_FAIL": "ما متوفرش", + "PROBE_CHECKING": "جاري التحقق", + "AUTO_FAILOVER": "بدل أوتوماتيكياً عند الخطأ", + "PLAYING_FROM": "كيتشغل من:", + "SWITCH_TITLE": "المصدر ما متوفرش — كنبدلو ل {{playlist}}", + "SWITCH_DETAIL": "غادي نكملو من {{timecode}}", + "SWITCH_AUDIO_WARNING": "يمكن الدوبلاج يكون مختلف", + "SWITCHED_TO": "تبدل ل {{playlist}}", + "UNDO": "تراجع", + "CHOOSE_OTHER": "اختار واحد آخر", + "TRY_ANOTHER_SOURCE": "جرب مصدر آخر", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} ما كيوفرش هاد البث. هاد الفيلم كاين ف قوائم التشغيل الأخرى ديالك:", + "RESUME_KEPT": "التوقيت غادي يتحفظ · {{timecode}}", + "ALL_SOURCES_TRIED": "جميع المصادر تجربو.", + "ALSO_FOUND_IN_ONE": "— كاين حتى ف قائمة تشغيل أخرى", + "ALSO_FOUND_IN_OTHER": "— كاين حتى ف {{count}} ديال قوائم التشغيل الأخرى", + "CHECK_ALL": "تحقق من الكل", + "FILTER_PLACEHOLDER": "فلتر بقائمة التشغيل", + "NO_MATCHES": "ما كاينش قوائم تشغيل مطابقة لهاد الفلتر", + "COPIES": "{{count}} ديال النسخ ف هاد قائمة التشغيل", + "MORE_SOURCES": "+{{count}} أخرى ف لائحة المصادر" + } }, "INFORMATION": "معلومات", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ab9eeba35..67a7f21fc 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы.", + "VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка." }, "THEMES": { "DARK_THEME": "Цёмная", @@ -1001,7 +1003,45 @@ "ITEM": "элемент", "ITEMS": "элементаў", "NO_EPISODES": "Няма даступных эпізодаў", - "ALL_RADIO": "Усе радыё" + "ALL_RADIO": "Усе радыё", + "MULTI_SOURCE": { + "SOURCES": "Крыніцы", + "SOURCE": "Крыніца", + "SOURCES_FOR": "Крыніцы для «{{title}}»", + "OPEN_SOURCES": "Паказаць альтэрнатыўныя крыніцы", + "MATCHED_BY_TMDB": "супастаўлена па TMDB", + "MATCHED_BY_TITLE": "супастаўлена па назве", + "MATCH_BY_TITLE": "па назве", + "PLAYING": "Прайграецца", + "CURRENT": "Бягучая", + "PLAY_FROM_SOURCE": "Прайграць з гэтай крыніцы", + "PIN_SOURCE": "Зрабіць асноўнай крыніцай", + "UNPIN_SOURCE": "Адмацаваць гэтую крыніцу", + "CHECK": "праверыць", + "CHECK_TOOLTIP": "Праверыць даступнасць", + "PROBE_OK": "даступна", + "PROBE_FAIL": "недаступна", + "PROBE_CHECKING": "праверка", + "AUTO_FAILOVER": "Аўтапераключэнне пры памылцы", + "PLAYING_FROM": "Прайграецца з:", + "SWITCH_TITLE": "Крыніца недаступная — пераключаемся на {{playlist}}", + "SWITCH_DETAIL": "працягваем з {{timecode}}", + "SWITCH_AUDIO_WARNING": "агучка можа адрознівацца", + "SWITCHED_TO": "Пераключана на {{playlist}}", + "UNDO": "Вярнуць", + "CHOOSE_OTHER": "Выбраць іншую", + "TRY_ANOTHER_SOURCE": "Паспрабаваць іншую крыніцу", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} не аддае гэты паток. Гэты фільм ёсць у іншых вашых плэйлістах:", + "RESUME_KEPT": "пазіцыя захаваецца · {{timecode}}", + "ALL_SOURCES_TRIED": "Усе крыніцы ўжо выпрабаваны.", + "ALSO_FOUND_IN_ONE": "— таксама ёсць яшчэ ў адным плэйлісце", + "ALSO_FOUND_IN_OTHER": "— таксама ёсць яшчэ ў {{count}} плэйлістах", + "CHECK_ALL": "праверыць усе", + "FILTER_PLACEHOLDER": "Фільтраваць па плэйлісце", + "NO_MATCHES": "Ніводны плэйліст не адпавядае гэтаму фільтру", + "COPIES": "{{count}} копій у гэтым плэйлісце", + "MORE_SOURCES": "+{{count}} у спісе крыніц" + } }, "INFORMATION": "Інфармацыя", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 24b3b7b0c..abe7f8414 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam.", + "VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann." }, "THEMES": { "DARK_THEME": "Dunkel", @@ -1001,7 +1003,45 @@ "ITEM": "Element", "ITEMS": "Elemente", "NO_EPISODES": "Keine Episoden verfügbar", - "ALL_RADIO": "Alle Radiosender" + "ALL_RADIO": "Alle Radiosender", + "MULTI_SOURCE": { + "SOURCES": "Quellen", + "SOURCE": "Quelle", + "SOURCES_FOR": "Quellen für „{{title}}“", + "OPEN_SOURCES": "Alternative Quellen anzeigen", + "MATCHED_BY_TMDB": "über TMDB zugeordnet", + "MATCHED_BY_TITLE": "über den Titel zugeordnet", + "MATCH_BY_TITLE": "per Titel", + "PLAYING": "Läuft", + "CURRENT": "Aktuell", + "PLAY_FROM_SOURCE": "Von dieser Quelle abspielen", + "PIN_SOURCE": "Als Hauptquelle festlegen", + "UNPIN_SOURCE": "Hauptquelle aufheben", + "CHECK": "prüfen", + "CHECK_TOOLTIP": "Verfügbarkeit prüfen", + "PROBE_OK": "verfügbar", + "PROBE_FAIL": "nicht verfügbar", + "PROBE_CHECKING": "wird geprüft", + "AUTO_FAILOVER": "Bei Fehler automatisch wechseln", + "PLAYING_FROM": "Wiedergabe von:", + "SWITCH_TITLE": "Quelle nicht verfügbar — Wechsel zu {{playlist}}", + "SWITCH_DETAIL": "wird bei {{timecode}} fortgesetzt", + "SWITCH_AUDIO_WARNING": "die Synchronfassung kann abweichen", + "SWITCHED_TO": "Zu {{playlist}} gewechselt", + "UNDO": "Rückgängig", + "CHOOSE_OTHER": "Andere wählen", + "TRY_ANOTHER_SOURCE": "Andere Quelle versuchen", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} liefert diesen Stream nicht. Dieser Film ist in Ihren anderen Playlists vorhanden:", + "RESUME_KEPT": "Wiedergabeposition wird beibehalten · {{timecode}}", + "ALL_SOURCES_TRIED": "Alle Quellen wurden bereits versucht.", + "ALSO_FOUND_IN_ONE": "— auch in einer weiteren Playlist gefunden", + "ALSO_FOUND_IN_OTHER": "— auch in {{count}} weiteren Playlists gefunden", + "CHECK_ALL": "alle prüfen", + "FILTER_PLACEHOLDER": "Nach Playlist filtern", + "NO_MATCHES": "Keine Playlists entsprechen dem Filter", + "COPIES": "{{count}} Kopien in dieser Playlist", + "MORE_SOURCES": "+{{count}} weitere unter „Quellen“" + } }, "INFORMATION": "Information", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 22dad3dc8..a7622308d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής.", + "VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση." }, "THEMES": { "DARK_THEME": "Σκούρο", @@ -1001,7 +1003,45 @@ "ITEM": "στοιχείο", "ITEMS": "στοιχεία", "NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια", - "ALL_RADIO": "Όλα τα ραδιόφωνα" + "ALL_RADIO": "Όλα τα ραδιόφωνα", + "MULTI_SOURCE": { + "SOURCES": "Πηγές", + "SOURCE": "Πηγή", + "SOURCES_FOR": "Πηγές για «{{title}}»", + "OPEN_SOURCES": "Εμφάνιση εναλλακτικών πηγών", + "MATCHED_BY_TMDB": "αντιστοιχίστηκε μέσω TMDB", + "MATCHED_BY_TITLE": "αντιστοιχίστηκε βάσει τίτλου", + "MATCH_BY_TITLE": "βάσει τίτλου", + "PLAYING": "Παίζει", + "CURRENT": "Τρέχουσα", + "PLAY_FROM_SOURCE": "Αναπαραγωγή από αυτήν την πηγή", + "PIN_SOURCE": "Ορισμός ως κύρια πηγή", + "UNPIN_SOURCE": "Κατάργηση κύριας πηγής", + "CHECK": "έλεγχος", + "CHECK_TOOLTIP": "Έλεγχος διαθεσιμότητας", + "PROBE_OK": "διαθέσιμη", + "PROBE_FAIL": "μη διαθέσιμη", + "PROBE_CHECKING": "ελέγχεται", + "AUTO_FAILOVER": "Αυτόματη αλλαγή σε σφάλμα", + "PLAYING_FROM": "Αναπαραγωγή από:", + "SWITCH_TITLE": "Η πηγή δεν είναι διαθέσιμη — μετάβαση σε {{playlist}}", + "SWITCH_DETAIL": "συνέχεια από {{timecode}}", + "SWITCH_AUDIO_WARNING": "η μεταγλώττιση μπορεί να διαφέρει", + "SWITCHED_TO": "Έγινε μετάβαση σε {{playlist}}", + "UNDO": "Αναίρεση", + "CHOOSE_OTHER": "Άλλη πηγή", + "TRY_ANOTHER_SOURCE": "Δοκιμή άλλης πηγής", + "ERROR_HAS_ALTERNATIVES": "Η λίστα {{playlist}} δεν παρέχει αυτήν τη ροή. Αυτή η ταινία υπάρχει στις άλλες λίστες αναπαραγωγής σας:", + "RESUME_KEPT": "η θέση αναπαραγωγής θα διατηρηθεί · {{timecode}}", + "ALL_SOURCES_TRIED": "Δοκιμάστηκαν όλες οι πηγές.", + "ALSO_FOUND_IN_ONE": "— βρέθηκε επίσης σε μία άλλη λίστα αναπαραγωγής", + "ALSO_FOUND_IN_OTHER": "— βρέθηκε επίσης σε {{count}} άλλες λίστες αναπαραγωγής", + "CHECK_ALL": "έλεγχος όλων", + "FILTER_PLACEHOLDER": "Φιλτράρισμα κατά λίστα αναπαραγωγής", + "NO_MATCHES": "Δεν βρέθηκαν αντίστοιχες λίστες αναπαραγωγής", + "COPIES": "{{count}} αντίγραφα σε αυτήν τη λίστα", + "MORE_SOURCES": "+{{count}} ακόμη στη λίστα πηγών" + } }, "INFORMATION": "Πληροφορίες", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 15d96ff2b..42fc881fd 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app.", + "VOD_AUTO_FAILOVER": "Auto-switch source on playback error", + "VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub." }, "THEMES": { "DARK_THEME": "Dark", @@ -1001,7 +1003,45 @@ "ITEM": "item", "ITEMS": "items", "NO_EPISODES": "No episodes available", - "ALL_RADIO": "All radio" + "ALL_RADIO": "All radio", + "MULTI_SOURCE": { + "SOURCES": "Sources", + "SOURCE": "Source", + "SOURCES_FOR": "Sources for “{{title}}”", + "OPEN_SOURCES": "Show alternative sources", + "MATCHED_BY_TMDB": "matched by TMDB", + "MATCHED_BY_TITLE": "matched by title", + "MATCH_BY_TITLE": "by title", + "PLAYING": "Playing", + "CURRENT": "Current", + "PLAY_FROM_SOURCE": "Play from this source", + "PIN_SOURCE": "Make this the main source", + "UNPIN_SOURCE": "Unpin this source", + "CHECK": "check", + "CHECK_TOOLTIP": "Check availability", + "PROBE_OK": "available", + "PROBE_FAIL": "unavailable", + "PROBE_CHECKING": "checking", + "AUTO_FAILOVER": "Auto-switch on error", + "PLAYING_FROM": "Playing from:", + "SWITCH_TITLE": "Source unavailable — switching to {{playlist}}", + "SWITCH_DETAIL": "resuming at {{timecode}}", + "SWITCH_AUDIO_WARNING": "the dub may differ", + "SWITCHED_TO": "Switched to {{playlist}}", + "UNDO": "Undo", + "CHOOSE_OTHER": "Choose another", + "TRY_ANOTHER_SOURCE": "Try another source", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} is not serving this stream. This movie is in your other playlists:", + "RESUME_KEPT": "timecode will be kept · {{timecode}}", + "ALL_SOURCES_TRIED": "Every source has been tried.", + "ALSO_FOUND_IN_ONE": "— also found in 1 other playlist", + "ALSO_FOUND_IN_OTHER": "— also found in {{count}} other playlists", + "CHECK_ALL": "check all", + "FILTER_PLACEHOLDER": "Filter by playlist", + "NO_MATCHES": "No playlists match that filter", + "COPIES": "{{count}} copies in this playlist", + "MORE_SOURCES": "+{{count}} more in the sources list" + } }, "INFORMATION": "Information", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 373fb2a1c..88e13f1b5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación.", + "VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto." }, "THEMES": { "DARK_THEME": "Oscuro", @@ -1001,7 +1003,45 @@ "ITEM": "elemento", "ITEMS": "elementos", "NO_EPISODES": "No hay episodios disponibles", - "ALL_RADIO": "Todas las radios" + "ALL_RADIO": "Todas las radios", + "MULTI_SOURCE": { + "SOURCES": "Fuentes", + "SOURCE": "Fuente", + "SOURCES_FOR": "Fuentes para “{{title}}”", + "OPEN_SOURCES": "Mostrar fuentes alternativas", + "MATCHED_BY_TMDB": "coincidencia por TMDB", + "MATCHED_BY_TITLE": "coincidencia por título", + "MATCH_BY_TITLE": "por título", + "PLAYING": "Reproduciendo", + "CURRENT": "Actual", + "PLAY_FROM_SOURCE": "Reproducir desde esta fuente", + "PIN_SOURCE": "Fijar como fuente principal", + "UNPIN_SOURCE": "Dejar de fijar esta fuente", + "CHECK": "comprobar", + "CHECK_TOOLTIP": "Comprobar disponibilidad", + "PROBE_OK": "disponible", + "PROBE_FAIL": "no disponible", + "PROBE_CHECKING": "comprobando", + "AUTO_FAILOVER": "Cambio automático al fallar", + "PLAYING_FROM": "Reproduciendo desde:", + "SWITCH_TITLE": "Fuente no disponible — cambiando a {{playlist}}", + "SWITCH_DETAIL": "se reanuda en {{timecode}}", + "SWITCH_AUDIO_WARNING": "el doblaje puede ser distinto", + "SWITCHED_TO": "Se cambió a {{playlist}}", + "UNDO": "Deshacer", + "CHOOSE_OTHER": "Elegir otra", + "TRY_ANOTHER_SOURCE": "Probar otra fuente", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} no está ofreciendo esta transmisión. Esta película está en tus otras listas:", + "RESUME_KEPT": "se conservará la posición · {{timecode}}", + "ALL_SOURCES_TRIED": "Se han probado todas las fuentes.", + "ALSO_FOUND_IN_ONE": "— también está en otra lista", + "ALSO_FOUND_IN_OTHER": "— también está en otras {{count}} listas", + "CHECK_ALL": "comprobar todas", + "FILTER_PLACEHOLDER": "Filtrar por lista", + "NO_MATCHES": "Ninguna lista coincide con ese filtro", + "COPIES": "{{count}} copias en esta lista", + "MORE_SOURCES": "+{{count}} más en la lista de fuentes" + } }, "INFORMATION": "Información", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 5fca1b97c..0579ed2c4 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application.", + "VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent." }, "THEMES": { "DARK_THEME": "Sombre", @@ -1001,7 +1003,45 @@ "ITEM": "élément", "ITEMS": "éléments", "NO_EPISODES": "Aucun épisode disponible", - "ALL_RADIO": "Toutes les radios" + "ALL_RADIO": "Toutes les radios", + "MULTI_SOURCE": { + "SOURCES": "Sources", + "SOURCE": "Source", + "SOURCES_FOR": "Sources pour « {{title}} »", + "OPEN_SOURCES": "Afficher les autres sources", + "MATCHED_BY_TMDB": "correspondance trouvée via TMDB", + "MATCHED_BY_TITLE": "correspondance trouvée d'après le titre", + "MATCH_BY_TITLE": "par titre", + "PLAYING": "En lecture", + "CURRENT": "Actuelle", + "PLAY_FROM_SOURCE": "Lire depuis cette source", + "PIN_SOURCE": "Définir comme source principale", + "UNPIN_SOURCE": "Ne plus utiliser comme source principale", + "CHECK": "vérifier", + "CHECK_TOOLTIP": "Vérifier la disponibilité", + "PROBE_OK": "disponible", + "PROBE_FAIL": "indisponible", + "PROBE_CHECKING": "vérification", + "AUTO_FAILOVER": "Bascule automatique en cas d'erreur", + "PLAYING_FROM": "Lecture depuis :", + "SWITCH_TITLE": "Source indisponible — bascule vers {{playlist}}", + "SWITCH_DETAIL": "reprise à {{timecode}}", + "SWITCH_AUDIO_WARNING": "le doublage peut être différent", + "SWITCHED_TO": "Basculé vers {{playlist}}", + "UNDO": "Annuler", + "CHOOSE_OTHER": "Choisir une autre source", + "TRY_ANOTHER_SOURCE": "Essayer une autre source", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} ne diffuse pas ce flux. Ce film est présent dans vos autres listes de lecture :", + "RESUME_KEPT": "la position de lecture sera conservée · {{timecode}}", + "ALL_SOURCES_TRIED": "Toutes les sources ont été essayées.", + "ALSO_FOUND_IN_ONE": "— également disponible dans une autre liste de lecture", + "ALSO_FOUND_IN_OTHER": "— également disponible dans {{count}} autres listes de lecture", + "CHECK_ALL": "tout vérifier", + "FILTER_PLACEHOLDER": "Filtrer par liste de lecture", + "NO_MATCHES": "Aucune liste de lecture ne correspond à ce filtre", + "COPIES": "{{count}} copies dans cette liste de lecture", + "MORE_SOURCES": "+{{count}} dans la liste des sources" + } }, "INFORMATION": "Information", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index d512f6ecb..ea101b39c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "Újdonságok", "EMBEDDED_MPV_FRAME_COPY": "Beágyazott MPV: képkocka-másolásos megjelenítés (kísérleti)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "A videó megjelenítése az alkalmazás rajzfelületén, hogy a menük és párbeszédpanelek fölötte jelenhessenek meg.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe.", + "VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet." }, "THEMES": { "DARK_THEME": "Sötét", @@ -1001,7 +1003,45 @@ "ITEM": "item", "ITEMS": "items", "NO_EPISODES": "Nincs elérhető epizód", - "ALL_RADIO": "Összes rádió" + "ALL_RADIO": "Összes rádió", + "MULTI_SOURCE": { + "SOURCES": "Források", + "SOURCE": "Forrás", + "SOURCES_FOR": "„{{title}}” forrásai", + "OPEN_SOURCES": "Alternatív források megjelenítése", + "MATCHED_BY_TMDB": "TMDB alapján párosítva", + "MATCHED_BY_TITLE": "Cím alapján párosítva", + "MATCH_BY_TITLE": "cím alapján", + "PLAYING": "Lejátszás alatt", + "CURRENT": "Aktuális", + "PLAY_FROM_SOURCE": "Lejátszás erről a forrásról", + "PIN_SOURCE": "Beállítás fő forrásként", + "UNPIN_SOURCE": "Fő forrás beállításának visszavonása", + "CHECK": "ellenőrzés", + "CHECK_TOOLTIP": "Elérhetőség ellenőrzése", + "PROBE_OK": "elérhető", + "PROBE_FAIL": "nem elérhető", + "PROBE_CHECKING": "ellenőrzés…", + "AUTO_FAILOVER": "Automatikus váltás hiba esetén", + "PLAYING_FROM": "Lejátszás innen:", + "SWITCH_TITLE": "A forrás nem érhető el — váltás erre: {{playlist}}", + "SWITCH_DETAIL": "folytatás innen: {{timecode}}", + "SWITCH_AUDIO_WARNING": "a szinkron eltérhet", + "SWITCHED_TO": "Átváltva erre: {{playlist}}", + "UNDO": "Visszavonás", + "CHOOSE_OTHER": "Másik választása", + "TRY_ANOTHER_SOURCE": "Másik forrás kipróbálása", + "ERROR_HAS_ALTERNATIVES": "Ez az adatfolyam nem érhető el innen: {{playlist}}. A film a többi lejátszási listájában is megtalálható:", + "RESUME_KEPT": "a lejátszási pozíció megmarad · {{timecode}}", + "ALL_SOURCES_TRIED": "Az összes forrás kipróbálva.", + "ALSO_FOUND_IN_ONE": "— megtalálható még egy másik lejátszási listában", + "ALSO_FOUND_IN_OTHER": "— megtalálható még {{count}} másik lejátszási listában", + "CHECK_ALL": "összes ellenőrzése", + "FILTER_PLACEHOLDER": "Szűrés lejátszási lista alapján", + "NO_MATCHES": "Nincs a szűrőnek megfelelő lejátszási lista", + "COPIES": "{{count}} másolat ebben a listában", + "MORE_SOURCES": "+{{count}} további a forráslistában" + } }, "INFORMATION": "Információk", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e58b0ad34..721069d00 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app.", + "VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso." }, "THEMES": { "DARK_THEME": "Scuro", @@ -1001,7 +1003,45 @@ "ITEM": "elemento", "ITEMS": "elementi", "NO_EPISODES": "Nessun episodio disponibile", - "ALL_RADIO": "Tutte le radio" + "ALL_RADIO": "Tutte le radio", + "MULTI_SOURCE": { + "SOURCES": "Sorgenti", + "SOURCE": "Sorgente", + "SOURCES_FOR": "Sorgenti per “{{title}}”", + "OPEN_SOURCES": "Mostra sorgenti alternative", + "MATCHED_BY_TMDB": "abbinato tramite TMDB", + "MATCHED_BY_TITLE": "abbinato per titolo", + "MATCH_BY_TITLE": "per titolo", + "PLAYING": "In riproduzione", + "CURRENT": "Attuale", + "PLAY_FROM_SOURCE": "Riproduci da questa sorgente", + "PIN_SOURCE": "Imposta come sorgente principale", + "UNPIN_SOURCE": "Rimuovi come sorgente principale", + "CHECK": "verifica", + "CHECK_TOOLTIP": "Verifica disponibilità", + "PROBE_OK": "disponibile", + "PROBE_FAIL": "non disponibile", + "PROBE_CHECKING": "verifica in corso", + "AUTO_FAILOVER": "Cambio automatico in caso di errore", + "PLAYING_FROM": "In riproduzione da:", + "SWITCH_TITLE": "Sorgente non disponibile — passaggio a {{playlist}}", + "SWITCH_DETAIL": "ripresa da {{timecode}}", + "SWITCH_AUDIO_WARNING": "il doppiaggio potrebbe essere diverso", + "SWITCHED_TO": "Passato a {{playlist}}", + "UNDO": "Annulla", + "CHOOSE_OTHER": "Scegli un'altra sorgente", + "TRY_ANOTHER_SOURCE": "Prova un'altra sorgente", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} non fornisce questo stream. Questo film è presente nelle tue altre playlist:", + "RESUME_KEPT": "la posizione verrà mantenuta · {{timecode}}", + "ALL_SOURCES_TRIED": "Sono state provate tutte le sorgenti.", + "ALSO_FOUND_IN_ONE": "— presente anche in un'altra playlist", + "ALSO_FOUND_IN_OTHER": "— presente anche in altre {{count}} playlist", + "CHECK_ALL": "verifica tutte", + "FILTER_PLACEHOLDER": "Filtra per playlist", + "NO_MATCHES": "Nessuna playlist corrisponde al filtro", + "COPIES": "{{count}} copie in questa playlist", + "MORE_SOURCES": "+{{count}} nell'elenco sorgenti" + } }, "INFORMATION": "Informazioni", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 6d6d021ef..8612ef627 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。" + "EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。", + "VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え", + "VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。" }, "THEMES": { "DARK_THEME": "ダーク", @@ -1001,7 +1003,45 @@ "ITEM": "件", "ITEMS": "件", "NO_EPISODES": "利用可能なエピソードはありません", - "ALL_RADIO": "すべてのラジオ" + "ALL_RADIO": "すべてのラジオ", + "MULTI_SOURCE": { + "SOURCES": "ソース", + "SOURCE": "ソース", + "SOURCES_FOR": "「{{title}}」のソース", + "OPEN_SOURCES": "他のソースを表示", + "MATCHED_BY_TMDB": "TMDB で一致", + "MATCHED_BY_TITLE": "タイトルで一致", + "MATCH_BY_TITLE": "タイトル一致", + "PLAYING": "再生中", + "CURRENT": "現在", + "PLAY_FROM_SOURCE": "このソースから再生", + "PIN_SOURCE": "メインのソースに設定", + "UNPIN_SOURCE": "メインのソース設定を解除", + "CHECK": "確認", + "CHECK_TOOLTIP": "利用可能か確認", + "PROBE_OK": "利用可能", + "PROBE_FAIL": "利用不可", + "PROBE_CHECKING": "確認中", + "AUTO_FAILOVER": "エラー時に自動切り替え", + "PLAYING_FROM": "再生元:", + "SWITCH_TITLE": "ソースを利用できません — {{playlist}} に切り替えます", + "SWITCH_DETAIL": "{{timecode}} から再開します", + "SWITCH_AUDIO_WARNING": "吹き替えが異なる場合があります", + "SWITCHED_TO": "{{playlist}} に切り替えました", + "UNDO": "元に戻す", + "CHOOSE_OTHER": "別のソースを選択", + "TRY_ANOTHER_SOURCE": "別のソースを試す", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} はこのストリームを配信していません。この映画は他のプレイリストにもあります:", + "RESUME_KEPT": "再生位置は引き継がれます · {{timecode}}", + "ALL_SOURCES_TRIED": "すべてのソースを試しました。", + "ALSO_FOUND_IN_ONE": "— 他のプレイリストにもあります", + "ALSO_FOUND_IN_OTHER": "— 他の {{count}} 件のプレイリストにもあります", + "CHECK_ALL": "すべて確認", + "FILTER_PLACEHOLDER": "プレイリストでフィルター", + "NO_MATCHES": "このフィルターに一致するプレイリストはありません", + "COPIES": "このプレイリスト内に{{count}}件", + "MORE_SOURCES": "他 {{count}} 件はソース一覧から" + } }, "INFORMATION": "情報", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 423671970..d8f86129d 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다.", + "VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환", + "VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다." }, "THEMES": { "DARK_THEME": "어둡게", @@ -1001,7 +1003,45 @@ "ITEM": "개", "ITEMS": "개", "NO_EPISODES": "사용 가능한 에피소드가 없습니다", - "ALL_RADIO": "모든 라디오" + "ALL_RADIO": "모든 라디오", + "MULTI_SOURCE": { + "SOURCES": "소스", + "SOURCE": "소스", + "SOURCES_FOR": "“{{title}}”의 소스", + "OPEN_SOURCES": "대체 소스 표시", + "MATCHED_BY_TMDB": "TMDB로 매칭됨", + "MATCHED_BY_TITLE": "제목으로 매칭됨", + "MATCH_BY_TITLE": "제목 기준", + "PLAYING": "재생 중", + "CURRENT": "현재", + "PLAY_FROM_SOURCE": "이 소스에서 재생", + "PIN_SOURCE": "기본 소스로 지정", + "UNPIN_SOURCE": "기본 소스 지정 해제", + "CHECK": "확인", + "CHECK_TOOLTIP": "사용 가능 여부 확인", + "PROBE_OK": "사용 가능", + "PROBE_FAIL": "사용 불가", + "PROBE_CHECKING": "확인 중", + "AUTO_FAILOVER": "오류 시 자동 전환", + "PLAYING_FROM": "재생 중인 소스:", + "SWITCH_TITLE": "소스를 사용할 수 없습니다 — {{playlist}}(으)로 전환합니다", + "SWITCH_DETAIL": "{{timecode}}부터 이어서 재생합니다", + "SWITCH_AUDIO_WARNING": "더빙이 다를 수 있습니다", + "SWITCHED_TO": "{{playlist}}(으)로 전환했습니다", + "UNDO": "실행 취소", + "CHOOSE_OTHER": "다른 소스 선택", + "TRY_ANOTHER_SOURCE": "다른 소스로 시도", + "ERROR_HAS_ALTERNATIVES": "{{playlist}}에서 이 스트림을 제공하지 않습니다. 이 영화는 다른 재생목록에도 있습니다:", + "RESUME_KEPT": "재생 위치가 유지됩니다 · {{timecode}}", + "ALL_SOURCES_TRIED": "모든 소스를 시도했습니다.", + "ALSO_FOUND_IN_ONE": "— 다른 재생목록 1개에도 있음", + "ALSO_FOUND_IN_OTHER": "— 다른 재생목록 {{count}}개에도 있음", + "CHECK_ALL": "모두 확인", + "FILTER_PLACEHOLDER": "재생목록으로 필터", + "NO_MATCHES": "이 필터에 해당하는 재생목록이 없습니다", + "COPIES": "이 재생목록에 소스 {{count}}개", + "MORE_SOURCES": "소스 목록에 {{count}}개 더 있음" + } }, "INFORMATION": "정보", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 59d91aba2..f6996b795 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app.", + "VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben." }, "THEMES": { "DARK_THEME": "Donker", @@ -1001,7 +1003,45 @@ "ITEM": "item", "ITEMS": "items", "NO_EPISODES": "Geen afleveringen beschikbaar", - "ALL_RADIO": "Alle radio" + "ALL_RADIO": "Alle radio", + "MULTI_SOURCE": { + "SOURCES": "Bronnen", + "SOURCE": "Bron", + "SOURCES_FOR": "Bronnen voor “{{title}}”", + "OPEN_SOURCES": "Alternatieve bronnen tonen", + "MATCHED_BY_TMDB": "gekoppeld via TMDB", + "MATCHED_BY_TITLE": "gekoppeld op titel", + "MATCH_BY_TITLE": "op titel", + "PLAYING": "Speelt nu", + "CURRENT": "Huidige", + "PLAY_FROM_SOURCE": "Afspelen vanaf deze bron", + "PIN_SOURCE": "Instellen als hoofdbron", + "UNPIN_SOURCE": "Niet meer als hoofdbron gebruiken", + "CHECK": "controleren", + "CHECK_TOOLTIP": "Beschikbaarheid controleren", + "PROBE_OK": "beschikbaar", + "PROBE_FAIL": "niet beschikbaar", + "PROBE_CHECKING": "wordt gecontroleerd", + "AUTO_FAILOVER": "Automatisch wisselen bij fout", + "PLAYING_FROM": "Speelt af vanaf:", + "SWITCH_TITLE": "Bron niet beschikbaar — overschakelen naar {{playlist}}", + "SWITCH_DETAIL": "wordt hervat op {{timecode}}", + "SWITCH_AUDIO_WARNING": "de nasynchronisatie kan afwijken", + "SWITCHED_TO": "Overgeschakeld naar {{playlist}}", + "UNDO": "Ongedaan maken", + "CHOOSE_OTHER": "Andere bron kiezen", + "TRY_ANOTHER_SOURCE": "Andere bron proberen", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} levert deze stream niet. Deze film staat wel in je andere afspeellijsten:", + "RESUME_KEPT": "afspeelpositie blijft behouden · {{timecode}}", + "ALL_SOURCES_TRIED": "Alle bronnen zijn geprobeerd.", + "ALSO_FOUND_IN_ONE": "— ook gevonden in één andere afspeellijst", + "ALSO_FOUND_IN_OTHER": "— ook gevonden in {{count}} andere afspeellijsten", + "CHECK_ALL": "alles controleren", + "FILTER_PLACEHOLDER": "Filter op afspeellijst", + "NO_MATCHES": "Geen afspeellijsten komen overeen met dit filter", + "COPIES": "{{count}} kopieën in deze afspeellijst", + "MORE_SOURCES": "+{{count}} meer in de bronnenlijst" + } }, "INFORMATION": "Informatie", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 51fc4ed48..eb5b4791b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji.", + "VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową." }, "THEMES": { "DARK_THEME": "Ciemny", @@ -1001,7 +1003,45 @@ "ITEM": "pozycja", "ITEMS": "pozycji", "NO_EPISODES": "Brak dostępnych odcinków", - "ALL_RADIO": "Wszystkie radia" + "ALL_RADIO": "Wszystkie radia", + "MULTI_SOURCE": { + "SOURCES": "Źródła", + "SOURCE": "Źródło", + "SOURCES_FOR": "Źródła dla „{{title}}”", + "OPEN_SOURCES": "Pokaż alternatywne źródła", + "MATCHED_BY_TMDB": "dopasowano na podstawie TMDB", + "MATCHED_BY_TITLE": "dopasowano na podstawie tytułu", + "MATCH_BY_TITLE": "wg tytułu", + "PLAYING": "Odtwarzane", + "CURRENT": "Bieżące", + "PLAY_FROM_SOURCE": "Odtwórz z tego źródła", + "PIN_SOURCE": "Ustaw jako główne źródło", + "UNPIN_SOURCE": "Przestań używać jako główne źródło", + "CHECK": "sprawdź", + "CHECK_TOOLTIP": "Sprawdź dostępność", + "PROBE_OK": "dostępne", + "PROBE_FAIL": "niedostępne", + "PROBE_CHECKING": "sprawdzanie", + "AUTO_FAILOVER": "Automatyczna zmiana przy błędzie", + "PLAYING_FROM": "Odtwarzanie z:", + "SWITCH_TITLE": "Źródło niedostępne — przełączanie na {{playlist}}", + "SWITCH_DETAIL": "wznawianie od {{timecode}}", + "SWITCH_AUDIO_WARNING": "wersja językowa może się różnić", + "SWITCHED_TO": "Przełączono na {{playlist}}", + "UNDO": "Cofnij", + "CHOOSE_OTHER": "Wybierz inne", + "TRY_ANOTHER_SOURCE": "Spróbuj innego źródła", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} nie udostępnia tego strumienia. Ten film jest na Twoich innych listach odtwarzania:", + "RESUME_KEPT": "pozycja odtwarzania zostanie zachowana · {{timecode}}", + "ALL_SOURCES_TRIED": "Wypróbowano już wszystkie źródła.", + "ALSO_FOUND_IN_ONE": "— znaleziono także na jednej innej liście odtwarzania", + "ALSO_FOUND_IN_OTHER": "— znaleziono także na {{count}} innych listach odtwarzania", + "CHECK_ALL": "sprawdź wszystkie", + "FILTER_PLACEHOLDER": "Filtruj według listy odtwarzania", + "NO_MATCHES": "Nie znaleziono pasujących list odtwarzania", + "COPIES": "{{count}} kopii na tej liście", + "MORE_SOURCES": "+{{count}} na liście źródeł" + } }, "INFORMATION": "Informacje", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 21e9210cd..f6aff24ab 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação.", + "VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente." }, "THEMES": { "DARK_THEME": "Escuro", @@ -1001,7 +1003,45 @@ "ITEM": "item", "ITEMS": "itens", "NO_EPISODES": "Nenhum episódio disponível", - "ALL_RADIO": "Todas as rádios" + "ALL_RADIO": "Todas as rádios", + "MULTI_SOURCE": { + "SOURCES": "Fontes", + "SOURCE": "Fonte", + "SOURCES_FOR": "Fontes para “{{title}}”", + "OPEN_SOURCES": "Mostrar fontes alternativas", + "MATCHED_BY_TMDB": "correspondência por TMDB", + "MATCHED_BY_TITLE": "correspondência por título", + "MATCH_BY_TITLE": "por título", + "PLAYING": "Reproduzindo", + "CURRENT": "Atual", + "PLAY_FROM_SOURCE": "Reproduzir desta fonte", + "PIN_SOURCE": "Definir como fonte principal", + "UNPIN_SOURCE": "Remover como fonte principal", + "CHECK": "verificar", + "CHECK_TOOLTIP": "Verificar disponibilidade", + "PROBE_OK": "disponível", + "PROBE_FAIL": "indisponível", + "PROBE_CHECKING": "verificando", + "AUTO_FAILOVER": "Troca automática em caso de erro", + "PLAYING_FROM": "Reproduzindo de:", + "SWITCH_TITLE": "Fonte indisponível — trocando para {{playlist}}", + "SWITCH_DETAIL": "retomando em {{timecode}}", + "SWITCH_AUDIO_WARNING": "a dublagem pode ser diferente", + "SWITCHED_TO": "Trocado para {{playlist}}", + "UNDO": "Desfazer", + "CHOOSE_OTHER": "Escolher outra", + "TRY_ANOTHER_SOURCE": "Tentar outra fonte", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} não está fornecendo este stream. Este filme está nas suas outras playlists:", + "RESUME_KEPT": "a posição será mantida · {{timecode}}", + "ALL_SOURCES_TRIED": "Todas as fontes já foram testadas.", + "ALSO_FOUND_IN_ONE": "— também encontrado em outra playlist", + "ALSO_FOUND_IN_OTHER": "— também encontrado em outras {{count}} playlists", + "CHECK_ALL": "verificar todas", + "FILTER_PLACEHOLDER": "Filtrar por playlist", + "NO_MATCHES": "Nenhuma playlist corresponde a este filtro", + "COPIES": "{{count}} cópias nesta playlist", + "MORE_SOURCES": "+{{count}} na lista de fontes" + } }, "INFORMATION": "Informação", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index a56dd0f7d..430a57b70 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "Что нового", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения.", + "VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка." }, "THEMES": { "DARK_THEME": "Тёмная", @@ -1001,7 +1003,45 @@ "ITEM": "элемент", "ITEMS": "элементов", "NO_EPISODES": "Нет доступных эпизодов", - "ALL_RADIO": "Все радио" + "ALL_RADIO": "Все радио", + "MULTI_SOURCE": { + "SOURCES": "Источники", + "SOURCE": "Источник", + "SOURCES_FOR": "Источники для «{{title}}»", + "OPEN_SOURCES": "Показать альтернативные источники", + "MATCHED_BY_TMDB": "сопоставлено по TMDB", + "MATCHED_BY_TITLE": "сопоставлено по названию", + "MATCH_BY_TITLE": "по названию", + "PLAYING": "Играет", + "CURRENT": "Текущий", + "PLAY_FROM_SOURCE": "Воспроизвести из этого источника", + "PIN_SOURCE": "Сделать основным источником", + "UNPIN_SOURCE": "Отменить основной источник", + "CHECK": "проверить", + "CHECK_TOOLTIP": "Проверить доступность", + "PROBE_OK": "доступен", + "PROBE_FAIL": "недоступен", + "PROBE_CHECKING": "проверка", + "AUTO_FAILOVER": "Авто-переключение при ошибке", + "PLAYING_FROM": "Играет с:", + "SWITCH_TITLE": "Источник недоступен — переключаемся на {{playlist}}", + "SWITCH_DETAIL": "продолжаем с {{timecode}}", + "SWITCH_AUDIO_WARNING": "озвучка может отличаться", + "SWITCHED_TO": "Переключено на {{playlist}}", + "UNDO": "Отменить", + "CHOOSE_OTHER": "Выбрать другой", + "TRY_ANOTHER_SOURCE": "Попробовать другой источник", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} не отдаёт этот поток. Этот фильм есть в других ваших плейлистах:", + "RESUME_KEPT": "таймкод сохранится · {{timecode}}", + "ALL_SOURCES_TRIED": "Испробованы все источники.", + "ALSO_FOUND_IN_ONE": "— найден ещё в 1 плейлисте", + "ALSO_FOUND_IN_OTHER": "— найден ещё в {{count}} плейлистах", + "CHECK_ALL": "проверить всё", + "FILTER_PLACEHOLDER": "Фильтровать по плейлисту", + "NO_MATCHES": "Ни один плейлист не соответствует фильтру", + "COPIES": "Копий в этом плейлисте: {{count}}", + "MORE_SOURCES": "+{{count}} в списке источников" + } }, "INFORMATION": "Инфо", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 2eba777aa..5baba7d7e 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur." + "EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur.", + "VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir", + "VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir." }, "THEMES": { "DARK_THEME": "Koyu", @@ -1001,7 +1003,45 @@ "ITEM": "öğe", "ITEMS": "öğe", "NO_EPISODES": "Bölüm mevcut değil", - "ALL_RADIO": "Tüm radyolar" + "ALL_RADIO": "Tüm radyolar", + "MULTI_SOURCE": { + "SOURCES": "Kaynaklar", + "SOURCE": "Kaynak", + "SOURCES_FOR": "“{{title}}” için kaynaklar", + "OPEN_SOURCES": "Alternatif kaynakları göster", + "MATCHED_BY_TMDB": "TMDB ile eşleştirildi", + "MATCHED_BY_TITLE": "Başlığa göre eşleştirildi", + "MATCH_BY_TITLE": "başlığa göre", + "PLAYING": "Oynatılıyor", + "CURRENT": "Geçerli", + "PLAY_FROM_SOURCE": "Bu kaynaktan oynat", + "PIN_SOURCE": "Bunu ana kaynak yap", + "UNPIN_SOURCE": "Bu kaynağın sabitlemesini kaldır", + "CHECK": "kontrol et", + "CHECK_TOOLTIP": "Kullanılabilirliği kontrol et", + "PROBE_OK": "kullanılabilir", + "PROBE_FAIL": "kullanılamıyor", + "PROBE_CHECKING": "kontrol ediliyor", + "AUTO_FAILOVER": "Hatada otomatik geçiş", + "PLAYING_FROM": "Şuradan oynatılıyor:", + "SWITCH_TITLE": "Kaynak kullanılamıyor — {{playlist}} kaynağına geçiliyor", + "SWITCH_DETAIL": "{{timecode}} konumundan devam ediliyor", + "SWITCH_AUDIO_WARNING": "dublaj farklı olabilir", + "SWITCHED_TO": "{{playlist}} kaynağına geçildi", + "UNDO": "Geri al", + "CHOOSE_OTHER": "Başkasını seç", + "TRY_ANOTHER_SOURCE": "Başka bir kaynak dene", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} bu yayını sunmuyor. Bu film diğer oynatma listelerinizde de var:", + "RESUME_KEPT": "oynatma konumu korunacak · {{timecode}}", + "ALL_SOURCES_TRIED": "Tüm kaynaklar denendi.", + "ALSO_FOUND_IN_ONE": "— bir oynatma listesinde daha bulundu", + "ALSO_FOUND_IN_OTHER": "— {{count}} oynatma listesinde daha bulundu", + "CHECK_ALL": "tümünü kontrol et", + "FILTER_PLACEHOLDER": "Oynatma listesine göre filtrele", + "NO_MATCHES": "Bu filtreyle eşleşen oynatma listesi yok", + "COPIES": "Bu oynatma listesinde {{count}} kopya", + "MORE_SOURCES": "Kaynak listesinde +{{count}} daha" + } }, "INFORMATION": "Bilgi", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 9c132c893..4d3162d0c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。" + "EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。", + "VOD_AUTO_FAILOVER": "播放出错时自动切换播放源", + "VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。" }, "THEMES": { "DARK_THEME": "深色", @@ -1001,7 +1003,45 @@ "ITEM": "项", "ITEMS": "项", "NO_EPISODES": "没有可用的剧集", - "ALL_RADIO": "所有广播" + "ALL_RADIO": "所有广播", + "MULTI_SOURCE": { + "SOURCES": "播放源", + "SOURCE": "播放源", + "SOURCES_FOR": "“{{title}}”的播放源", + "OPEN_SOURCES": "显示其他播放源", + "MATCHED_BY_TMDB": "通过 TMDB 匹配", + "MATCHED_BY_TITLE": "通过标题匹配", + "MATCH_BY_TITLE": "标题匹配", + "PLAYING": "正在播放", + "CURRENT": "当前", + "PLAY_FROM_SOURCE": "从此播放源播放", + "PIN_SOURCE": "设为主播放源", + "UNPIN_SOURCE": "取消主播放源", + "CHECK": "检测", + "CHECK_TOOLTIP": "检测可用性", + "PROBE_OK": "可用", + "PROBE_FAIL": "不可用", + "PROBE_CHECKING": "检测中", + "AUTO_FAILOVER": "出错时自动切换", + "PLAYING_FROM": "播放来源:", + "SWITCH_TITLE": "播放源不可用 — 正在切换到 {{playlist}}", + "SWITCH_DETAIL": "从 {{timecode}} 继续播放", + "SWITCH_AUDIO_WARNING": "配音可能不同", + "SWITCHED_TO": "已切换到 {{playlist}}", + "UNDO": "撤销", + "CHOOSE_OTHER": "选择其他", + "TRY_ANOTHER_SOURCE": "尝试其他播放源", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} 未提供此视频流。这部电影也在您的其他播放列表中:", + "RESUME_KEPT": "将保留播放进度 · {{timecode}}", + "ALL_SOURCES_TRIED": "所有播放源均已尝试过。", + "ALSO_FOUND_IN_ONE": "— 另有 1 个播放列表提供此影片", + "ALSO_FOUND_IN_OTHER": "— 另有 {{count}} 个播放列表提供此影片", + "CHECK_ALL": "全部检测", + "FILTER_PLACEHOLDER": "按播放列表筛选", + "NO_MATCHES": "未找到匹配的播放列表", + "COPIES": "此播放列表中有 {{count}} 个版本", + "MORE_SOURCES": "其余 {{count}} 个在播放源列表中" + } }, "INFORMATION": "信息", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ae5a90624..f7f479b1e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -451,7 +451,9 @@ "APP_UPDATE_RELEASE_NOTES": "What's new", "EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)", "EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。", - "EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。" + "EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。", + "VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源", + "VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。" }, "THEMES": { "DARK_THEME": "深色", @@ -1001,7 +1003,45 @@ "ITEM": "項", "ITEMS": "項", "NO_EPISODES": "沒有可用的集數", - "ALL_RADIO": "所有廣播" + "ALL_RADIO": "所有廣播", + "MULTI_SOURCE": { + "SOURCES": "播放來源", + "SOURCE": "播放來源", + "SOURCES_FOR": "「{{title}}」的播放來源", + "OPEN_SOURCES": "顯示其他播放來源", + "MATCHED_BY_TMDB": "依 TMDB 比對", + "MATCHED_BY_TITLE": "依標題比對", + "MATCH_BY_TITLE": "依標題", + "PLAYING": "播放中", + "CURRENT": "目前", + "PLAY_FROM_SOURCE": "從此來源播放", + "PIN_SOURCE": "設為主要來源", + "UNPIN_SOURCE": "取消設為主要來源", + "CHECK": "檢查", + "CHECK_TOOLTIP": "檢查是否可用", + "PROBE_OK": "可用", + "PROBE_FAIL": "無法使用", + "PROBE_CHECKING": "檢查中", + "AUTO_FAILOVER": "發生錯誤時自動切換", + "PLAYING_FROM": "播放來源:", + "SWITCH_TITLE": "來源無法使用 — 正在切換至 {{playlist}}", + "SWITCH_DETAIL": "從 {{timecode}} 繼續播放", + "SWITCH_AUDIO_WARNING": "配音可能不同", + "SWITCHED_TO": "已切換至 {{playlist}}", + "UNDO": "復原", + "CHOOSE_OTHER": "選擇其他來源", + "TRY_ANOTHER_SOURCE": "嘗試其他播放來源", + "ERROR_HAS_ALTERNATIVES": "{{playlist}} 未提供此串流。您的其他播放清單中有這部電影:", + "RESUME_KEPT": "將保留播放進度 · {{timecode}}", + "ALL_SOURCES_TRIED": "已嘗試所有播放來源。", + "ALSO_FOUND_IN_ONE": "— 另有 1 個播放清單也有此片", + "ALSO_FOUND_IN_OTHER": "— 另有 {{count}} 個播放清單也有此片", + "CHECK_ALL": "全部檢查", + "FILTER_PLACEHOLDER": "依播放清單篩選", + "NO_MATCHES": "沒有符合篩選條件的播放清單", + "COPIES": "此播放清單中的 {{count}} 個版本", + "MORE_SOURCES": "播放來源清單中另有 {{count}} 個" + } }, "INFORMATION": "資訊", "DOWNLOADS": { diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index 8f2416235..2d367d0cd 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -115,11 +115,17 @@ media. Barriers and delays are coordination tools, not timing inputs. | `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | | `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active | | `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active | +| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active | +| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active | | `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | | `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account). +`multisrc1` and `multisrc2` deliberately share one faker seed, so both portals +generate an identical catalog. That overlap is what the VOD multi-source E2E +needs — the same movie present in two different playlists. + --- ## API Endpoints diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 8212b9212..81e32ff8d 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -125,6 +125,31 @@ export const SCENARIOS: Record = { expiryDate: '2099-12-31', vodDetailsFixture: 'empty-metadata', }, + // Two distinct credential pairs sharing one seed, so both portals generate + // an IDENTICAL catalog. That overlap is what VOD multi-source discovery + // needs: the same movie present in two different playlists. + 'multisrc1:multisrc1': { + name: 'multi-source-a', + description: 'Multi-source portal A — shares its catalog with portal B', + seed: 4242, + categoryCount: { live: 1, vod: 2, series: 1 }, + itemsPerCategory: 5, + seasonsPerSeries: 1, + episodesPerSeason: 2, + accountStatus: 'Active', + expiryDate: '2099-12-31', + }, + 'multisrc2:multisrc2': { + name: 'multi-source-b', + description: 'Multi-source portal B — shares its catalog with portal A', + seed: 4242, + categoryCount: { live: 1, vod: 2, series: 1 }, + itemsPerCategory: 5, + seasonsPerSeries: 1, + episodesPerSeason: 2, + accountStatus: 'Active', + expiryDate: '2099-12-31', + }, 'marketing:marketing': { name: 'marketing-demo', description: diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 6fb85b0b2..519f2f60a 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -51,15 +51,26 @@ These tokens are the base for interactive emphasis: - `--app-selection-border` - `--app-selection-glow` -Use Material surface tokens for neutral surfaces: +Use the app's own surface tokens for neutral surfaces (defined for both themes +in `apps/web/src/m3-theme.scss`): -- `--mat-sys-surface` -- `--mat-sys-surface-container-low` -- `--mat-sys-surface-container` -- `--mat-sys-surface-container-high` -- `--mat-sys-outline-variant` -- `--mat-sys-on-surface` -- `--mat-sys-on-surface-variant` +- `--app-shell-bg` / `--app-rail-bg` / `--app-header-bg` / `--app-content-bg` +- `--app-widget-bg` / `--app-widget-header-bg` — panels and popovers +- `--app-card-hover-bg` — raised or hovered rows +- `--app-widget-border` / `--app-rail-border` — hairlines +- `--app-on-surface` — primary text +- `--app-eyebrow-color` — secondary/muted text + +**Do not reach for `--mat-sys-*` surface tokens.** Angular Material's system +tokens are *not* emitted globally in this app — only `--mat-sys-on-surface` +exists. `var(--mat-sys-surface-container-high)` and friends resolve to nothing, +which usually goes unnoticed because the surrounding Material component +supplies its own default and masks it. Outside a Material component — a CDK +overlay, a projected panel — the same reference silently produces a fully +transparent, unreadable surface. + +If a `--mat-sys-*` token is genuinely needed, always give it a real fallback: +`var(--mat-sys-on-primary, #fff)`. Do not hardcode unrelated accent colors for selected state when these tokens already exist. diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 2f3f6f045..b45eedae0 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -162,12 +162,34 @@ worker IPC boundary in the snake_case wire shape declared by `XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project their Drizzle rows explicitly to keep that contract true. +Clearing the playlist's existing pins goes through a dedicated +delete-by-playlist operation, not the keyed clear: that one caps its key list +to bound an IN clause, so a playlist with more pinned movies than the cap kept +the surplus while still reporting success. A failure now fails the entry +rather than leaving the union of old and archived pins. + +`sourcePins` (VOD multi-source) is the one **optional** collection, and the +normalizer preserves that: an absent field stays absent rather than becoming +`[]`, because restore treats a PRESENT collection as authoritative and clears +the playlist's existing pins before applying it. Materializing an empty array +would turn "this archive predates pins" into "this archive says there are +none": archives +written before multi-source existed simply do not have it, so its absence is +age rather than damage and only a wrong type is rejected. A pin is carried +under the playlist it points AT — exporting it anywhere else would restore a +preference for a portal the archive never contained. Its `matchKey` identifies +the film rather than the portal, so it survives untouched; only the playlist id +is remapped to the imported copy. Pins whose match key or content id is +unusable are dropped, since writing one would occupy the unique key of a film +it does not describe. + Electron restore behavior: 1. Category import reads pending hidden-category state while saving categories. 2. After content import, favorites/recent state is restored by typed `{ contentType, xtreamId }` matching. 3. Playback positions are cleared and re-applied from backup state. +4. VOD source pins are re-applied against the IMPORTED playlist id. For existing Xtream playlists with a fully populated offline cache, backup import applies the restore immediately. Otherwise the typed restore payload is diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index c22d3d008..121be4848 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -109,6 +109,7 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the | --------------------- | ----------------------------------------------- | | Web app browser flows | `pnpm nx run web-e2e:e2e -- --project=chromium` | | Electron flows | `pnpm nx run electron-backend-e2e:e2e` | +| VOD multi-source | `pnpm nx run electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts` | Use atomized E2E targets when available, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`. diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md new file mode 100644 index 000000000..423aac3ec --- /dev/null +++ b/docs/architecture/vod-multi-source.md @@ -0,0 +1,660 @@ +# VOD Multi-Source + +Finds the same movie in the user's other imported playlists, lets them switch +which one it streams from without losing the timecode, and turns the +playback-error screen into a recovery point. + +The point is not choice for its own sake — it is rescuing a viewing when the +current source is dead, serves an unsupported codec, or buffers badly. + +## Scope (v1) + +| | | +|---|---| +| Source types | **Xtream ↔ Xtream only** | +| Content | Movies only — series are not offered a source chip | +| Environment | **Electron only** — the chip renders nothing in the PWA | +| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`); offered only on the built-in web players | +| Pin scope | Per movie (a global portal priority is out of scope) | +| Stream probe | HEAD → reachable + latency, sent with the playlist's own playback headers. **No codec probing** | + +Stalker never reaches the `content` table (it would need a live authenticated +`get_ordered_list&search=` per portal), and M3U playlists are stored as a JSON +blob whose search path forces `content_type: 'live'`. Both are additive later +without changing the contracts — `VodSourceCandidate.portalType` already carries +`'xtream' | 'stalker' | 'm3u'` and discovery sits behind a service interface. + +A probe answer is cached per *request*, not per URL: two playlists can share a +stream URL and require different headers, and one of them answering 403 says +nothing about the other. + +`ffprobe`/`ffmpeg` are not dependencies of this app and are not bundled, so +`provenance: 'probe'` means reachability and latency only. There is deliberately +no feature flag for codec probing: it would gate a code path with no binary +behind it. + +## The honesty rule + +This is the part to preserve if anything here is refactored. + +Every metadata value carries **where it came from**: + +| provenance | produced by | rendered as | +|---|---|---| +| `api` | `get_vod_info` — container, codec, audio, dimensions | plain tag | +| `parsed` | regex over the title/filename | tag prefixed `~`, warn colour | +| `probe` | HEAD → reachable + latency (retried as a ranged GET when the server answers 405/501, since plenty serve media over GET while refusing HEAD) | `ok` / `fail` status tag | +| *absent* | — | **no tag at all** + a `check` chip | + +Three rules follow, and each is enforced in code rather than by convention: + +1. **A guess never ranks.** `factualOnly()` + (`libs/portal/shared/data-access/.../vod-source-metadata.util.ts`) is the only + sanctioned accessor for ranking and failover decisions, and `parsed` values + are structurally unreachable through it. A filename claiming 4K cannot + outrank a source that was actually reached. +2. **Unknown is not "unavailable".** `VodSourceProbeStatus` separates `fail` + (contacted and refused) from `unknown` (timed out, blocked by the redirect + policy, or no probe capability). A probe returning HTTP status `0` maps to + `unknown`; the UI keeps offering a check and never shows the source as dead. +3. **Empty beats wrong.** Quality is derived from pixel *width*, because + letterboxed masters are cropped vertically — a 2.39:1 1080p film is 1920×800, + and 800 alone is indistinguishable from a 1280×800 encode. With no width, a + height is trusted only within 5% of a standard frame height; otherwise no tag + is emitted. Below the HD widths the ranges stop working: 960×540 and 1024×576 + are two formats inside one 900–1199 range, 800×600 and 800×450 are neither + 480p nor each other, and 720 wide is NTSC 480p or PAL 576p depending only on + the height. Those formats are therefore matched rather than bucketed, and an + unrecognised shape returns nothing rather than a label that would be + published as an `api` fact. + + A known height has to be consistent with the width **on every tier**, + ranges included. Cropping only ever *removes* lines, so a shorter frame is a + letterboxed master of that format, while a taller one is a different shape: + 640×480 against 640×360 below, and 1440×1080 (anamorphic 1080) or 1600×900 + against the 720p range above. All of those get no tag. Bucketing HD widths is + otherwise sound — the standard widths really are far apart — but only once + the height is allowed to veto the answer. + +Provenance is per-field and changes over time: at discovery a row has only +`parsed` tags, because the `content` table stores no container, codec or audio. +Facts arrive when the source is resolved. + +## Architecture + +``` +VOD details page (Xtream) + └── VodMultiSourceHostService component-provided, one per open movie + ├── VodMultiSourceController session state + failover ranking + ├── VodSourceDiscoveryService ──► DB_FIND_TITLE_SOURCES (trigram FTS) + ├── VodSourceResolverService ──► foreign playlist creds → get_vod_info + │ → constructVodUrl + ├── VodSourcePinService ──► DB_GET/SET/CLEAR_VOD_SOURCE_PIN + └── StreamProbeService ──► STREAM_PROBE_URL (main process) +``` + +`VodMultiSourceHostService` is **component-provided, never root-provided**: the +controller's "each source is tried at most once" set must die with the movie, or +a later film inherits a poisoned failover history. + +`VodMultiSourceController` is a plain class, not a service, for the same reason — +and so its ranking logic is testable without TestBed. + +### Ownership + +| Concern | Location | +|---|---| +| DTOs, match key | `libs/shared/interfaces/src/lib/vod-source*.ts` | +| Pin table | `libs/shared/database/src/lib/vod-source-pins.schema.ts` | +| Discovery SQL | `apps/electron-backend/.../operations/title-sources.operations.ts` | +| Pin CRUD | `apps/electron-backend/.../operations/vod-source-pin.operations.ts` | +| Probe handler | `apps/electron-backend/src/app/events/stream-probe.ts` | +| Discovery / resolve / rank | `libs/portal/shared/data-access/src/lib/multi-source/` | +| Probe + pin clients | `libs/services/src/lib/{stream-probe,vod-source-pin}.service.ts` | +| Row / popover / chip | `libs/ui/components/src/lib/vod-sources/` | +| Page wiring | `libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-*.ts` | + +### One picker, two places, two counts + +The chip on the action row and the chip in the inline player's now-playing bar +are the same component, so both are handed the same `matchKind` and the same +`vodAutoFailover` value and both write the setting back. A copy that rendered +the toggle off while it was on — and did nothing when flipped — would be worse +than not offering it. + +The two numbers around it are deliberately different, because their sentences +are: the chip says "Sources N" and counts alternative **streams**, while the +caption says "also found in N other playlists" and counts distinct +**playlists** (`alternativePlaylistCount`). The popover groups a portal's three +copies of a film under that one portal, so counting rows there would contradict +the list the caption invites the user to open. + +## Identity + +Provider ids cannot key a pin — the same film has a different `stream_id` in +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`. + +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. +Reading every alias is what keeps a late TMDB id from orphaning an earlier pin. + +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 decision under **every** key in `write`, and clears whatever +stale row is left over. Each half rules out the other's shortcut: + +- Writing only the top key leaves the movie unfindable under its own poorer + identity. A pin set while `tmdb:438631` was known is invisible to the next + reopen, which starts out with nothing but a title and a year and asks for + `title:dune:2021` — so the preference is ignored until enrichment lands, and + permanently if enrichment is off or never answers. Storing it under both keys + makes it readable at every stage of the same film's identity, and overwriting + the poorer key is also what stops it from still naming the source the user + just replaced. +- Spreading it across *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 form is deliberately absent from `write` + for exactly this reason; it stays readable and unwritten. + +The renderer passes `write[0]` as the pin's own `matchKey` and the rest as +`aliasKeys`; `setVodSourcePin` upserts one row per key and retires the leftovers +inside the same transaction, so no key list can half-apply. + +Known limit, inherent to addressing rows by key alone: a write can only touch +keys the renderer can *name*. Re-pin a film during a pre-enrichment window and +the `tmdb:{id}` row from an earlier, enriched session is not among them, so it +survives pointing at the old source — and outranks the title key once the id +arrives again. Nothing can enumerate it from the page's side; closing it needs a +reverse index from film to key. The window is small in practice (TMDB responses +are cached in `tmdb_metadata`, so a revisit usually resolves the id at once) and +does not exist at all with enrichment off. + +A write stores the new key **before** retiring the old rows. The other order +destroys the stored preference and can then fail to replace it, leaving nothing +persisted while the row still shows the old pin; lookups are most-trusted-first, +so a leftover alias never outranks the key just written. + +The pin a rediscovery reads is applied **immediately**, not after its source +lookup returns: holding that snapshot across the await lets it overwrite a pin +the user makes in the meantime, leaving the row and the primary Play naming a +source the database no longer holds. Applying it first makes the later write +simply win. + +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, +and it will not be — so `togglePinnedSource` reports "nothing happened" and the +controller is left exactly as it was. + +### Rediscovery vs. a new session + +Two keys, deliberately, because the host has two different questions to answer +when enrichment lands: + +- `vodMultiSourceMovieKey` covers title, year and TMDB id. Enrichment changing + any of them **re-runs discovery** — that is how a yearless search gets its + year and a `tmdb:`-keyed pin becomes findable. +- `vodMultiSourceSessionKey` is `playlistId:contentId` — the film itself. It is + what decides whether that rerun is a *refresh* or a *new session*. + +A refresh keeps the controller: the source the user switched to stays active +(with the facts its resolve produced, rather than the catalog's guesses), the +tried set stays burned, the live position stays, and a switch already in flight +still commits. Rebuilding there would take the film off the source it is +actually streaming, and hand failover a clean slate for sources it has already +spent. Only a different film resets — including the tried set, which is what +makes failover terminate. + +A rerun can also legitimately *drop* the playing row: the year the enrichment +supplies makes the year gate reject a copy the yearless search had admitted +("Dune" 1984 while watching the 2021 film). Off the list is right — it is not +the same film. Off the screen is not, so `applyDiscoveredSources` keeps it as a +row and leaves it active; a caption naming a playlist that is not streaming +anything would be a lie about the one thing this feature exists to state. + +## What the queries are allowed to miss + +A source that exists but is never read is indistinguishable, to the user, from +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 + matches "Titanic" and "The Italian Job" for "It", so the scan asks for the + token as a word + (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) and orders by + title length (a match is the title plus decoration: "IT (2017) 1080p"). + + The FTS path keeps its 60-row window because it ranks by relevance — the best + rows are the ones it keeps. A scan cannot rank, so a window there would + silently decide which valid sources the user may see, and it would not even + buy anything: the GLOB cannot use an index, so SQLite reads every row either + way and a `LIMIT` saves transfer, not work. What bounds the scan instead is + its predicate: reaching it means NO token cleared the trigram minimum — a + one-or-two-character title like "It", but also an all-short multiword one + like "I Am" — and EVERY token must then appear as a word. Matching on the + first token alone would return most of a large catalog for the confirmation + pass to discard, which on the single database worker is real blocking. + + Like the `LIKE` it replaces it compares ASCII-lowercased text, so a non-ASCII + short title is no better and no worse served than before. + +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. When that pin is +the route's *own* row, the kept copy and `currentSourceRow()` are the same +stream, so `applyDiscoveredSources` drops the duplicate rather than listing it +twice. + +### 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. + +The two tiers read different years, though. The **base** tier accepts either +form, bracketed or trailing, because it has just stripped a trailing year and +that year is the only thing standing between "Dune 1984" and "Dune 2021". The +**exact** tier reads the bracketed form ONLY: reaching it means both titles are +the same string, so a trailing four digits belong to both, and weighing a number +that is part of the NAME against a release year out of metadata rejects the very +copy it was meant to confirm — "Blade Runner 2049" against a stated 2017 +disappears the moment enrichment lands. Brackets are never part of a name, so +"Dune (1984)" is still rejected against 2021 on the exact tier. + +Which is why the movie's OWN year is read with `releaseTagYear`, not +`extractYear`. The latter takes a year from anywhere in the title, which is the +right answer where a year is only a search hint that scoring will confirm — and +the wrong one here, where the year is part of an identity. "2001: A Space +Odyssey" is not a 2001 film: calling it one makes every genuine 1968 copy fail +the gate above, so the movie has no alternatives at all, and it moves the pin +key the moment enrichment supplies the real year. Only bracketed and trailing +forms count. The trailing form stays ambiguous on purpose ("Blade Runner 2049" +is a title, not a tag) — that is what the two-tier exact/base match is for. + +## Why resolution is lazy + +The `content` table stores no `container_extension`, and +`XtreamUrlService.constructVodUrl` returns `''` without one. A discovered source +is therefore **not playable from the database** — every alternative costs one +live `get_vod_info` against a foreign playlist's credentials, which can fail +offline, on an expired account, or behind Cloudflare. + +So: rows render instantly from the FTS match with `parsed` provenance, and a URL +is resolved only when the user clicks play, pins, or checks. Containers are +memoised per `(playlistId, streamId)` for the session, and a container learned +earlier is reused if the portal later goes unreachable. Resolution is never +fanned out on page load. + +## Switching without losing the timecode + +Every playback host renders `@if (inlinePlayback(); as playback)`, so re-`set()`ing +that signal with a new `{streamUrl, startTime}` swaps the source **in place** — +the player component, `WebPlayerView` and the engine all survive, and +`WebPlayerViewComponent`'s effect rebuilds the source and clears the diagnostic. + +Three details make the position survive: + +- The carried position is the **live** one. `handleInlineTimeUpdate` reports to + `VodMultiSourceHostService.reportPosition()` *before* the 15-second + persistence throttle, so a switch does not rewind by up to 15 seconds. + External players have no `timeupdate` at all, so for them the polled + `playback_positions` value IS the live one and is reported directly — + seeding it would freeze the resume point where playback started. +- It is a single `.set()`, never `null` then set — a null in between would + destroy the player subtree and lose the engine. +- `playback_positions` is keyed `(playlistId, contentXtreamId, contentType)`, so + a switch changes the key. The resolved playback carries the **new** source's + `contentInfo`, and that source's row takes over. + +The position also has to exist *before* anything plays. Nothing reports a live +one until the first `timeupdate`, so a pinned source started straight off the +Resume button would resolve at zero and restart the film. The controller is +therefore seeded from the persisted position (`seedResumeSeconds`), one-way: +once a live position exists it wins, because the stored one lags it by up to +the save throttle and applying it would visibly rewind. + +A resuming engine can emit a `timeupdate` at ~0 before it finishes seeking. +`VodDetailsPlaybackService` guards this with a one-shot `resumeSettled` latch — +a filter would have broken deliberate seek-backwards. The latch also releases +when the target is out of reach: switching a two-hour position into a +90-minute cut means the engine can never report it, and waiting would suppress +every save for the rest of the session. `handleInlineTimeUpdate` +returns that verdict and the route feeds multi-source the `startTime` it asked +for until the engine gets there, so a switch or a failure during the initial +seek does not resolve the next source at zero and restart the film. One latch +serves both, because two would eventually disagree. + +## Failover + +Only fires when `Settings.vodAutoFailover` is on, and it first awaits a +discovery still in flight — a stream can fail faster than SQLite answers, and +concluding "nowhere to go" against an empty controller would strand the user on +the error screen with alternatives landing a moment later. `stillOwnsScreen()` +does that wait and then re-checks the session, because the user can navigate +during it and the controller afterwards may belong to a different film; the +pinned-Play path takes the same wait, or a persisted pin would lose to worker +latency. +Ranking (`pickFailoverTarget`): + +1. never tried this session — a **hard filter**, not a preference +2. probed reachable; probed failing is penalised +3. not known to have failed recently +4. richer **factual** metadata (via `factualOnly`) +5. exact title match over fuzzy + +Termination is structural: `triedSourceIds` only ever grows within a session, so +an N-source movie fails over at most N−1 times and then shows the honest error +screen. Returning to an earlier source by hand does not clear the set. + +Selection is not an attempt. `setActiveSource` only selects; `markPlaying` also +spends the source's turn, and only the three places that really start playback +call it — a switch, the route's own Play/Resume, and restoring the playing row +after a rediscovery. Discovery selects the route's row the moment the page +opens, and a pin or the picker can select an alternative before anything plays; +counting those would let a later failure skip a healthy fallback, or call the +options exhausted with one untouched. `runFailover` then retires whatever is on +screen before picking, so the source that just failed is spent however it got +there — one hole per start path would be an infinite ping-pong between two +sources. + +A source that cannot even be resolved is marked tried without becoming active, +and failover **continues to the next candidate** rather than giving up — +production calls `failover()` only once, on the original failure, so stopping at +a dead top-ranked source would strand every healthy one below it. `switchTo` +therefore reports which of two things happened: `unresolvable` (marked tried, +keep going) or `superseded` (something newer owns the screen, stop). Without +that distinction the loop would spin on a superseded target forever, because +only the first outcome marks the candidate tried. + +**A pin is not decoration.** The primary Play action starts from the pinned +source when one is set, and the pin outranks every other signal in the ranking +above. Loading a pin that only decorated its row would mean "make this the main +source" survived a restart as an icon and nothing else. + +The switch is **always announced** — another source can carry a different dub or +cut. The toast offers Undo, and adds a dub warning when +`audioDiffersFactually()` is true, which requires **both** sides to state an +audio track as fact. Two guesses, or a guess against a fact, stay silent. + +Web engines only (HTML5/hls.js, Video.js, ArtPlayer). Embedded MPV suppresses +shared diagnostics and owns its own error block; external MPV/VLC are +fire-and-forget with no error channel back. + +## External players and an alternative source + +A switch goes through the same inline-vs-external fork a normal Play takes, so +with MPV or VLC configured the alternative opens in the external player — and +that session carries the OTHER playlist's ids. `matchedExternalPlayback` would +disown it: the primary button never became Stop, stopping found no session, and +another click opened a second player. The page therefore claims a session that +matches either the route's own stream or the alternative multi-source says is +active (`VodDetailsPlaybackBindings.activeSource`). + +`ownsContent()` answers that question once, for both consumers: the session +matcher AND the playback-position bridge. They cannot be allowed to disagree — +a page that shows a Stop button for a session whose progress it discards keeps +the resume point at wherever playback began, so a switch an hour later rewinds +the whole session. + +The caption itself appears only while a player is actually running — inline or +a matched external session, and not while a playback diagnostic is up. +Discovery marks a source active as the page opens, so gating on that alone +would have the page claim "Playing from …" before Play was pressed, after the +player was closed, and over the error screen for a stream that would not +play. + +Whichever source ends up playing, the "playing" badge follows it: starting the +route's own stream (Play, Resume, Restart, or the fallback after a pin does not +apply) hands the badge back to the route row, or the picker and caption go on +naming an alternative that is no longer running. That hand-back also +invalidates a switch still resolving — the user chose the route stream, and an +older resolution arriving afterwards would replace what they just asked for. And a source started through +the picker or a pin is recorded in Recently Viewed exactly as an ordinary Play +is — it is the same film, watched. + +Stop then has to win over the pin. The primary action consults the pin first — +that is what makes "make this the main source" decide where playback starts — +but when a session is already running the same button reads Stop, and doing +anything other than stopping would launch a second player while the first kept +going. + +## PWA + +Discovery, foreign-playlist reads, the pin table and the probe are all +main-process. A browser HEAD to an arbitrary IPTV host is CORS-blocked, and +`no-cors` yields an opaque response where 200, 403 and 404 are +indistinguishable — so the PWA cannot answer these questions honestly rather +than merely lacking a convenience. + +Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching +`CatalogTitleMatchService`. In the PWA the chip renders nothing and the +auto-failover setting is hidden. + +## Resuming a pinned copy + +Playback positions are keyed by (playlist, stream), so a pinned alternative +carries its own. `playPinned` looks that position up and applies it — and +applies **zero** when the lookup comes back empty, because the controller is +still holding the ROUTE copy's position at that moment. Carrying it across +would drop the user into the middle of a film they never started here, and the +first save would write that timecode under the pinned source's key. + +When no lookup function is supplied at all, nothing is applied: "never watched" +was never established, so there is nothing to correct. + +## What the primary button describes + +Two position signals, not one. `vodPlaybackPosition` is the LAST position +seen — whichever copy produced it — and feeds the progress bar and the switch +handoff. `routePlaybackPosition` is the route copy's own row, and everything +that acts on the route's stream reads that: Resume, its label, its timecode. +Collapsing them lets an alternative's progress resume the route copy at a +timecode nobody reached in it. Route reuse (the Similar rail) clears +both, so the button cannot describe the previous movie while the new lookup is +still in flight, and every route start — including the primary button's +fall-through past an unresolvable pin — goes through the route's own wrappers, +which replace whatever timecode an alternative left in the controller. + +While the pinned copy is the one playing, its live position wins over the row +that was stored before this session started. + +A pin means the button plays a copy the page did not load a position for. A +watched-through copy resolves to zero rather than its stored seconds, through +the same `isResumablePosition` rule the label uses — otherwise the button reads +Play and then seeks back to where the film ended. Restart follows the pin for +the same reason: with Resume honouring a pinned copy, a Restart that started +the route copy would silently switch the user's playlist. +`createPrimaryActionPosition` therefore looks that copy's row up and lets it +govern the label, the timecode and the Restart affordance — including when the +lookup comes back empty, because "never watched" is an answer: the button must +read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the +route's own row changes nothing; the loaded position already IS that copy's. + +## Provider codec metadata + +`info.video` / `info.audio` come back in two shapes: the declared string array +(`['H.264']`, which the mock server and many panels send) and the ffprobe +object carrying `codec_name`/`width`/`height`. `readStreamInfo` accepts both — +reading only the object silently lost the codec on every array response. + +That codec is a **display** fact and nothing more. The "dub may differ" warning +reads `audioLanguage`, which comes from the track's language tag +(`info.audio.tags.language`, or `language` on panels that hoist it) and never +from `audio`. A codec cannot answer the question the warning asks: AAC and AC3 +routinely carry the same dub, and two AC3 tracks can carry different ones, so +comparing codecs fired on every identical-language re-encode and stayed silent +on the dub changes it exists for. Wrong in both directions is worse than +absent, because a warning people learn to ignore is not a warning. Few panels +tag a language at all, so in practice it is usually silent — which is the +honest state, and the same one the rest of this feature takes when it does not +know. + +Switching sources through `startResolvedPlayback` closes the external session +it LAUNCHED first — tracked separately from the controller's active source, +which a switch has already moved to the destination by then. It REPLACES what is playing — with MPV or VLC and instance +reuse off, the backend would otherwise spawn a second detached player, leaving +both sources running and Stop owning only the newer one. + +## Short titles and Unicode + +The title index folds diacritics. `content_title_fts` is created with +`tokenize='trigram remove_diacritics 1'`, because matching compares NORMALIZED +titles ("Amélie" → "amelie") against an index built from the raw one — without +folding, every accented title was invisible to the FTS path, and two identical +`Amélie` entries produced no candidates at all. The tokenizer is fixed at +CREATE time, so existing databases are recreated and rebuilt once behind +`migration:content-title-fts-remove-diacritics:v1`. + +`remove_diacritics` needs SQLite 3.45+. The migration probes support on a temp +table first and does nothing when the runtime rejects it, leaving the working +index in place — and does NOT record itself as done, so a later app version +shipping a newer SQLite upgrades then. (better-sqlite3 currently bundles 3.53, +so the fallback is defensive rather than a path anyone is on.) + +The completed marker is not taken as proof. `createTables` declares this table +too, with the plain tokenizer, and `CREATE TABLE IF NOT EXISTS` would recreate +it unfolded if it ever went missing after the marker was written — leaving two +sources of truth for one tokenizer. The migration therefore reads the live +table's own DDL out of `sqlite_master` and rebuilds unless it really folds. A +degraded index is otherwise invisible: discovery simply stops finding "Pokémon" +for "pokemon", with nothing to indicate it should have. + +**Case folding for non-ASCII, on the FTS tier, is still not possible.** +`LOWER()` and the trigram tokenizer both fold ASCII only, so a Cyrillic title +stored with different capitalisation in two playlists ("ОН" vs "Он") cannot be +folded by the indexed path. Closing that needs a stored normalized-title +column, which is deliberately out of scope here. + +The **scan** tier does fold it, because GLOB character classes are *not* +ASCII-only — `patternCompare` reads them as UTF-8 code points, and +`'Он' GLOB '*[Оо][Нн]*'` is true. `caseInsensitiveGlobPattern` therefore folds +the case in JavaScript, where Unicode case mapping is real, and hands SQLite one +class per character. Each class also carries the uppercase form's OWN lowercase, +which is what covers a letter spelled two ways in lower case: Greek `Σ` +lowercases to `σ`, but a word-final sigma is written `ς` and is equally a +lowercase of it. That reach is one-way — `σ → Σ → σ` never arrives at `ς` — and +left so deliberately, because `ς` is only correct at the end of a word, exactly +where the request's own last character sits; closing the other direction needs a +fold table. + +It returns `null` — leaving the substring tests as the whole answer — for a +token holding a GLOB metacharacter (SQLite GLOB has no escape character, so an +unescaped `*` would become a wildcard matching every row) or a case mapping that +changes length (`ß` uppercases to `SS`, `İ` lowercases to two code points). +Neither has a single-character class that means the same thing, and a wrong +pattern is worse than an absent one. + +The ASCII/Unicode branch is decided from the RAW token, not the normalized one: +normalization folds diacritics, so "Ça" arrives as "ca" and looks like plain +ASCII while the stored title still reads "Ça". ASCII tokens keep the +word-boundary GLOB (what stops "it" matching "Titanic"). The non-ASCII branch +keeps both substring tests alongside the folded class, because the class is +built from the raw token and cannot match across a diacritic difference, which +the folded-token test does. It has no word boundary — `[^a-z0-9]` would treat +every Cyrillic letter as a separator — so it is looser, and the normalized +confirmation afterwards is what makes looser safe. + +## Claims about the present + +`isActive` means "the source a switch or Play would use" — selection, not +playback. `pinnedSourceAwaitingPlay` therefore takes `playbackLive` as well: +a pinned row stays selected after its player is closed, and skipping the pin +on selection alone would send the next Play to the route copy. Discovery sets it the +moment the page opens, and it survives closing the player — so it cannot, on +its own, back a statement in the present tense. + +`VodDetailsRouteComponent.playbackLive` is that statement. Inline, it needs a +`timeupdate`: `inlinePlayback()` is only the REQUEST to play, non-null while +the engine is still opening the stream and still non-null after it fails, while +a timeupdate is the engine reporting frames. External, it needs the session +past `launching`. Every path that mounts a stream clears the latch first — +Play, Restart, and a source switch, which puts a DIFFERENT stream into the +same host and so cannot inherit the old one's evidence. + +Two things read it, and they must agree: the "Playing from" caption, and the +source row's badge — which reads `Current` when a source is merely selected and +`Playing` once one really is. + +## Where the route's code lives + +`VodDetailsRouteComponent` is a thin host. The multi-source concerns it grew +live in `VodDetailsMultiSourceUiService` (component-provided): the +`playbackLive` evidence, the caption, the primary button's position, the +source actions, and the failover toast. The "Similar" rail and offline +downloads sit in their own component-provided services beside it. + +## Backup + +`playPinnedSource` reports `played` / `superseded` / `unavailable`, and only +`unavailable` may fall through to the route source. Collapsing the first two +meant a double-click on Play — where the second attempt supersedes the first — +had the losing attempt conclude "no usable pin" and start the route stream over +the playback the winning one had just begun. Same distinction as `runFailover`'s, +for the same reason. + +A pin is written under every key naming its film, and its stale aliases retired, +in ONE transaction (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`). Split in +two there is no honest outcome for a half-failure: a surviving alias is read +before the canonical key on the next open, so reporting success starts the +source the user just replaced — while reporting failure leaves the canonical row +durable and the UI showing a pin that is no longer the stored one. A call with +no usable key reports failure rather than claiming a preference it never wrote. + +Pins ride along with playlist backup, under the playlist they point at, as the +optional `sourcePins` collection. See +`docs/architecture/playlist-backup-restore.md` for the remapping and +sanitizing rules — the short version is that `matchKey` names the film and +survives as-is, while the playlist id becomes the imported copy's. + +## Which engines can fail over + +Only the built-in web players (HTML5, Video.js, ArtPlayer) raise the playback +diagnostic that reaches `onPlaybackFailed()`. `WebPlayerViewComponent` +suppresses it for Embedded MPV, and external MPV/VLC never mount that component +at all — a stream that dies there is invisible to the app. + +So the toggle is hidden, not merely inert, on those engines: in +`Settings > Playback` (`reportsPlaybackFailures()` gating the row) and in the +sources menu (`autoFailoverSupported`). Leaving it visible would let a user +switch on a feature that can never fire. The stored preference is untouched by +the change — switching back to a web player restores whatever was set. diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index ca8babad6..4f245dfcb 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -1 +1,2 @@ export * from './lib/collection'; +export * from './lib/multi-source'; diff --git a/libs/portal/shared/data-access/src/lib/multi-source/index.ts b/libs/portal/shared/data-access/src/lib/multi-source/index.ts new file mode 100644 index 000000000..5113803fc --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/index.ts @@ -0,0 +1,4 @@ +export * from './vod-multi-source.controller'; +export * from './vod-source-discovery.service'; +export * from './vod-source-metadata.util'; +export * from './vod-source-resolver.service'; diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.spec.ts new file mode 100644 index 000000000..e264891fd --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.spec.ts @@ -0,0 +1,281 @@ +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import { VodMultiSourceController } from './vod-multi-source.controller'; + +function source( + id: string, + overrides: Partial = {} +): VodSourceCandidate { + return { + id, + playlistId: id, + playlistName: id.toUpperCase(), + portalType: 'xtream', + contentId: 1, + rawTitle: 'Dune', + matchConfidence: 'exact', + ...overrides, + }; +} + +function controllerWith(...ids: string[]) { + const controller = new VodMultiSourceController(); + controller.setSources(ids.map((id) => source(id)), 'title-year'); + return controller; +} + +describe('VodMultiSourceController', () => { + describe('alternatives and the chip', () => { + it('reports no alternatives when the only source is the active one', () => { + const controller = controllerWith('a'); + controller.setActiveSource('a'); + + expect(controller.hasAlternatives()).toBe(false); + expect(controller.alternativeCount()).toBe(0); + }); + + it('excludes the active source from the alternatives', () => { + const controller = controllerWith('a', 'b', 'c'); + controller.setActiveSource('a'); + + expect(controller.alternatives().map((s) => s.id)).toEqual([ + 'b', + 'c', + ]); + }); + }); + + describe('failover cannot loop', () => { + it('never returns a source that was already tried', () => { + const controller = controllerWith('a', 'b', 'c'); + controller.markPlaying('a'); + + const first = controller.pickFailoverTarget(); + expect(first).not.toBeNull(); + controller.markPlaying(first!.id); + + const second = controller.pickFailoverTarget(); + expect(second!.id).not.toBe(first!.id); + expect(second!.id).not.toBe('a'); + }); + + it('exhausts after each source has been tried exactly once', () => { + const controller = controllerWith('a', 'b', 'c'); + controller.markPlaying('a'); + + const visited = ['a']; + for (let hop = 0; hop < 5; hop++) { + const next = controller.pickFailoverTarget(); + if (!next) { + break; + } + expect(visited).not.toContain(next.id); + visited.push(next.id); + controller.markPlaying(next.id); + } + + // Three sources, three visits, then an honest dead end. + expect(visited).toEqual(['a', 'b', 'c']); + expect(controller.pickFailoverTarget()).toBeNull(); + expect(controller.isExhausted()).toBe(true); + }); + + it('stays exhausted even if the user returns to an earlier source', () => { + const controller = controllerWith('a', 'b'); + controller.markPlaying('a'); + controller.markPlaying('b'); + controller.markPlaying('a'); + + // Returning by hand is allowed; automatic retrying is not. + expect(controller.pickFailoverTarget()).toBeNull(); + }); + + describe('selection is not an attempt', () => { + it('keeps the route source available after discovery selects it', () => { + // Discovery marks the route's row active the moment the page + // opens. Spending its turn there means a failure on some other + // source skips a healthy fallback. + const controller = controllerWith('a', 'b'); + controller.setActiveSource('a'); + + controller.markPlaying('b'); + + expect(controller.pickFailoverTarget()?.id).toBe('a'); + expect(controller.isExhausted()).toBe(false); + }); + + it('still exhausts once every source has actually played', () => { + const controller = controllerWith('a', 'b'); + controller.setActiveSource('a'); + + controller.markPlaying('b'); + controller.markPlaying('a'); + + expect(controller.isExhausted()).toBe(true); + }); + }); + }); + + describe('failover ranking', () => { + it('prefers a probed-reachable source', () => { + const controller = controllerWith('a', 'b', 'c'); + controller.setActiveSource('a'); + controller.setProbe('c', { status: 'ok' }); + + expect(controller.pickFailoverTarget()!.id).toBe('c'); + }); + + it('avoids a source probed as failing', () => { + const controller = controllerWith('a', 'b', 'c'); + controller.setActiveSource('a'); + controller.setProbe('b', { status: 'fail' }); + + expect(controller.pickFailoverTarget()!.id).toBe('c'); + }); + + it('treats an unknown probe as neutral, not as a failure', () => { + const controller = new VodMultiSourceController(); + controller.setSources( + [source('a'), source('b'), source('c')], + 'title-year' + ); + controller.setActiveSource('a'); + controller.setProbe('b', { status: 'unknown' }); + controller.setProbe('c', { status: 'fail' }); + + // 'unknown' means we could not check — it must not be ranked + // below a source we positively know is broken. + expect(controller.pickFailoverTarget()!.id).toBe('b'); + }); + + it('ignores guessed metadata when ranking', () => { + const controller = new VodMultiSourceController(); + controller.setSources( + [ + source('a'), + // Rich-looking, but every value is a filename guess. + source('guessy', { + quality: { value: '2160p', provenance: 'parsed' }, + codec: { value: 'HEVC', provenance: 'parsed' }, + audio: { value: 'Дубляж', provenance: 'parsed' }, + container: { value: 'mkv', provenance: 'parsed' }, + }), + // One verified fact beats four guesses. + source('factual', { + container: { value: 'mp4', provenance: 'api' }, + }), + ], + 'title-year' + ); + controller.setActiveSource('a'); + + expect(controller.pickFailoverTarget()!.id).toBe('factual'); + }); + + it('prefers an exact match over a fuzzy one, all else equal', () => { + const controller = new VodMultiSourceController(); + controller.setSources( + [ + source('a'), + source('fuzzy', { matchConfidence: 'fuzzy' }), + source('exact', { matchConfidence: 'exact' }), + ], + 'title-year' + ); + controller.setActiveSource('a'); + + expect(controller.pickFailoverTarget()!.id).toBe('exact'); + }); + + it('deprioritizes a source known to have failed before', () => { + const controller = new VodMultiSourceController(); + controller.setSources( + [ + source('a'), + source('stale', { + lastFailedAt: '2026-07-01T00:00:00.000Z', + }), + source('fresh'), + ], + 'title-year' + ); + controller.setActiveSource('a'); + + expect(controller.pickFailoverTarget()!.id).toBe('fresh'); + }); + }); + + describe('resume position', () => { + it('carries the last reported position', () => { + const controller = controllerWith('a'); + controller.setResumeSeconds(2538); + + expect(controller.getResumeSeconds()).toBe(2538); + }); + + it('ignores nonsensical positions rather than losing the good one', () => { + const controller = controllerWith('a'); + controller.setResumeSeconds(2538); + + controller.setResumeSeconds(NaN); + controller.setResumeSeconds(-5); + controller.setResumeSeconds(Infinity); + + expect(controller.getResumeSeconds()).toBe(2538); + }); + + it('seeds from the stored position before playback reports one', () => { + // Switching straight off the Resume button happens before any + // timeupdate, so without this the movie restarts. + const controller = controllerWith('a'); + controller.seedResumeSeconds(2538); + + expect(controller.getResumeSeconds()).toBe(2538); + }); + + it('never lets the stored position override the live one', () => { + const controller = controllerWith('a'); + controller.setResumeSeconds(2560); + + // The persisted value lags the live one by up to the save + // throttle; applying it would visibly rewind the switch. + controller.seedResumeSeconds(2538); + + expect(controller.getResumeSeconds()).toBe(2560); + }); + }); + + describe('source state', () => { + it('marks the pinned and active sources', () => { + const controller = controllerWith('a', 'b'); + controller.setActiveSource('a'); + controller.setPinnedSource('b'); + + const [first, second] = controller.sources(); + expect(first.isActive).toBe(true); + expect(first.isPinned).toBe(false); + expect(second.isActive).toBe(false); + expect(second.isPinned).toBe(true); + }); + + it('merges upgraded metadata without reordering the list', () => { + const controller = controllerWith('a', 'b'); + + controller.updateSource( + source('b', { + container: { value: 'mp4', provenance: 'api' }, + }) + ); + + expect(controller.sources().map((s) => s.id)).toEqual(['a', 'b']); + expect(controller.sources()[1].container).toEqual({ + value: 'mp4', + provenance: 'api', + }); + }); + + it('defaults every source to an idle probe', () => { + const controller = controllerWith('a'); + expect(controller.sources()[0].probe).toEqual({ status: 'idle' }); + }); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.ts new file mode 100644 index 000000000..85eec4d92 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.ts @@ -0,0 +1,217 @@ +import { computed, signal } from '@angular/core'; +import type { + VodSourceCandidate, + VodSourceDescriptor, + VodSourceMatchKind, + VodSourceProbeResult, +} from '@iptvnator/shared/interfaces'; +import { factualOnly } from './vod-source-metadata.util'; + +/** + * Per-movie multi-source session state. + * + * A plain class rather than a service: one instance belongs to one open movie, + * mirroring `StalkerVodPlaybackController`. Holding this in a root-provided + * service would leak `triedSourceIds` between films and quietly break the + * anti-loop guarantee. + * + * It owns state and decisions only — no I/O. Discovery, resolution, probing + * and persistence are injected by the host, which keeps this testable without + * TestBed. + */ +export class VodMultiSourceController { + private readonly _sources = signal([]); + private readonly _activeSourceId = signal(null); + private readonly _pinnedSourceId = signal(null); + private readonly _matchKind = signal('title-year'); + private readonly _probes = signal>({}); + + /** + * Sources already attempted in this session. + * + * The anti-loop guarantee: failover only ever considers sources absent + * from this set, so a three-source movie can fail over at most twice and + * then honestly gives up. Never cleared during a session — only a fresh + * movie (a new controller) resets it. + */ + private readonly tried = new Set(); + + /** Live playback position, carried across a source switch. */ + private resumeSeconds = 0; + + readonly matchKind = this._matchKind.asReadonly(); + readonly activeSourceId = this._activeSourceId.asReadonly(); + + /** Every known source, decorated with live session state. */ + readonly sources = computed(() => { + const activeId = this._activeSourceId(); + const pinnedId = this._pinnedSourceId(); + const probes = this._probes(); + + return this._sources().map((candidate) => ({ + ...candidate, + isActive: candidate.id === activeId, + isPinned: candidate.id === pinnedId, + isTried: this.tried.has(candidate.id), + probe: probes[candidate.id] ?? { status: 'idle' }, + })); + }); + + /** Sources other than the one playing — what the popover offers. */ + readonly alternatives = computed(() => + this.sources().filter((source) => !source.isActive) + ); + + /** The chip only appears when there is genuinely somewhere else to go. */ + readonly hasAlternatives = computed(() => this.alternatives().length > 0); + + readonly alternativeCount = computed(() => this.alternatives().length); + + setSources(sources: VodSourceCandidate[], matchKind: VodSourceMatchKind) { + this._sources.set(sources); + this._matchKind.set(matchKind); + } + + /** + * Merge an updated candidate (e.g. after resolution upgraded its metadata + * from `parsed` to `api`) without disturbing list order or identity. + */ + updateSource(updated: VodSourceCandidate) { + this._sources.update((sources) => + sources.map((source) => + source.id === updated.id ? updated : source + ) + ); + } + + /** + * Select a source WITHOUT spending its failover turn. + * + * Discovery selects the route's row the moment the page opens, and a pin + * or the picker can select an alternative before anything plays. None of + * those is an attempt: counting them means a later failure finds the + * route source "tried" and skips a perfectly healthy fallback — or calls + * the options exhausted while one is untouched. + */ + setActiveSource(sourceId: string | null) { + this._activeSourceId.set(sourceId); + } + + /** Select a source AND spend its turn — playback is actually starting. */ + markPlaying(sourceId: string) { + this.markTried(sourceId); + this.setActiveSource(sourceId); + } + + /** + * Record an attempt that never became active — e.g. a source whose URL + * could not be resolved at all. Without this, failover would keep + * re-picking a source that already proved unusable and spin. + */ + markTried(sourceId: string) { + this.tried.add(sourceId); + // `tried` is not a signal (it is per-session bookkeeping, not state + // the view derives from directly), so nudge the derived list. + this._sources.update((sources) => [...sources]); + } + + setPinnedSource(sourceId: string | null) { + this._pinnedSourceId.set(sourceId); + } + + setProbe(sourceId: string, result: VodSourceProbeResult) { + this._probes.update((probes) => ({ ...probes, [sourceId]: result })); + } + + /** Position updates arrive continuously from the player. */ + setResumeSeconds(seconds: number) { + if (Number.isFinite(seconds) && seconds >= 0) { + this.resumeSeconds = seconds; + } + } + + /** + * Seed from the PERSISTED position, so a switch made before playback ever + * started still resumes. + * + * Nothing reports a live position until the player emits its first + * timeupdate, so until then this is zero — and "Resume" through a pinned + * source would silently restart the film. Deliberately one-way: once a + * live position exists it wins, because the stored one lags it by up to + * the persistence throttle and applying it would visibly rewind. + */ + seedResumeSeconds(seconds: number) { + if (this.resumeSeconds <= 0) { + this.setResumeSeconds(seconds); + } + } + + getResumeSeconds(): number { + return this.resumeSeconds; + } + + findSource(sourceId: string): VodSourceCandidate | null { + return ( + this._sources().find((source) => source.id === sourceId) ?? null + ); + } + + /** + * The best source to fail over to, or null when the options are exhausted. + * + * Ranking deliberately ignores guessed metadata — a filename claiming 4K + * must not outrank a source we actually reached. Order: + * 1. never tried this session (a hard filter, not a preference) + * 2. pinned by the user for this movie + * 3. probed reachable + * 4. not known to have failed recently + * 5. richer FACTUAL metadata + * 6. exact title match over fuzzy + */ + pickFailoverTarget(): VodSourceDescriptor | null { + const untried = this.sources().filter( + (source) => !source.isActive && !source.isTried + ); + if (untried.length === 0) { + return null; + } + + return [...untried].sort((a, b) => score(b) - score(a))[0]; + } + + /** True once every source has been attempted — time for an honest error. */ + isExhausted(): boolean { + return this.pickFailoverTarget() === null; + } +} + +function score(source: VodSourceDescriptor): number { + let value = 0; + + // An explicit user choice outranks anything we inferred about the others. + if (source.isPinned) { + value += 1000; + } + + if (source.probe.status === 'ok') { + value += 100; + } + if (source.probe.status === 'fail') { + value -= 100; + } + if (source.lastFailedAt) { + value -= 50; + } + if (source.matchConfidence === 'exact') { + value += 10; + } + + // Only verified metadata counts toward "richer". A parsed guess adds + // nothing here by construction — factualOnly() cannot return one. + const facts = factualOnly(source); + value += [facts.quality, facts.codec, facts.container, facts.audio].filter( + Boolean + ).length; + + return value; +} diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts new file mode 100644 index 000000000..05f3fc68b --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts @@ -0,0 +1,70 @@ +import { VodSourceDiscoveryService } from './vod-source-discovery.service'; + +/** + * Discovery talks to a foreign playlist, and Xtream carries the account in the + * URL — so anything it fails on has to go through the redacting logger before + * it reaches the console. + */ +describe('VodSourceDiscoveryService — failure logging', () => { + const CREDENTIAL_URL = + 'http://portal.example.com:8080/player_api.php' + + '?username=alice&password=hunter2&action=get_vod_info'; + + let warnSpy: jest.SpyInstance; + let service: VodSourceDiscoveryService; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { + /* captured */ + }); + service = new VodSourceDiscoveryService(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + delete (window as { electron?: unknown }).electron; + }); + + it('never lets a portal error carry credentials to the console', async () => { + (window as { electron?: unknown }).electron = { + dbFindTitleSources: jest + .fn() + .mockRejectedValue( + new Error(`Request to ${CREDENTIAL_URL} failed`) + ), + }; + + await expect( + service.discover({ + title: 'Dune', + currentPlaylistId: 'playlist-1', + }) + ).resolves.toEqual({ sources: [], matchKind: 'title-year' }); + + expect(warnSpy).toHaveBeenCalled(); + const logged = loggedText(); + expect(logged).not.toContain('hunter2'); + expect(logged).not.toContain('alice'); + // Still useful: the failure is reported, only the account is not. + expect(logged).toContain('VOD source discovery failed'); + }); + + /** + * What a console would actually show. + * + * `JSON.stringify` on an Error yields `{}` — its message and stack are + * non-enumerable — so stringifying the call list would hide a raw error's + * credentials and quietly pass whatever this asserts. + */ + function loggedText(): string { + return warnSpy.mock.calls + .flat() + .map((arg) => { + if (arg instanceof Error) { + return `${arg.message}\n${arg.stack ?? ''}`; + } + return typeof arg === 'string' ? arg : JSON.stringify(arg); + }) + .join('\n'); + } +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts new file mode 100644 index 000000000..78fd6384f --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@angular/core'; +import type { + VodSourceCandidate, + VodSourceCandidateRow, + VodSourceMatchKind, +} from '@iptvnator/shared/interfaces'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { parseTitleMetadata } from './vod-source-metadata.util'; + +/** + * Finds the same movie in the user's other playlists. + * + * Electron-only, and deliberately lazy: it runs when a detail page opens, not + * as a background index. Discovery returns rows the `content` table can prove + * — title, ids, poster — and NOTHING about container, codec or audio, because + * those columns do not exist. Everything a row shows at this stage is either a + * hard id or a guess parsed off the title and marked as one. + */ + +export interface VodSourceDiscoveryRequest { + title: string; + 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 { + sources: VodSourceCandidate[]; + /** + * How the set was matched. v1 matches on normalized title + year — a TMDB + * id is not stored in the `content` table, so it cannot drive the SQL. + */ + matchKind: VodSourceMatchKind; +} + +@Injectable({ providedIn: 'root' }) +export class VodSourceDiscoveryService { + private readonly logger = createLogger('VodSourceDiscovery'); + + get isAvailable(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.electron?.dbFindTitleSources === 'function' + ); + } + + async discover( + request: VodSourceDiscoveryRequest + ): Promise { + const empty: VodSourceDiscoveryResult = { + sources: [], + matchKind: 'title-year', + }; + + if (!this.isAvailable || !request.title?.trim()) { + return empty; + } + + try { + const rows = await window.electron.dbFindTitleSources({ + title: request.title, + year: request.year ?? null, + excludePlaylistId: request.currentPlaylistId, + keepContentId: request.keepContentId ?? null, + }); + + return { + sources: (rows ?? []).map(toCandidate), + matchKind: 'title-year', + }; + } catch (error) { + this.logger.warn('VOD source discovery failed:', error); + return empty; + } + } +} + +function toCandidate(row: VodSourceCandidateRow): VodSourceCandidate { + // Everything inferable at this point comes from the title string, so it is + // all `provenance: 'parsed'`. Facts arrive later, from `get_vod_info`. + const parsed = parseTitleMetadata(row.title); + + return { + id: `${row.playlistId}:xtream:${row.xtreamId}`, + playlistId: row.playlistId, + playlistName: row.playlistName, + portalType: 'xtream', + contentId: row.xtreamId, + rawTitle: row.title, + matchConfidence: row.matchConfidence, + year: row.year, + posterUrl: row.posterUrl, + quality: parsed.quality, + codec: parsed.codec, + audio: parsed.audio, + }; +} diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts new file mode 100644 index 000000000..c6d3a6d86 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts @@ -0,0 +1,373 @@ +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import { + applyApiMetadata, + audioDiffersFactually, + factualOnly, + parseTitleMetadata, +} from './vod-source-metadata.util'; + +function candidate( + overrides: Partial = {} +): VodSourceCandidate { + return { + id: 'p1:xtream:1', + playlistId: 'p1', + playlistName: 'SharkTV', + portalType: 'xtream', + contentId: 1, + rawTitle: 'Dune', + matchConfidence: 'exact', + ...overrides, + }; +} + +describe('parseTitleMetadata', () => { + it('returns nothing for an empty title rather than inventing defaults', () => { + expect(parseTitleMetadata('')).toEqual({}); + expect(parseTitleMetadata(null)).toEqual({}); + expect(parseTitleMetadata(undefined)).toEqual({}); + }); + + it('leaves fields undefined when the title says nothing about them', () => { + const parsed = parseTitleMetadata('Dune Part Two'); + + expect(parsed.quality).toBeUndefined(); + expect(parsed.codec).toBeUndefined(); + expect(parsed.audio).toBeUndefined(); + expect(parsed.hdr).toBeUndefined(); + }); + + it('marks every inferred value as a guess', () => { + const parsed = parseTitleMetadata('Dune 2160p HEVC Дубляж HDR'); + + expect(parsed.quality).toEqual({ + value: '2160p', + provenance: 'parsed', + }); + expect(parsed.codec).toEqual({ value: 'HEVC', provenance: 'parsed' }); + expect(parsed.audio).toEqual({ value: 'Дубляж', provenance: 'parsed' }); + expect(parsed.hdr).toEqual({ value: 'HDR', provenance: 'parsed' }); + }); + + it.each([ + ['Dune 4K', '2160p'], + ['Dune UHD', '2160p'], + ['Dune 1080i', '1080p'], + ['Dune 720P', '720p'], + ])('normalizes quality tag %s', (title, expected) => { + expect(parseTitleMetadata(title).quality?.value).toBe(expected); + }); + + it.each([ + ['Dune x265', 'HEVC'], + ['Dune H.265', 'HEVC'], + ['Dune x264', 'H.264'], + ['Dune H264', 'H.264'], + ['Dune AV1', 'AV1'], + ])('normalizes codec tag %s', (title, expected) => { + expect(parseTitleMetadata(title).codec?.value).toBe(expected); + }); + + it('prefers the more specific quality when several could match', () => { + // "4K" and "1080p" both appear; the higher, more specific tag wins. + expect(parseTitleMetadata('Dune 4K remux from 1080p').quality?.value) + .toBe('2160p'); + }); + + it('does not match quality tags embedded in unrelated words', () => { + expect(parseTitleMetadata('Blade Runner 2049').quality).toBeUndefined(); + }); +}); + +describe('applyApiMetadata', () => { + it('overrides a guess with a provider-stated fact', () => { + const withGuess = candidate({ + container: { value: 'mkv', provenance: 'parsed' }, + }); + + const result = applyApiMetadata(withGuess, { + containerExtension: 'mp4', + }); + + expect(result.container).toEqual({ value: 'mp4', provenance: 'api' }); + }); + + it('keeps an existing guess when the provider states nothing', () => { + const withGuess = candidate({ + quality: { value: '1080p', provenance: 'parsed' }, + }); + + const result = applyApiMetadata(withGuess, { + containerExtension: null, + height: null, + }); + + // Erasing a marked guess in favour of nothing helps no one. + expect(result.quality).toEqual({ + value: '1080p', + provenance: 'parsed', + }); + }); + + it('derives quality from a standard frame height as a fact', () => { + expect(applyApiMetadata(candidate(), { height: 2160 }).quality).toEqual({ + value: '2160p', + provenance: 'api', + }); + }); + + it('prefers width, which cropping does not distort', () => { + // A 2.39:1 1080p master is 1920x800 — the width still says 1080p. + expect( + applyApiMetadata(candidate(), { width: 1920, height: 800 }).quality + ).toEqual({ value: '1080p', provenance: 'api' }); + }); + + it('does not call every small stream 480p', () => { + // The old rule labelled ANY width under 900 as 480p, published with + // `api` provenance — so a 640x360 stream stated 480p as a fact. + expect( + applyApiMetadata(candidate(), { width: 640, height: 360 }).quality + ).toEqual({ value: '360p', provenance: 'api' }); + expect( + applyApiMetadata(candidate(), { width: 854, height: 480 }).quality + ).toEqual({ value: '480p', provenance: 'api' }); + }); + + it('reads the height when one width covers two formats', () => { + // 720 wide is NTSC 480p or PAL 576p; only the height separates them, + // and without one there is no honest answer to give. + expect( + applyApiMetadata(candidate(), { width: 720, height: 576 }).quality + ).toEqual({ value: '576p', provenance: 'api' }); + expect( + applyApiMetadata(candidate(), { width: 720, height: 480 }).quality + ).toEqual({ value: '480p', provenance: 'api' }); + expect( + applyApiMetadata(candidate(), { width: 720 }).quality + ).toBeUndefined(); + }); + + it('tells 540p apart from 576p instead of bucketing both', () => { + // 960x540 and 1024x576 are two standard formats inside what used to be + // one 900-1199 range, so every 540p encode was published as "576p" + // with `api` provenance — a measurement its own pixels contradict. + expect( + applyApiMetadata(candidate(), { width: 960, height: 540 }).quality + ).toEqual({ value: '540p', provenance: 'api' }); + expect( + applyApiMetadata(candidate(), { width: 1024, height: 576 }).quality + ).toEqual({ value: '576p', provenance: 'api' }); + // Letterboxing only ever removes lines, so the width still names it. + expect( + applyApiMetadata(candidate(), { width: 1024, height: 432 }).quality + ).toEqual({ value: '576p', provenance: 'api' }); + }); + + it.each([ + ['eng', 'en'], + ['en', 'en'], + ['en-US', 'en'], + // Both ISO 639-2 spellings of German have to land together. + ['ger', 'de'], + ['deu', 'de'], + // No two-letter code exists for these, so three is canonical. + ['fil', 'fil'], + ])('canonicalizes the language tag %s', (raw, expected) => { + expect( + applyApiMetadata(candidate(), { audioLanguage: raw }).audioLanguage + ).toEqual({ value: expected, provenance: 'api' }); + }); + + it.each([ + // ffprobe's marker for "we do not know". + ['und'], + ['Russian'], + ['en_US'], + [''], + ])('states no language for %s', (raw) => { + expect( + applyApiMetadata(candidate(), { audioLanguage: raw }).audioLanguage + ).toBeUndefined(); + }); + + it('refuses a wide frame taller than the format its width names', () => { + // 1440x1080 is anamorphic 1080 and 1600x900 is 900p. Both fall in the + // 1200-1699 band, so both were published as "720p" — with `api` + // provenance, which is the one that means the provider said so. + expect( + applyApiMetadata(candidate(), { width: 1440, height: 1080 }).quality + ).toBeUndefined(); + expect( + applyApiMetadata(candidate(), { width: 1600, height: 900 }).quality + ).toBeUndefined(); + }); + + it('still names a letterboxed wide master by its width', () => { + // Cropping only removes lines, so a short frame is this format. + expect( + applyApiMetadata(candidate(), { width: 1920, height: 800 }).quality + ).toEqual({ value: '1080p', provenance: 'api' }); + expect( + applyApiMetadata(candidate(), { width: 1920, height: 1080 }).quality + ).toEqual({ value: '1080p', provenance: 'api' }); + expect( + applyApiMetadata(candidate(), { width: 3840, height: 2160 }).quality + ).toEqual({ value: '2160p', provenance: 'api' }); + }); + + it('gives no quality for a width between the known formats', () => { + // 1100 wide is no standard shape. The old range answered "576p" for + // it; an absent tag and a check chip is the honest reply. + expect( + applyApiMetadata(candidate(), { width: 1100, height: 620 }).quality + ).toBeUndefined(); + }); + + it('rejects a height the width cannot account for', () => { + // 640x480 is 4:3 VGA, not a letterboxed 360p — the width alone would + // have called it 360p and published that as a measurement. + expect( + applyApiMetadata(candidate(), { width: 640, height: 480 }).quality + ).toBeUndefined(); + }); + + it('still trusts the width when the frame is cropped', () => { + // Cropping only removes lines, so a short frame at 854 wide is a + // letterboxed 480p master and the width still names it. + expect( + applyApiMetadata(candidate(), { width: 854, height: 360 }).quality + ).toEqual({ value: '480p', provenance: 'api' }); + }); + + it('emits nothing for a shape that is not a known format', () => { + // 800 wide is neither 854x480 nor anything else in the table, and + // 800x600 is certainly not 480 lines high. + expect( + applyApiMetadata(candidate(), { width: 800, height: 600 }).quality + ).toBeUndefined(); + expect( + applyApiMetadata(candidate(), { width: 800, height: 450 }).quality + ).toBeUndefined(); + expect( + applyApiMetadata(candidate(), { width: 320, height: 240 }).quality + ).toBeUndefined(); + }); + + it('emits no quality for an ambiguous cropped height', () => { + // 800 lines alone could be a cropped 1080p or a 1280x800 encode. + // Guessing here would publish a wrong value labelled as a fact. + expect(applyApiMetadata(candidate(), { height: 800 }).quality) + .toBeUndefined(); + }); + + it('ignores a nonsensical dimension instead of emitting a bogus fact', () => { + expect(applyApiMetadata(candidate(), { height: 0 }).quality) + .toBeUndefined(); + expect(applyApiMetadata(candidate(), { height: -5 }).quality) + .toBeUndefined(); + expect(applyApiMetadata(candidate(), { height: NaN }).quality) + .toBeUndefined(); + expect(applyApiMetadata(candidate(), { width: 0, height: 1080 }).quality) + .toEqual({ value: '1080p', provenance: 'api' }); + }); + + it('normalizes provider codec names', () => { + expect(applyApiMetadata(candidate(), { videoCodec: 'hevc' }).codec) + .toEqual({ value: 'HEVC', provenance: 'api' }); + expect(applyApiMetadata(candidate(), { videoCodec: 'h264' }).codec) + .toEqual({ value: 'H.264', provenance: 'api' }); + }); + + it('does not mutate the input candidate', () => { + const original = candidate(); + applyApiMetadata(original, { containerExtension: 'mp4' }); + expect(original.container).toBeUndefined(); + }); +}); + +describe('factualOnly', () => { + it('hides guessed values from ranking decisions', () => { + const mixed = candidate({ + quality: { value: '2160p', provenance: 'parsed' }, + container: { value: 'mp4', provenance: 'api' }, + }); + + const facts = factualOnly(mixed); + + // The whole point: a filename claiming 4K must not outrank a source + // we actually verified. + expect(facts.quality).toBeUndefined(); + expect(facts.container).toEqual({ value: 'mp4', provenance: 'api' }); + }); + + it('keeps probe-sourced values', () => { + const probed = candidate({ + codec: { value: 'HEVC', provenance: 'probe' }, + }); + + expect(factualOnly(probed).codec).toEqual({ + value: 'HEVC', + provenance: 'probe', + }); + }); +}); + +describe('audioDiffersFactually', () => { + it('warns only when both sides are known facts that differ', () => { + const from = candidate({ + audioLanguage: { value: 'rus', provenance: 'api' }, + }); + const to = candidate({ + audioLanguage: { value: 'eng', provenance: 'api' }, + }); + + expect(audioDiffersFactually(from, to)).toBe(true); + }); + + it('stays silent when either side is only a guess', () => { + const from = candidate({ + audioLanguage: { value: 'rus', provenance: 'api' }, + }); + const guessed = candidate({ + audioLanguage: { value: 'eng', provenance: 'parsed' }, + }); + + expect(audioDiffersFactually(from, guessed)).toBe(false); + }); + + it('stays silent when either side is unknown', () => { + const known = candidate({ + audioLanguage: { value: 'rus', provenance: 'api' }, + }); + + expect(audioDiffersFactually(known, candidate())).toBe(false); + expect(audioDiffersFactually(candidate(), known)).toBe(false); + expect(audioDiffersFactually(null, known)).toBe(false); + }); + + it('stays silent when two panels spell one language differently', () => { + // ffprobe says `eng`, a panel hoists `en`, and both mean English. A + // literal comparison turns that into a dub change on every switch + // between those two portals. + const from = candidate( + applyApiMetadata(candidate(), { audioLanguage: 'eng' }) + ); + const to = candidate( + applyApiMetadata(candidate(), { audioLanguage: 'en-US' }) + ); + + expect(audioDiffersFactually(from, to)).toBe(false); + }); + + it('stays silent when both sides state the same track', () => { + const from = candidate({ + audioLanguage: { value: 'rus', provenance: 'api' }, + }); + const to = candidate({ + audioLanguage: { value: 'rus', provenance: 'api' }, + }); + + expect(audioDiffersFactually(from, to)).toBe(false); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts new file mode 100644 index 000000000..f3ec6ed5e --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts @@ -0,0 +1,410 @@ +import type { + VodSourceCandidate, + VodSourceField, +} from '@iptvnator/shared/interfaces'; + +/** + * Metadata extraction for VOD multi-source rows. + * + * The governing rule: a value inferred from a filename is a GUESS and must + * never be presented, sorted, or acted on as though the provider had stated + * it. Everything here returns `provenance: 'parsed'`; facts come from + * `applyApiMetadata` below, and reachability from the probe. + * + * Empty beats wrong — when a pattern does not match, the field stays + * `undefined` and the row simply shows no tag, plus a "check" affordance. + */ + +/** + * Unicode-aware word boundary. + * + * JavaScript's `\b` is ASCII-only, so `/\bдубляж\b/` never matches — which + * would silently drop the dub tag on exactly the Cyrillic catalogues where it + * matters most. Lookarounds over `\p{L}\p{N}` give a real boundary. + */ +function bounded(source: string): RegExp { + return new RegExp(`(? = [ + [bounded('2160p|4k|uhd'), '2160p'], + [bounded('1440p'), '1440p'], + [bounded('1080[pi]'), '1080p'], + [bounded('720[pi]'), '720p'], + [bounded('576[pi]'), '576p'], + [bounded('480[pi]'), '480p'], +]; + +const CODEC_PATTERNS: ReadonlyArray<[RegExp, string]> = [ + [bounded('hevc|h\\.?265|x265'), 'HEVC'], + [bounded('avc|h\\.?264|x264'), 'H.264'], + [bounded('av1'), 'AV1'], + [bounded('mpeg-?2'), 'MPEG-2'], +]; + +/** + * Dub/voice-over markers. The single most valuable field to a user and the + * least likely to be stated by any provider, so it is worth guessing — but + * only ever as a guess. + */ +const AUDIO_PATTERNS: ReadonlyArray<[RegExp, string]> = [ + [bounded('дубляж|dub'), 'Дубляж'], + [bounded('mvo'), 'MVO'], + [bounded('dvo'), 'DVO'], + [bounded('avo'), 'AVO'], + [bounded('субтитры|subs|sub'), 'Sub'], + [bounded('eng|english'), 'Eng'], + [bounded('rus|russian|рус'), 'Rus'], + [bounded('ukr|ukrainian|укр'), 'Ukr'], +]; + +const HDR_PATTERNS: ReadonlyArray<[RegExp, string]> = [ + [bounded('dolby\\s?vision|dv'), 'Dolby Vision'], + [bounded('hdr10\\+'), 'HDR10+'], + [bounded('hdr'), 'HDR'], +]; + +function matchFirst( + raw: string, + patterns: ReadonlyArray<[RegExp, string]> +): VodSourceField | undefined { + for (const [pattern, value] of patterns) { + if (pattern.test(raw)) { + return { value, provenance: 'parsed' }; + } + } + return undefined; +} + +export interface ParsedTitleMetadata { + quality?: VodSourceField; + codec?: VodSourceField; + audio?: VodSourceField; + hdr?: VodSourceField; +} + +/** + * Infer what the title string is willing to admit. Everything returned is a + * guess, tagged as such. + */ +export function parseTitleMetadata( + rawTitle: string | null | undefined +): ParsedTitleMetadata { + if (!rawTitle) { + return {}; + } + + return { + quality: matchFirst(rawTitle, QUALITY_PATTERNS), + codec: matchFirst(rawTitle, CODEC_PATTERNS), + audio: matchFirst(rawTitle, AUDIO_PATTERNS), + hdr: matchFirst(rawTitle, HDR_PATTERNS), + }; +} + +/** Metadata a provider actually stated, from `get_vod_info`. */ +export interface ProviderVodMetadata { + containerExtension?: string | null; + videoCodec?: string | null; + audioCodec?: string | null; + /** The audio track's spoken language, when the provider tags one. */ + audioLanguage?: string | null; + /** Pixel width from the provider's own video stream info. */ + width?: number | null; + /** Pixel height from the provider's own video stream info. */ + height?: number | null; +} + +/** + * Overlay provider-stated facts on top of parsed guesses. + * + * A fact always wins: once the portal says the container is `mkv`, the guess + * is discarded rather than shown alongside it. A missing fact never erases an + * existing guess — that would trade a marked guess for nothing. + */ +export function applyApiMetadata( + candidate: VodSourceCandidate, + api: ProviderVodMetadata +): VodSourceCandidate { + const next: VodSourceCandidate = { ...candidate }; + + const container = cleanString(api.containerExtension); + if (container) { + next.container = { value: container, provenance: 'api' }; + } + + const codec = normalizeCodecName(api.videoCodec); + if (codec) { + next.codec = { value: codec, provenance: 'api' }; + } + + const audio = normalizeCodecName(api.audioCodec); + if (audio) { + next.audio = { value: audio, provenance: 'api' }; + } + + const language = canonicalLanguage(api.audioLanguage); + if (language) { + next.audioLanguage = { value: language, provenance: 'api' }; + } + + const quality = qualityFromDimensions(api.width, api.height); + if (quality) { + next.quality = { value: quality, provenance: 'api' }; + } + + return next; +} + +/** + * The only sanctioned way to read metadata for ranking or automatic choices. + * + * Guessed values are structurally unreachable from here, which is what stops + * "~2160p parsed off a filename" from outranking a source we actually + * verified. Presentation code reads the fields directly; decision code must + * come through this. + */ +export function factualOnly( + candidate: VodSourceCandidate +): Pick { + const keep = (field?: VodSourceField) => + field && field.provenance !== 'parsed' ? field : undefined; + + return { + quality: keep(candidate.quality), + codec: keep(candidate.codec), + container: keep(candidate.container), + audio: keep(candidate.audio), + }; +} + +/** + * True when both sides name a spoken LANGUAGE as fact and those differ. + * + * Reads `audioLanguage`, never `audio`. The latter holds a codec whenever the + * fact came from the API, and a codec cannot answer this question: AAC and AC3 + * routinely carry the same dub, while two AC3 tracks can carry different ones. + * Warning on a codec change would fire on identical-language re-encodes and + * stay silent on the actual dub changes it exists for — and a warning that is + * wrong in both directions is worse than no warning, because people learn to + * ignore it. + * + * Deliberately conservative beyond that: two guesses, or a guess against a + * fact, are not enough. It stays silent when we do not know, which — since few + * panels tag a language at all — is the common case. + */ +export function audioDiffersFactually( + from: VodSourceCandidate | null | undefined, + to: VodSourceCandidate | null | undefined +): boolean { + const a = from && keepFactual(from.audioLanguage)?.value; + const b = to && keepFactual(to.audioLanguage)?.value; + return Boolean(a && b && a !== b); +} + +function keepFactual(field?: VodSourceField): VodSourceField | undefined { + return field && field.provenance !== 'parsed' ? field : undefined; +} + +/** + * A language tag reduced to the one form two panels can be compared on. + * + * The same spoken language is written several ways in the wild — ffprobe emits + * ISO 639-2 (`eng`, and `ger` or `deu` for German), panels hoist 639-1 (`en`), + * and either may carry a region (`en-US`). Comparing those literally makes a + * dub "change" out of two spellings of English. + * + * `Intl.Locale` does the canonicalizing: 639-2 collapses to 639-1 where one + * exists, both German forms land on `de`, and regions drop away. `und` — + * ffprobe's marker for undetermined — canonicalizes to nothing, which is + * exactly right: it means the provider does not know either. + * + * Anything that survives with more than three characters is not a language + * code (`Russian` parses as the subtag `russian`), so the comparison is + * declined rather than guessed at. + */ +function canonicalLanguage(raw: string | null | undefined): string | null { + const value = cleanString(raw); + if (!value || !LocaleCtor) { + return null; + } + + try { + const language = new LocaleCtor(value).language; + return language && language.length <= 3 ? language : null; + } catch { + // Not a well-formed tag at all; saying nothing beats comparing junk. + return null; + } +} + +/** + * `Intl.Locale` is ES2020 and this workspace compiles against the es2018 lib, + * so it is reached through a narrow shim rather than by moving the target. + * + * Absent — which no supported runtime actually is — the comparison is declined + * rather than half-canonicalized, since `eng` measured against `en` by a + * partial fold is exactly the false warning this function exists to prevent. + */ +const LocaleCtor = ( + Intl as unknown as { + Locale?: new (tag: string) => { language?: string }; + } +).Locale; + +function cleanString(raw: string | null | undefined): string | null { + const trimmed = typeof raw === 'string' ? raw.trim() : ''; + return trimmed === '' ? null : trimmed; +} + +/** Map ffmpeg-style codec names onto the labels users recognize. */ +function normalizeCodecName(raw: string | null | undefined): string | null { + const value = cleanString(raw); + if (!value) { + return null; + } + + const lower = value.toLowerCase(); + if (lower === 'hevc' || lower === 'h265') { + return 'HEVC'; + } + if (lower === 'h264' || lower === 'avc') { + return 'H.264'; + } + return value; +} + +/** Standard frame heights, for recognizing an uncropped master. */ +const STANDARD_HEIGHTS: ReadonlyArray<[number, string]> = [ + [2160, '2160p'], + [1440, '1440p'], + [1080, '1080p'], + [720, '720p'], + [576, '576p'], + [480, '480p'], +]; + +function isPositiveNumber(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +/** + * Derive the quality label from real pixel dimensions. + * + * Width is authoritative because letterboxed masters are cropped vertically: + * a 2.39:1 "1080p" film is 1920×800, and 800 alone is indistinguishable from + * a 1280×800 720p-class encode. Bucketing that by height would emit "720p" + * labelled as a FACT — precisely the kind of confident wrongness this feature + * exists to avoid. + * + * With no width, a height is only trusted when it is (near) a standard frame + * height. Anything else returns null, so the row shows no tag and offers a + * check instead. Empty beats wrong. + */ +/** + * The sub-HD formats, matched rather than bucketed. + * + * Bucketing only survives above 1200, where the standard widths are far apart. + * Below that they are not: 960×540 and 1024×576 are two different formats + * inside what used to be one 900–1199 range, so every 540p encode was published + * as "576p" with `api` provenance — a measurement the pixels contradict. Lower + * still, 800×600 and 800×450 are neither 480p nor each other, and 720 is NTSC + * 480p or PAL 576p depending only on the height. + * + * Anything that is not one of these shapes gets no tag at all, because a guess + * here would be read as a measurement. + */ +/** + * HD and up, as `[minimum width, frame height, label]`. + * + * Ranges are honest here — the standard widths really are far apart — but the + * height still has to agree, for the same reason it does below: a frame TALLER + * than the format is a different shape, not a crop of it. + */ +const WIDE_FORMATS: ReadonlyArray<[number, number, string]> = [ + [3400, 2160, '2160p'], + [2400, 1440, '1440p'], + [1700, 1080, '1080p'], + [1200, 720, '720p'], +]; + +const KNOWN_FORMATS: ReadonlyArray<[number, number, string]> = [ + [1024, 576, '576p'], + [960, 540, '540p'], + [854, 480, '480p'], + [720, 576, '576p'], + [720, 480, '480p'], + [640, 360, '360p'], +]; + +function within5Percent(value: number, reference: number): boolean { + return Math.abs(value - reference) / reference <= 0.05; +} + +function knownFormatQuality( + width: number, + height: number | null | undefined +): string | null { + const byWidth = KNOWN_FORMATS.filter(([reference]) => + within5Percent(width, reference) + ); + if (byWidth.length === 0) { + return null; + } + + if (byWidth.length > 1) { + // One width, two formats (720): only the height separates them. + if (!isPositiveNumber(height)) { + return null; + } + const exact = byWidth.find(([, reference]) => + within5Percent(height, reference) + ); + return exact ? exact[2] : null; + } + + // A single candidate still has to survive the height, when one is known. + // Cropping only ever REMOVES lines, so a shorter frame is a letterboxed + // master of this format and the width still names it — but a TALLER one + // (640x480 against 640x360) is a different shape entirely, and labelling + // it would state a measurement the numbers contradict. + const [, formatHeight, label] = byWidth[0]; + if (isPositiveNumber(height) && height > formatHeight * 1.05) { + return null; + } + return label; +} + +function qualityFromDimensions( + width: number | null | undefined, + height: number | null | undefined +): string | null { + if (isPositiveNumber(width)) { + const wide = WIDE_FORMATS.find(([minWidth]) => width >= minWidth); + if (wide) { + const [, formatHeight, label] = wide; + // The same rule the matched formats use. Cropping only ever + // REMOVES lines, so a shorter frame is a letterboxed master of this + // format and the width still names it — but a TALLER one is a + // different shape: 1440x1080 is anamorphic 1080 and 1600x900 is + // 900p, and calling either "720p" states a measurement its own + // pixels contradict, under the provenance that means the provider + // said so. + return isPositiveNumber(height) && height > formatHeight * 1.05 + ? null + : label; + } + + return knownFormatQuality(width, height); + } + + if (!isPositiveNumber(height)) { + return null; + } + + const standard = STANDARD_HEIGHTS.find( + ([reference]) => Math.abs(height - reference) / reference <= 0.05 + ); + return standard ? standard[1] : null; +} diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver-stream-info.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver-stream-info.spec.ts new file mode 100644 index 000000000..d791b81b6 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver-stream-info.spec.ts @@ -0,0 +1,66 @@ +import { readStreamInfo } from './vod-source-resolver.service'; + +/** + * Xtream panels disagree about the shape of `info.video` / `info.audio`. + * + * The declared shape is a string array and that is what the mock server and + * many panels send; others send the ffprobe object. Reading only one of them + * loses the provider's codec on every response of the other kind. + * + * What the array shape carries is a CODEC and nothing else — no language — so + * it can never feed the "dub may differ" warning. That is the honest outcome + * rather than a gap: a codec cannot tell one dub from another. + */ +describe('readStreamInfo', () => { + it('reads the declared array shape', () => { + expect(readStreamInfo(['H.264'])).toEqual({ codec_name: 'H.264' }); + expect(readStreamInfo(['AAC'])).toEqual({ codec_name: 'AAC' }); + }); + + it('reads the ffprobe object shape, dimensions included', () => { + expect( + readStreamInfo({ codec_name: 'hevc', width: 3840, height: 2160 }) + ).toEqual({ codec_name: 'hevc', width: 3840, height: 2160 }); + }); + + it('skips blank entries rather than stating an empty codec', () => { + expect(readStreamInfo(['', ' ', 'H.264'])).toEqual({ + codec_name: 'H.264', + }); + }); + + it('trims, since the value is published as a fact', () => { + expect(readStreamInfo([' H.264 '])).toEqual({ codec_name: 'H.264' }); + }); + + it('finds no language in the declared array shape', () => { + // The shape the repo's own mock sends. It states a codec, so the dub + // warning stays silent on every source that arrives this way — which + // is most of them, and is correct: AAC says nothing about which dub + // this is. + const audio = readStreamInfo(['AAC']); + + expect(audio?.language).toBeUndefined(); + expect(audio?.tags?.language).toBeUndefined(); + }); + + it('reads the language out of ffprobe tags when a panel sends them', () => { + expect( + readStreamInfo({ codec_name: 'ac3', tags: { language: 'rus' } }) + ?.tags?.language + ).toBe('rus'); + // Some panels hoist it out of `tags`. + expect( + readStreamInfo({ codec_name: 'ac3', language: 'eng' })?.language + ).toBe('eng'); + }); + + it('says nothing when the provider said nothing', () => { + // Empty beats wrong: an absent codec must not become a stated one. + expect(readStreamInfo([])).toBeUndefined(); + expect(readStreamInfo(['', ' '])).toBeUndefined(); + expect(readStreamInfo(undefined)).toBeUndefined(); + expect(readStreamInfo(null)).toBeUndefined(); + expect(readStreamInfo('H.264')).toBeUndefined(); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts new file mode 100644 index 000000000..240439d73 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts @@ -0,0 +1,279 @@ +import { inject, Injectable, Injector } from '@angular/core'; +import { + XTREAM_DATA_SOURCE, + XtreamApiService, + XtreamUrlService, + type IXtreamDataSource, + type XtreamCredentials, +} from '@iptvnator/portal/xtream/data-access'; +import type { + ResolvedPortalPlayback, + VodSourceCandidate, +} from '@iptvnator/shared/interfaces'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { + applyApiMetadata, + type ProviderVodMetadata, +} from './vod-source-metadata.util'; + +/** + * Turns a discovered source into something playable. + * + * This exists because discovery alone is not enough: the `content` table has + * no `container_extension` column, and `constructVodUrl` returns '' without + * one. Every alternative therefore costs one live `get_vod_info` against a + * FOREIGN playlist's credentials — which is exactly why resolution is lazy and + * never fanned out on page load. + * + * That same call is also the first moment real facts about a source exist, so + * resolution doubles as the metadata upgrade from `parsed` to `api`. + */ + +export interface ResolvedVodSource { + playback: ResolvedPortalPlayback; + /** The candidate with provider-stated facts merged in. */ + candidate: VodSourceCandidate; +} + +@Injectable({ providedIn: 'root' }) +export class VodSourceResolverService { + // Redacting: a failed `get_vod_info` carries the foreign playlist's + // URL, and Xtream puts the username and password in that query string. + private readonly logger = createLogger('VodSourceResolver'); + + /** + * The Xtream chain is resolved LAZILY, never at construction. + * + * This service is root-provided but its dependencies are route-scoped + * (`XTREAM_DATA_SOURCE` comes from `provideXtreamDataSource()`, and + * `XtreamApiService` pulls in `DataService`). Injecting them eagerly would + * make merely *providing* multi-source throw outside a fully configured + * Xtream route. Resolution only ever happens on an explicit user action — + * play, pin, or check — so deferring costs nothing and cannot explode. + */ + private readonly injector = inject(Injector); + + private getXtreamDeps(): { + dataSource: IXtreamDataSource; + api: XtreamApiService; + urlService: XtreamUrlService; + } | null { + const dataSource = this.injector.get(XTREAM_DATA_SOURCE, null, { + optional: true, + }); + if (!dataSource) { + return null; + } + + return { + dataSource, + api: this.injector.get(XtreamApiService), + urlService: this.injector.get(XtreamUrlService), + }; + } + + /** + * Containers are immutable per (playlist, stream) and cost a network round + * trip to learn, so the second visit to a movie resolves instantly. + */ + private readonly containerCache = new Map(); + + async resolve( + candidate: VodSourceCandidate, + options: { startTime?: number } = {} + ): Promise { + if (candidate.portalType !== 'xtream') { + // v1 discovers Xtream only; the guard keeps a future Stalker + // candidate from silently resolving to a broken Xtream URL. + return null; + } + + const deps = this.getXtreamDeps(); + if (!deps) { + return null; + } + + const playlist = await this.loadPlaylist( + deps.dataSource, + candidate.playlistId + ); + if (!playlist) { + return null; + } + + const credentials: XtreamCredentials = { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + allowedOutputFormats: playlist.allowedOutputFormats, + }; + + const details = await this.loadVodDetails( + deps.api, + credentials, + candidate + ); + if (!details) { + return null; + } + + const streamUrl = deps.urlService.constructVodUrl( + credentials, + details.vodDetails + ); + if (!streamUrl) { + return null; + } + + return { + playback: { + streamUrl, + title: candidate.rawTitle, + thumbnail: candidate.posterUrl ?? null, + isLive: false, + startTime: options.startTime, + contentInfo: { + // The NEW source owns the position from here on — the + // position key is (playlistId, contentXtreamId, type). + playlistId: candidate.playlistId, + contentXtreamId: candidate.contentId, + contentType: 'vod', + }, + userAgent: playlist.userAgent, + referer: playlist.referrer, + origin: playlist.origin, + }, + candidate: applyApiMetadata(candidate, details.metadata), + }; + } + + private async loadPlaylist( + dataSource: IXtreamDataSource, + playlistId: string + ) { + try { + const playlist = await dataSource.getPlaylist(playlistId); + // A foreign playlist can be missing credentials entirely (an M3U + // row, or a partially imported portal) — resolving would build a + // nonsense URL. + if (!playlist?.serverUrl || !playlist.username) { + return null; + } + return playlist; + } catch (error) { + this.logger.warn('Loading the alternative playlist failed:', error); + return null; + } + } + + private async loadVodDetails( + api: XtreamApiService, + credentials: XtreamCredentials, + candidate: VodSourceCandidate + ) { + const cacheKey = `${candidate.playlistId}:${candidate.contentId}`; + const cachedContainer = this.containerCache.get(cacheKey); + + try { + const vodDetails = await api.getVodInfo( + credentials, + candidate.contentId + ); + + const container = vodDetails?.movie_data?.container_extension; + if (container) { + this.containerCache.set(cacheKey, container); + } + + return { + vodDetails, + metadata: providerVodMetadataOf(vodDetails), + }; + } catch (error) { + this.logger.warn('Reading alternative VOD details failed:', error); + + // The portal is unreachable right now, but a container learned on + // a previous visit is still valid — enough to build a URL and let + // playback itself report the real problem. + if (!cachedContainer) { + return null; + } + + return { + vodDetails: { + movie_data: { + stream_id: candidate.contentId, + container_extension: cachedContainer, + }, + } as Parameters[1], + metadata: { containerExtension: cachedContainer }, + }; + } + } +} + +/** + * Xtream panels disagree about `info.video` / `info.audio`. + * + * The declared shape is a string array (`['H.264']`), and that is what the + * mock server and many panels return; others return the ffprobe object with + * `codec_name`/`width`/`height`. Reading only the object shape silently lost + * the codec on every array-shaped response — so the source rows showed no + * provider-stated codec and the "dub may differ" warning could never fire. + */ +/** + * The provider's own stated facts about one movie, in the shape + * `applyApiMetadata` consumes. + * + * Shared with the ROUTE's source row, which the page can build from details it + * has already loaded. Without it the row the user is switching *from* carries + * no facts at all, so `audioDiffersFactually` has nothing to compare and the + * "dub may differ" warning cannot fire on the commonest switch there is. + */ +export function providerVodMetadataOf( + vodDetails: + | { info?: unknown; movie_data?: { container_extension?: string } } + | null + | undefined +): ProviderVodMetadata { + // A provider can send `info` as an array, which carries no stream data. + const info = Array.isArray(vodDetails?.info) + ? undefined + : (vodDetails?.info as + | { video?: unknown; audio?: unknown } + | undefined); + const video = readStreamInfo(info?.video); + const audio = readStreamInfo(info?.audio); + + return { + containerExtension: vodDetails?.movie_data?.container_extension ?? null, + videoCodec: video?.codec_name ?? null, + audioCodec: audio?.codec_name ?? null, + // ffprobe puts the language in `tags`; some panels hoist it. + audioLanguage: audio?.tags?.language ?? audio?.language ?? null, + width: video?.width ?? null, + height: video?.height ?? null, + }; +} + +export function readStreamInfo(value: unknown): + | { + codec_name?: string; + width?: number; + height?: number; + language?: string; + tags?: { language?: string }; + } + | undefined { + if (Array.isArray(value)) { + const codec = value.find( + (entry): entry is string => + typeof entry === 'string' && entry.trim() !== '' + ); + return codec ? { codec_name: codec.trim() } : undefined; + } + + return typeof value === 'object' && value !== null + ? (value as { codec_name?: string; width?: number; height?: number }) + : undefined; +} diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts index 834a6200a..62cc47b40 100644 --- a/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts @@ -1,5 +1,8 @@ import { signal } from '@angular/core'; -import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import type { + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; import { createExternalPlaybackButtonState } from './external-playback-button-state'; function session( @@ -135,4 +138,73 @@ describe('createExternalPlaybackButtonState', () => { expect(api.matchedSession()).toBeNull(); }); + + it('also owns a session launched for another playlist it switched to', () => { + // Multi-source: same film, other playlist, other stream id. Without + // this the page shows Play while its own switch is streaming, and + // clicking it opens a second player instead of stopping the first. + const api = createExternalPlaybackButtonState({ + session: signal( + session({ + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }) + ), + playlistId: signal('playlist-1'), + contentId: signal(42), + alsoOwns: signal({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }), + }); + + expect(api.matchedSession()).not.toBeNull(); + expect(api.isStopAction()).toBe(true); + }); + + it('disowns that session once playback moved back', () => { + const api = createExternalPlaybackButtonState({ + session: signal( + session({ + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }) + ), + playlistId: signal('playlist-1'), + contentId: signal(42), + alsoOwns: signal(null), + }); + + expect(api.matchedSession()).toBeNull(); + }); + + it('still checks the content type of an owned alternative', () => { + const api = createExternalPlaybackButtonState({ + session: signal( + session({ + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'episode', + }, + }) + ), + playlistId: signal('playlist-1'), + contentId: signal(42), + alsoOwns: signal({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }), + }); + + expect(api.matchedSession()).toBeNull(); + }); }); diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.ts index ea97ddfb6..916c2c784 100644 --- a/libs/portal/shared/util/src/lib/external-playback-button-state.ts +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.ts @@ -1,5 +1,8 @@ import { computed, type Signal } from '@angular/core'; -import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import type { + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; /** * Derives the primary Play/Stop button's state from the active external @@ -21,6 +24,15 @@ export interface ExternalPlaybackButtonStateConfig { contentId: Signal; /** Defaults to `'vod'`. */ contentType?: 'vod' | 'episode'; + /** + * A second pair of ids this page also owns, if any. + * + * Multi-source can launch an external player on a copy of the same film in + * ANOTHER playlist, and the session then carries that playlist's ids. + * Without this the page shows Play while its own switch is streaming, and + * the user has no way to stop it from here. + */ + alsoOwns?: Signal; } export interface ExternalPlaybackButtonStateApi { @@ -53,15 +65,19 @@ export function createExternalPlaybackButtonState( } const info = session.contentInfo; - if ( - info.playlistId !== config.playlistId() || - info.contentType !== contentType || - info.contentXtreamId !== config.contentId() - ) { + if (info.contentType !== contentType) { return null; } - return session; + const alsoOwns = config.alsoOwns?.(); + const owned = + (info.playlistId === config.playlistId() && + info.contentXtreamId === config.contentId()) || + (!!alsoOwns && + info.playlistId === alsoOwns.playlistId && + info.contentXtreamId === alsoOwns.contentXtreamId); + + return owned ? session : null; }); const primaryLabel = computed(() => { diff --git a/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts b/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts index 16b3b023c..2b44dc2b0 100644 --- a/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts @@ -110,4 +110,76 @@ describe('createInlinePlaybackPositionWriter', () => { }) ); }); + + describe('resume latch', () => { + function resuming(startTime: number): ResolvedPortalPlayback { + return { ...playbackWithInfo(), startTime }; + } + + it('drops updates emitted before the engine reaches startTime', () => { + // A resuming engine ticks at ~0 while it is still seeking. Writing + // one of those overwrites the very position being resumed from. + const { writer, save } = setup(resuming(2538)); + + expect( + writer.handleTimeUpdate({ currentTime: 12, duration: 7744 }) + ).toBe(false); + expect(save).not.toHaveBeenCalled(); + }); + + it('releases once, so a later seek backwards is still saved', () => { + jest.useFakeTimers(); + const { writer, save } = setup(resuming(2538)); + + writer.handleTimeUpdate({ currentTime: 12, duration: 7744 }); + // Reaching the resume point latches the guard open for good. + writer.handleTimeUpdate({ currentTime: 2540, duration: 7744 }); + + jest.advanceTimersByTime(16000); + expect( + writer.handleTimeUpdate({ currentTime: 30, duration: 7744 }) + ).toBe(true); + expect(save).toHaveBeenLastCalledWith( + 'playlist-1', + expect.objectContaining({ positionSeconds: 30 }) + ); + }); + + it('gives up on a resume point the source cannot reach', () => { + // Two hours carried into a 90-minute cut: the engine can never + // report that time, so latching on it would suppress every save + // for the whole session. + const { writer, save } = setup(resuming(7200)); + + expect( + writer.handleTimeUpdate({ currentTime: 12, duration: 5400 }) + ).toBe(true); + expect(save).toHaveBeenCalled(); + }); + + it('keeps the latch closed across an update with no contentInfo', () => { + const { writer, playback } = setup(resuming(2538)); + + writer.handleTimeUpdate({ currentTime: 12, duration: 7744 }); + playback.set({ streamUrl: 'http://example.com/x.mkv', title: 'X' }); + + // Reporting `true` here would tell multi-source that a timecode of + // ~0 can be carried to the next source. + expect( + writer.handleTimeUpdate({ currentTime: 13, duration: 7744 }) + ).toBe(false); + }); + + it('is cleared by reset, so a new resume waits again', () => { + const { writer, playback } = setup(); + + writer.handleTimeUpdate({ currentTime: 40, duration: 7744 }); + playback.set(resuming(2538)); + writer.reset(); + + expect( + writer.handleTimeUpdate({ currentTime: 12, duration: 7744 }) + ).toBe(false); + }); + }); }); diff --git a/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts b/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts index 049078094..eb36492e7 100644 --- a/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts @@ -12,12 +12,17 @@ import type { * behaves differently per portal is exactly the kind of divergence users * notice. * - * Deliberately behaviour-identical to the two implementations it replaces. + * Beyond that throttle it holds one resume latch, because a position written + * before the engine reaches `startTime` overwrites the point being resumed + * from — a bug both copies had. */ /** The player fires ~4x/second; persisting that often would hammer SQLite. */ const DEFAULT_THROTTLE_MS = 15000; +/** Engines land near, not exactly on, the requested `startTime`. */ +const RESUME_TOLERANCE_SECONDS = 5; + export interface InlinePlaybackPositionWriterConfig { /** The playback currently mounted in the inline player. */ playback: Signal; @@ -28,8 +33,14 @@ export interface InlinePlaybackPositionWriterConfig { } export interface InlinePlaybackPositionWriter { - handleTimeUpdate(event: { currentTime: number; duration: number }): void; - /** Clears the throttle so the next update is written immediately. */ + /** + * @returns whether `currentTime` can be believed yet — false while the + * engine is still on its way to `startTime`. Multi-source switching needs + * the same answer to carry a timecode across, and one latch has to serve + * both or the two disagree about where playback is. + */ + handleTimeUpdate(event: { currentTime: number; duration: number }): boolean; + /** Clears the throttle and the resume latch for a new playback. */ reset(): void; } @@ -38,18 +49,46 @@ export function createInlinePlaybackPositionWriter( ): InlinePlaybackPositionWriter { const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS; let lastSaveTime = 0; + /** Cleared on every start; latches once playback reaches `startTime`. */ + let resumeSettled = false; return { handleTimeUpdate(event) { const playback = config.playback(); // Without contentInfo there is no key to store the position under. + // The latch never ran, so the answer is whatever it already was — + // not a fresh "no". if (!playback?.contentInfo) { - return; + return resumeSettled; + } + + // A resuming engine can emit timeupdates at ~0 before it finishes + // seeking to startTime. Writing one would overwrite the very + // position being resumed from — which a source switch then + // inherits, restarting the film from the beginning. + // + // This is a one-shot latch, not a filter: once playback has + // reached the resume point the guard is done, so a deliberate seek + // backwards is still saved normally. + if (!resumeSettled) { + const startTime = playback.startTime ?? 0; + // A shorter cut can never reach a position carried into it, + // and latching on that would suppress every save this session. + const reachable = + !(event.duration > 0) || startTime < event.duration; + if ( + reachable && + startTime > 0 && + event.currentTime < startTime - RESUME_TOLERANCE_SECONDS + ) { + return false; + } + resumeSettled = true; } const now = Date.now(); if (now - lastSaveTime <= throttleMs) { - return; + return true; } lastSaveTime = now; @@ -61,10 +100,12 @@ export function createInlinePlaybackPositionWriter( config.save(playback.contentInfo.playlistId, position); config.onSaved?.(position); + return true; }, reset() { lastSaveTime = 0; + resumeSettled = false; }, }; } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index 48adfd92d..3c33d89ff 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { DatabaseService, PlaybackPositionService, + VodSourcePinService, XtreamPendingRestoreService, } from '@iptvnator/services'; import { @@ -56,6 +57,13 @@ export function createDbServiceMock() { }; } +export function createVodSourcePinServiceMock() { + return { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: jest.fn().mockResolvedValue(true), + }; +} + export function createPlaybackServiceMock() { return { savePlaybackPosition: jest.fn().mockResolvedValue(undefined), @@ -85,6 +93,7 @@ export interface ElectronXtreamDataSourceHarness { dataSource: ElectronXtreamDataSource; dbService: ReturnType; playbackService: ReturnType; + vodSourcePinService: ReturnType; pendingRestoreService: ReturnType; apiService: ReturnType; } @@ -92,6 +101,7 @@ export interface ElectronXtreamDataSourceHarness { export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness { const dbService = createDbServiceMock(); const playbackService = createPlaybackServiceMock(); + const vodSourcePinService = createVodSourcePinServiceMock(); const pendingRestoreService = createPendingRestoreServiceMock(); const apiService = createApiServiceMock(); @@ -100,6 +110,10 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness ElectronXtreamDataSource, { provide: DatabaseService, useValue: dbService }, { provide: PlaybackPositionService, useValue: playbackService }, + { + provide: VodSourcePinService, + useValue: vodSourcePinService, + }, { provide: XtreamPendingRestoreService, useValue: pendingRestoreService, @@ -112,6 +126,7 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness dataSource: TestBed.inject(ElectronXtreamDataSource), dbService, playbackService, + vodSourcePinService, pendingRestoreService, apiService, }; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index a2365a76b..d417cc333 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -4,6 +4,7 @@ import { PlaybackPositionService, XtreamPendingRestoreService, XtreamImportStatus, + VodSourcePinService, } from '@iptvnator/services'; import { PlaybackPositionData, @@ -39,6 +40,7 @@ import { export class ElectronXtreamDataSource implements IXtreamDataSource { private readonly dbService = inject(DatabaseService); private readonly playbackService = inject(PlaybackPositionService); + private readonly vodSourcePinService = inject(VodSourcePinService); private readonly pendingRestoreService = inject( XtreamPendingRestoreService ); @@ -540,9 +542,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async clearPlaylistContent( playlistId: string ): Promise { - const [result, playbackPositions] = await Promise.all([ + const [result, playbackPositions, sourcePins] = await Promise.all([ this.dbService.deleteXtreamPlaylistContent(playlistId), this.playbackService.getAllPlaybackPositions(playlistId), + this.vodSourcePinService.listForPlaylist(playlistId), ]); return { @@ -550,6 +553,11 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { favorites: result.favorites, recentlyViewed: result.recentlyViewed, playbackPositions, + sourcePins: sourcePins.map((pin) => ({ + matchKey: pin.matchKey, + contentId: pin.contentId, + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + })), }; } @@ -573,5 +581,27 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { playbackPosition ); } + + // The fresh-import path lands here rather than in the backup service: + // a new playlist has no content yet when the archive is read, so its + // user state is parked and applied once the import finishes. + for (const pin of restoreState.sourcePins ?? []) { + const written = await this.vodSourcePinService.set({ + matchKey: pin.matchKey, + playlistId, + contentId: pin.contentId, + portalType: 'xtream', + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + }); + + // Throwing keeps the pending state for a later retry — the caller + // only clears it when this resolves. Dropping it here would lose + // the preference with the import still reporting success. + if (!written) { + throw new Error( + `Restoring the pinned source for "${pin.matchKey}" failed.` + ); + } + } } } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index 1b887d1b4..af578ea28 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -194,9 +194,57 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { favorites, recentlyViewed, playbackPositions: [position], + sourcePins: [], }); }); + it('applies parked source pins against the imported playlist', async () => { + // The fresh-import path: a new playlist has no content when the + // archive is read, so its user state is parked and replayed here. + // Without this the backup's pins are dropped for every new import. + const restoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + sourcePins: [ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ], + } as never; + + await harness.dataSource.restoreUserData(playlistId, restoreState); + + expect(harness.vodSourcePinService.set).toHaveBeenCalledWith({ + matchKey: 'tmdb:603', + playlistId, + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }); + }); + + it('keeps the pending state when a pin cannot be written', async () => { + harness.vodSourcePinService.set.mockResolvedValue(false); + const restoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + sourcePins: [{ matchKey: 'tmdb:603', contentId: 501 }], + } as never; + + // The caller clears the parked state only when this resolves, so + // resolving here would drop the preference on a transient DB + // failure while the import still reported success. + await expect( + harness.dataSource.restoreUserData(playlistId, restoreState) + ).rejects.toThrow('tmdb:603'); + }); + it('restores user data, then resets and replays playback positions', async () => { const positionA = { contentXtreamId: 1 } as never; const positionB = { contentXtreamId: 2 } as never; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts new file mode 100644 index 000000000..f16e46121 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Signal, computed, inject, signal } from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { DownloadsService } from '@iptvnator/services'; +import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access'; +import { XtreamVodDetails } from '@iptvnator/shared/interfaces'; +import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; + +/** + * Offline downloads for the movie on screen. + * + * Every read re-touches `downloads()` so the computed tracks the signal the + * service updates — the per-item helpers are plain lookups and would not + * otherwise re-run when a download's state changes. + */ +@Injectable() +export class VodDetailsDownloadsService { + private readonly downloadsService = inject(DownloadsService); + private readonly xtreamStore = inject(XtreamStore); + + private routeContentId: Signal = signal(NaN); + + bind(bindings: { routeContentId: Signal }): void { + this.routeContentId = bindings.routeContentId; + } + + private readonly context = computed(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + // Read so the state below re-derives as downloads progress. + this.downloadsService.downloads(); + return playlistId ? { playlistId, vodId: this.routeContentId() } : null; + }); + + readonly isDownloaded = computed(() => { + const context = this.context(); + return context + ? this.downloadsService.isDownloaded( + context.vodId, + context.playlistId, + 'vod' + ) + : false; + }); + + readonly isDownloading = computed(() => { + const context = this.context(); + return context + ? this.downloadsService.isDownloading( + context.vodId, + context.playlistId, + 'vod' + ) + : false; + }); + + readonly isPausedDownload = computed(() => { + const context = this.context(); + return context + ? this.downloadsService.isPaused( + context.vodId, + context.playlistId, + 'vod' + ) + : false; + }); + + async resumePaused(): Promise { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + + await this.downloadsService.resumeDownloadByContent( + this.routeContentId(), + playlistId, + 'vod' + ); + } + + async start(vodItem: XtreamVodDetails | null): Promise { + const playlist = this.xtreamStore.currentPlaylist(); + if (!vodItem || !playlist) { + return; + } + + // A provider can omit the route id entirely (sparse details), and the + // resolved source is then the only place the stream id exists. + const source = resolveXtreamVodPlaybackSource(vodItem); + if (!source) { + return; + } + + const presentation = resolveXtreamVodPlaybackPresentation(vodItem); + const routeVodId = this.routeContentId(); + const id = + Number.isSafeInteger(routeVodId) && routeVodId > 0 + ? routeVodId + : source.streamId; + + await this.downloadsService.startDownload({ + playlistId: playlist.id, + xtreamId: id, + contentType: 'vod', + title: presentation.title, + url: this.xtreamStore.constructVodStreamUrl(vodItem), + posterUrl: presentation.posterUrl, + headers: { + userAgent: playlist.userAgent, + referer: playlist.referrer, + origin: playlist.origin, + }, + }); + } + + async playLocal(): Promise { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + + const filePath = this.downloadsService.getDownloadedFilePath( + this.routeContentId(), + playlistId, + 'vod' + ); + + if (filePath) { + await this.downloadsService.playDownload(filePath); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts new file mode 100644 index 000000000..515caa99b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts @@ -0,0 +1,267 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import type { + PlaybackPositionData, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; +import { VodDetailsPlaybackService } from './vod-details-playback.service'; + +/** + * Which external session this page owns. + * + * Multi-source can launch MPV/VLC for a movie in ANOTHER playlist, and the + * session then carries that playlist's ids — so the matcher decides whether + * the primary button can stop it or silently launches a second player. + */ +/** + * Which external process this page owns, and what replacing it entails. + * Split from the position/ownership spec to keep both inside the file-size + * rule. + */ +describe('VodDetailsPlaybackService — external playback handoff', () => { + const ROUTE_PLAYLIST = 'playlist-1'; + const ROUTE_VOD_ID = 650020; + + let service: VodDetailsPlaybackService; + /** The bridge callback the service registers at construction. */ + let positionListener: ((data: PlaybackPositionData) => void) | undefined; + const addRecentItem = jest.fn(); + const activeSession = signal(null); + const closeSession = jest.fn().mockResolvedValue(undefined); + const openResolvedPlayback = jest.fn(); + const activeSource = signal(null); + + function sessionFor(playlistId: string, contentXtreamId: number) { + return { + player: 'mpv', + status: 'playing', + contentInfo: { + playlistId, + contentXtreamId, + contentType: 'vod' as const, + }, + }; + } + + beforeEach(() => { + activeSession.set(null); + activeSource.set(null); + positionListener = undefined; + addRecentItem.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + VodDetailsPlaybackService, + { + provide: XtreamStore, + useValue: { + currentPlaylist: signal({ id: ROUTE_PLAYLIST }), + addRecentItem, + constructVodStreamUrl: jest + .fn() + .mockReturnValue('https://example.com/route.mkv'), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession, closeSession }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn(), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback, + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: ( + listener: (data: PlaybackPositionData) => void + ) => { + positionListener = listener; + return () => undefined; + }, + }, + }, + ], + }); + + service = TestBed.inject(VodDetailsPlaybackService); + service.bind({ + vodId: signal(ROUTE_VOD_ID), + vodInfo: signal(null), + activeSource, + }); + }); + + it('closes the alternative it launched, once the badge has moved on', async () => { + // The controller marks the DESTINATION active before playback is + // handed over, so by the time the switch reaches the service the + // running process no longer looks like "ours" — and was left playing + // beside its replacement. + activeSession.set(sessionFor('playlist-2', 991)); + activeSource.set({ + playlistId: 'playlist-3', + contentXtreamId: 77, + contentType: 'vod', + }); + closeSession.mockClear(); + + // Pretend the alternative we are replacing is the one we started. + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/first.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }); + closeSession.mockClear(); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/second.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-3', + contentXtreamId: 77, + contentType: 'vod', + }, + }); + + expect(closeSession).toHaveBeenCalledWith( + expect.objectContaining({ + contentInfo: expect.objectContaining({ + contentXtreamId: 991, + }), + }) + ); + }); + + it('still starts the replacement when closing the old player fails', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + closeSession.mockRejectedValue(new Error('close ipc failed')); + openResolvedPlayback.mockClear(); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + }); + + // The switch is already committed — the badge names the new source. + // Bailing out here left the page claiming a source with nothing + // started at all. + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + + closeSession.mockResolvedValue(undefined); + }); + + it('launches only the newest source when two switches overlap', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + // One shared promise: both calls see the same running session, so + // both await the same close rather than each making its own. + let releaseClose: (() => void) | undefined; + const closing = new Promise((resolve) => { + releaseClose = () => resolve(); + }); + closeSession.mockReturnValue(closing); + // These spies are module-level and never cleared between cases. + openResolvedPlayback.mockClear(); + + const first = service.startResolvedPlayback({ + streamUrl: 'https://example.com/one.mkv', + title: 'Example Movie', + }); + const second = service.startResolvedPlayback({ + streamUrl: 'https://example.com/two.mkv', + title: 'Example Movie', + }); + + releaseClose?.(); + await Promise.all([first, second]); + + // Both saw the same running session and awaited its close. Launching + // both afterwards is how two detached players appear again, with the + // older one holding a source the user has already moved on from. + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: 'https://example.com/two.mkv', + }), + true + ); + + closeSession.mockResolvedValue(undefined); + }); + + it('drops a switch that a plain Play overtook', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + let releaseClose: (() => void) | undefined; + const closing = new Promise((resolve) => { + releaseClose = () => resolve(); + }); + closeSession.mockReturnValue(closing); + openResolvedPlayback.mockClear(); + + const switching = service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + }); + + // The user presses Play on the route copy while that close is pending. + service.playVod({ + movie_data: { + stream_id: ROUTE_VOD_ID, + name: 'Example Movie', + container_extension: 'mp4', + }, + } as never); + releaseClose?.(); + await switching; + + // Only the route copy may be playing; the switch was overtaken. + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + expect(openResolvedPlayback).not.toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: 'https://example.com/alt.mkv', + }), + true + ); + + closeSession.mockResolvedValue(undefined); + }); + + it('stops the running external player before switching sources', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }); + + // A switch REPLACES what is playing. With MPV/VLC and instance reuse + // off the backend spawns a second detached player otherwise: both + // sources keep running, and Stop owns only the newer one. + expect(closeSession).toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts new file mode 100644 index 000000000..af5dfb0a4 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts @@ -0,0 +1,108 @@ +import type { + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; + +/** + * Which external player process, and which position rows, belong to this page. + * + * Multi-source makes both questions harder than they look: playback can be on + * a copy of the film in ANOTHER playlist, whose ids the session and the + * position rows then carry, and during a switch the controller has already + * moved "active" to the destination before playback is handed over. + */ + +/** Ids this page owns, beyond the route's own copy. */ +export interface OwnedContentIds { + routePlaylistId: string | undefined; + routeContentId: number | undefined; + /** The alternative currently in use, if it is not the route's own. */ + alternative: PlayerContentInfo | null; +} + +/** + * Whether a position update refers to content this page owns. + * + * An absent playlist id must never match an absent current playlist, or + * `undefined === undefined` would adopt a stranger's progress. + */ +export function ownsContent( + info: + | { + playlistId?: string; + contentXtreamId?: number; + contentType?: string; + } + | undefined, + owned: OwnedContentIds +): boolean { + if (!info?.playlistId || info.contentType !== 'vod') { + return false; + } + + const { alternative } = owned; + return ( + (info.playlistId === owned.routePlaylistId && + info.contentXtreamId === owned.routeContentId) || + (!!alternative && + info.playlistId === alternative.playlistId && + info.contentXtreamId === alternative.contentXtreamId) + ); +} + +/** + * The external process this page started, if it is still up. + * + * Matched on the ids we LAUNCHED with rather than on what is active now: a + * switch marks the destination active before handing playback over, so asking + * "is this session ours?" at that moment answers no and leaves the running + * process playing beside its replacement. + */ +export function runningExternalSession( + session: ExternalPlayerSession | null, + launched: PlayerContentInfo | null, + matched: ExternalPlayerSession | null +): ExternalPlayerSession | null { + if ( + !session?.contentInfo || + session.status === 'closed' || + session.status === 'error' + ) { + return null; + } + + const info = session.contentInfo; + const isLaunchedOne = + !!launched && + info.playlistId === launched.playlistId && + info.contentXtreamId === launched.contentXtreamId; + + return isLaunchedOne ? session : matched; +} + +/** + * Close the running external player before its replacement starts. + * + * A failure is logged rather than propagated: the caller has already + * committed the switch, so refusing to launch would leave the page naming a + * source with nothing playing — worse than a possibly-lingering process, and + * a close that rejects usually means the session was gone already. + */ +export async function closeRunningExternalSession( + session: ExternalPlayerSession | null, + close: (session: ExternalPlayerSession) => Promise, + warn: (message: string, error: unknown) => void +): Promise { + if (!session) { + return; + } + + try { + await close(session); + } catch (error) { + warn( + 'Closing the previous external player failed; starting the replacement anyway.', + error + ); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts new file mode 100644 index 000000000..63f07236a --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts @@ -0,0 +1,329 @@ +import { Injectable, Signal, computed, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { SettingsStore } from '@iptvnator/services'; +import { + PlaybackPositionData, + PlayerContentInfo, + playlistDisplayLabel, + reportsPlaybackFailures, + type VodSourceCandidate, + type VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { VodDetailsPlaybackService } from './vod-details-playback.service'; +import { VodMultiSourceHostService } from './vod-multi-source-host.service'; +import { + createPrimaryActionPosition, + formatPlaybackPosition, + isResumablePosition, +} from './vod-primary-action-position'; + +/** + * The VOD details route's multi-source concerns. + * + * Everything here answers one of two questions: what is TRUE about playback + * right now, and what does the primary button act on. Both are easy to get + * subtly wrong — the controller's "active source" means selected rather than + * playing, and positions are keyed per (playlist, stream) so the row the page + * loaded is not necessarily the one a click will start. + * + * Component-provided, like the host service whose session it reads. + */ +@Injectable() +export class VodDetailsMultiSourceUiService { + private readonly multiSource = inject(VodMultiSourceHostService); + private readonly playback = inject(VodDetailsPlaybackService); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly xtreamStore = inject(XtreamStore); + private readonly settingsStore = inject(SettingsStore); + private readonly snackBar = inject(MatSnackBar); + private readonly translateService = inject(TranslateService); + + /** The vod id the route is showing; bound once by the host component. */ + private routeContentId: Signal = signal(NaN); + /** The movie identity, for the sources popover header. */ + private movieTitle: Signal = signal(''); + + bind(bindings: { + routeContentId: Signal; + movieTitle: Signal; + }): void { + this.routeContentId = bindings.routeContentId; + this.movieTitle = bindings.movieTitle; + } + + /** True from a playback failure until something plays again. */ + private readonly playbackFailed = signal(false); + + /** + * Cleared on every start; set by the first timeupdate. + * + * `inlinePlayback()` is the REQUEST to play — it is non-null while the + * engine is still opening the stream, and stays non-null after it fails. + * A timeupdate is the engine reporting that it is producing frames, which + * is the only evidence the page has that something is really playing. + */ + private readonly inlineTimeSeen = signal(false); + + /** + * Whether a stream is on screen right now. + * + * Both the "Playing from" caption and the row's Playing badge are claims + * about the present, and discovery marks a source active the moment the + * page opens — long before anything plays, and again after the player is + * closed. Gating both on this keeps them from lying. + */ + readonly playbackLive = computed(() => { + if (this.playback.inlinePlayback()) { + return !this.playbackFailed() && this.inlineTimeSeen(); + } + + // The external player is a window of its own: once it is open the film + // is on screen, and only 'launching' means it is not there yet. + const status = this.playback.matchedExternalPlayback()?.status; + return status === 'opened' || status === 'playing'; + }); + + /** + * The source playing when it is NOT the route's own. + * + * Both halves of the comparison matter: a pinned copy can live in the + * route's OWN playlist, and matching on playlist alone would call it "the + * route source" — losing the Stop state and every position update for it. + */ + readonly activeAlternativeSource = computed( + () => { + const active = this.multiSource + .sources() + .find((source) => source.isActive); + const routePlaylistId = this.xtreamStore.currentPlaylist()?.id; + + if ( + !active || + (active.playlistId === routePlaylistId && + active.contentId === this.routeContentId()) + ) { + return null; + } + + return { + playlistId: active.playlistId, + contentXtreamId: active.contentId, + contentType: 'vod' as const, + }; + } + ); + + private readonly primaryAction = createPrimaryActionPosition({ + sources: this.multiSource.sources, + routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id), + routeContentId: computed(() => this.routeContentId()), + routePosition: this.playback.routePlaybackPosition, + livePosition: this.playback.vodPlaybackPosition, + load: (source) => this.positionFor(source), + }); + + readonly hasPlaybackPosition = this.primaryAction.hasPosition; + + formatPosition(): string { + return formatPlaybackPosition(this.primaryAction.position()); + } + + /** + * 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. + */ + readonly positionFor = ( + source: VodSourceCandidate | VodSourceDescriptor + ): Promise => + this.playbackPositions.getPlaybackPosition( + source.playlistId, + source.contentId, + 'vod' + ); + + /** + * The seconds a pinned play should resume from — `null` once the copy has + * been watched through, so a row the button offers as Play does not seek + * back to where it ended. + */ + readonly resumeSecondsFor = async ( + source: VodSourceCandidate + ): Promise => { + const position = await this.positionFor(source); + return isResumablePosition(position) + ? (position?.positionSeconds ?? null) + : null; + }; + + /** True when the primary button acts on a pinned copy, not the route's. */ + readonly primaryIsPinnedCopy = computed( + () => this.primaryAction.foreignPin() !== null + ); + + /** Title shown in the sources popover header. */ + readonly multiSourceTitle = computed(() => this.movieTitle()); + + /** The ".srcbar" caption under the action row: where this is playing from. */ + readonly activeSourceCaption = computed(() => { + const active = this.multiSource + .sources() + .find((source) => source.isActive); + // "Playing from" only while something actually is. Discovery marks a + // source active as soon as the page opens, so gating on that alone + // makes the line a claim about a player that has not started, one the + // user has since closed, or one that failed and is showing an error. + if (!active || !this.playbackLive()) { + return null; + } + + // Only FACTS reach the caption. A guessed quality would read as a + // claim about the stream the user is watching right now. + const facts = [ + active.quality?.provenance !== 'parsed' + ? active.quality?.value + : null, + active.container?.provenance !== 'parsed' + ? active.container?.value + : null, + ].filter(Boolean); + + return { + // Never the raw playlist name: it is routinely the pasted URL, + // credentials and all, and this line sits in the open on every + // screenshot of the detail page. + source: [ + playlistDisplayLabel(active.playlistName, active.playlistId), + ...facts, + ].join(' · '), + // The caption speaks of PLAYLISTS ("also found in 2 others"), so + // three copies inside one portal must not read as three portals. + alternativeCount: this.multiSource.alternativePlaylistCount(), + }; + }); + + /** + * Only the built-in web players raise a playback diagnostic, so on MPV, + * VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The + * toggle is hidden there rather than left promising a switch that cannot + * happen. + */ + readonly autoFailoverSupported = computed(() => + reportsPlaybackFailures(this.settingsStore.player?.()) + ); + + /** A new stream is starting: nothing yet vouches for it. */ + beginPlayback(): void { + this.playbackFailed.set(false); + this.inlineTimeSeen.set(false); + } + + /** Route reuse — the previous movie's evidence must not carry over. */ + reset(): void { + this.inlineTimeSeen.set(false); + } + + playFromSource(sourceId: string): void { + // Only once the switch actually starts something. A source picked off + // the error screen that cannot be resolved leaves the diagnostic up, + // and clearing eagerly would have the caption claim playback again. + void this.multiSource.play(sourceId).then((switched) => { + if (switched) { + this.playbackFailed.set(false); + } + }); + } + + pinSource(sourceId: string): void { + void this.multiSource.togglePin(sourceId); + } + + checkSource(sourceId: string): void { + void this.multiSource.check(sourceId); + } + + setAutoFailover(enabled: boolean): void { + // `updateSettings` patches memory first and REJECTS if the write + // fails, so without this the toggle looks saved, reverts on restart, + // and the rejection surfaces only as an unhandled promise. + this.settingsStore + .updateSettings({ vodAutoFailover: enabled }) + .catch(() => + this.snackBar.open( + this.translateService.instant( + 'SETTINGS.SETTINGS_SAVE_FAILED' + ), + this.translateService.instant('CLOSE'), + { duration: 10000 } + ) + ); + } + + /** + * A source failed. With auto-failover on we move to the best untried + * source and ANNOUNCE it; otherwise the player's own error overlay — which + * is already showing the alternatives — is left to do its job. + */ + async onPlaybackFailed(): Promise { + // The error screen is up: nothing is playing from anywhere until a + // source actually starts again. + this.playbackFailed.set(true); + const notice = await this.multiSource.failover(); + if (!notice) { + return; + } + + this.snackBar + .open( + this.translateService.instant( + 'PORTALS.MULTI_SOURCE.SWITCHED_TO', + { playlist: notice.playlistName } + ) + + (notice.audioMayDiffer + ? ` — ${this.translateService.instant( + 'PORTALS.MULTI_SOURCE.SWITCH_AUDIO_WARNING' + )}` + : ''), + this.translateService.instant('PORTALS.MULTI_SOURCE.UNDO'), + { duration: 10000 } + ) + .onAction() + .subscribe(() => this.undoFailover()); + } + + /** Return to the source that was playing before the automatic switch. */ + private undoFailover(): void { + const previousId = this.multiSource.previousSourceId(); + if (previousId) { + void this.multiSource.play(previousId); + } + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + // The engine is producing time, so whatever failed before is over. + this.playbackFailed.set(false); + this.inlineTimeSeen.set(true); + const settled = this.playback.handleInlineTimeUpdate(event); + + // Ahead of the service's 15s persistence throttle, so a source switch + // resumes from where playback actually is rather than up to 15s back. + // + // Until the engine has finished seeking to the resume point it reports + // ~0, and that is not where the film is — it is where it has not got + // to yet. Feeding it to multi-source would make a switch or a failure + // during those first seconds restart the movie from the beginning, so + // the position we asked the engine for stands in until it arrives. + this.multiSource.reportPosition( + settled + ? event.currentTime + : (this.playback.inlinePlayback()?.startTime ?? 0) + ); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts new file mode 100644 index 000000000..90d7cc76b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -0,0 +1,264 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import type { + PlaybackPositionData, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; +import { VodDetailsPlaybackService } from './vod-details-playback.service'; + +/** + * Which external session this page owns. + * + * Multi-source can launch MPV/VLC for a movie in ANOTHER playlist, and the + * session then carries that playlist's ids — so the matcher decides whether + * the primary button can stop it or silently launches a second player. + */ +describe('VodDetailsPlaybackService — external session ownership', () => { + const ROUTE_PLAYLIST = 'playlist-1'; + const ROUTE_VOD_ID = 650020; + + let service: VodDetailsPlaybackService; + /** The bridge callback the service registers at construction. */ + let positionListener: ((data: PlaybackPositionData) => void) | undefined; + const addRecentItem = jest.fn(); + const activeSession = signal(null); + const closeSession = jest.fn().mockResolvedValue(undefined); + const openResolvedPlayback = jest.fn(); + const activeSource = signal(null); + + function sessionFor(playlistId: string, contentXtreamId: number) { + return { + player: 'mpv', + status: 'playing', + contentInfo: { + playlistId, + contentXtreamId, + contentType: 'vod' as const, + }, + }; + } + + beforeEach(() => { + activeSession.set(null); + activeSource.set(null); + positionListener = undefined; + addRecentItem.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + VodDetailsPlaybackService, + { + provide: XtreamStore, + useValue: { + currentPlaylist: signal({ id: ROUTE_PLAYLIST }), + addRecentItem, + constructVodStreamUrl: jest + .fn() + .mockReturnValue('https://example.com/route.mkv'), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession, closeSession }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn(), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback, + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: ( + listener: (data: PlaybackPositionData) => void + ) => { + positionListener = listener; + return () => undefined; + }, + }, + }, + ], + }); + + service = TestBed.inject(VodDetailsPlaybackService); + service.bind({ + vodId: signal(ROUTE_VOD_ID), + vodInfo: signal(null), + activeSource, + }); + }); + + it('owns a session launched for the route’s own stream', () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + + expect(service.matchedExternalPlayback()).not.toBeNull(); + expect(service.isExternalStopAction()).toBe(true); + }); + + it('owns a session launched for the alternative it switched to', () => { + // Same movie, other playlist, other stream id. Before this the page + // disowned its own session: the button never became Stop, stopping + // found nothing to stop, and another click opened a second player. + activeSource.set({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }); + activeSession.set(sessionFor('playlist-2', 991)); + + expect(service.matchedExternalPlayback()).not.toBeNull(); + expect(service.isExternalStopAction()).toBe(true); + }); + + it('disowns a session belonging to some other movie entirely', () => { + activeSource.set({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }); + activeSession.set(sessionFor('playlist-3', 12345)); + + expect(service.matchedExternalPlayback()).toBeNull(); + expect(service.isExternalStopAction()).toBe(false); + }); + + it('disowns an alternative session once playback moved back', () => { + activeSession.set(sessionFor('playlist-2', 991)); + + // No active alternative: the switch was undone, so that session is + // no longer this page's to stop. + expect(service.matchedExternalPlayback()).toBeNull(); + }); + + it('records a source started through multi-source as recently viewed', async () => { + // Playing an alternative from the picker, or letting a pin decide the + // primary Play, is still watching the movie — it belongs in Recently + // Viewed exactly as an ordinary Play does. + // + // Awaited: the handoff always yields once now, because replacing a + // running external player is a round-trip even when there is none. + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }); + + expect(addRecentItem).toHaveBeenCalled(); + }); + + it('gives up on a resume point the source cannot reach', () => { + // Carrying a two-hour position into a 90-minute cut: the engine can + // never report that time, so latching on it would suppress every save + // for the whole session and keep re-reporting the impossible start. + service.inlinePlayback.set({ + streamUrl: 'https://example.com/short-cut.mkv', + title: 'Example Movie', + startTime: 7200, + contentInfo: { + playlistId: ROUTE_PLAYLIST, + contentXtreamId: ROUTE_VOD_ID, + contentType: 'vod', + }, + }); + + expect( + service.handleInlineTimeUpdate({ currentTime: 12, duration: 5400 }) + ).toBe(true); + }); + + it('still waits for a resume point the source can reach', () => { + service.inlinePlayback.set({ + streamUrl: 'https://example.com/full.mkv', + title: 'Example Movie', + startTime: 2538, + contentInfo: { + playlistId: ROUTE_PLAYLIST, + contentXtreamId: ROUTE_VOD_ID, + contentType: 'vod', + }, + }); + + expect( + service.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 }) + ).toBe(false); + }); + + describe('position updates from the bridge', () => { + function emit(playlistId: string, contentXtreamId: number, at: number) { + positionListener?.({ + playlistId, + contentXtreamId, + contentType: 'vod', + positionSeconds: at, + durationSeconds: 7744, + }); + } + + it('keeps the route’s own resume point when an alternative plays', () => { + activeSource.set({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }); + + // An alternative's position arrives under ITS ids. Letting it stand in + // for the route copy's row would have Resume start the route stream at + // a timecode nobody ever reached in it. + emit('playlist-2', 991, 4200); + + expect(service.vodPlaybackPosition()?.positionSeconds).toBe(4200); + expect(service.routePlaybackPosition()).toBeNull(); + expect(service.hasPlaybackPosition()).toBe(false); + + emit(ROUTE_PLAYLIST, ROUTE_VOD_ID, 60); + expect(service.routePlaybackPosition()?.positionSeconds).toBe(60); + }); + + it('takes the route stream’s progress', () => { + emit(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120); + + expect(service.vodPlaybackPosition()?.positionSeconds).toBe(120); + }); + + it('takes the progress of the alternative it switched to', () => { + activeSource.set({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }); + + // An external player running an alternative reports under THAT + // playlist's ids. Dropping these leaves the resume point where + // playback started, and a later switch rewinds the whole session. + emit('playlist-2', 991, 3600); + + expect(service.vodPlaybackPosition()?.positionSeconds).toBe(3600); + }); + + it('ignores progress for a movie this page is not showing', () => { + emit('playlist-3', 12345, 900); + + expect(service.vodPlaybackPosition()).toBeNull(); + }); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 1e216c27d..953a65168 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -28,13 +28,25 @@ import { XtreamVodInfo, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; +import { + closeRunningExternalSession, + ownsContent, + runningExternalSession, +} from './vod-details-external-session'; import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; +import { formatPlaybackPosition } from './vod-primary-action-position'; export interface VodDetailsPlaybackBindings { /** Current vod id resolved from the route */ vodId: Signal; /** Usable metadata of the selected VOD, if any */ vodInfo: Signal; + /** + * The source playing when it is NOT the route's own. An external player + * launched for an alternative carries that playlist's ids, so without + * this the session belongs to no page and never offers Stop. + */ + activeSource?: Signal; } /** @@ -57,13 +69,33 @@ export class VodDetailsPlaybackService { private readonly bindings = signal(null); readonly inlinePlayback = signal(null); + /** + * The LAST position seen, whichever copy produced it. + * + * Multi-source can put playback on a copy in another playlist, and this + * follows it — the progress bar and the switch feed both want the stream + * on screen, not the one the route happens to address. + */ readonly vodPlaybackPosition = signal(null); + /** + * The ROUTE copy's own row. + * + * Everything that acts on the route's stream — Resume, its label, its + * timecode — has to read this instead. Positions are keyed by (playlist, + * stream), so once an alternative has played, `vodPlaybackPosition` names + * a different film's row entirely and resuming from it would jump the + * route copy to a timecode nobody reached in it. + */ + readonly routePlaybackPosition = signal(null); + private readonly externalButton = createExternalPlaybackButtonState({ session: this.externalPlayback.activeSession, playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id), contentId: computed(() => this.bindings()?.vodId()), + alsoOwns: computed(() => this.bindings()?.activeSource?.() ?? null), }); + readonly matchedExternalPlayback = this.externalButton.matchedSession; readonly externalPrimaryLabel = this.externalButton.primaryLabel; readonly externalPrimaryIcon = this.externalButton.primaryIcon; @@ -74,9 +106,28 @@ export class VodDetailsPlaybackService { getPortalPlaybackProgressPercent(this.vodPlaybackPosition()) ); + /** Mirrors an incoming position into the route's row when it owns it. */ + private trackPosition(position: PlaybackPositionData | null): void { + this.vodPlaybackPosition.set(position); + if (this.isRouteContent(position)) { + this.routePlaybackPosition.set(position); + } + } + + private isRouteContent(position: PlaybackPositionData | null): boolean { + return ( + !!position && + position.playlistId === this.xtreamStore.currentPlaylist()?.id && + position.contentXtreamId === this.bindings()?.vodId() + ); + } + + /** Whether the ROUTE copy has somewhere to resume from. */ readonly hasPlaybackPosition = computed(() => { - const inProgress = - this.vodPlaybackProgress() > 0 && this.vodPlaybackProgress() < 90; + const progress = getPortalPlaybackProgressPercent( + this.routePlaybackPosition() + ); + const inProgress = progress > 0 && progress < 90; this.logger.debug('hasPlaybackPosition check', { vodId: this.bindings()?.vodId(), inProgress, @@ -88,24 +139,33 @@ export class VodDetailsPlaybackService { const unsubscribePositionUpdates = this.playbackPositionBridge.onPlaybackPositionUpdate( (data: PlaybackPositionData) => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - const vodId = this.bindings()?.vodId(); - - if ( - data.contentType !== 'vod' || - data.playlistId !== playlistId || - data.contentXtreamId !== vodId - ) { - return; + // An external player on an ALTERNATIVE reports under + // that playlist's ids; dropping those rewinds a switch. + if (this.ownsContent(data)) { + this.trackPosition(data); } - - this.vodPlaybackPosition.set(data); } ) ?? null; inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.()); } + private ownsContent( + info: + | { + playlistId?: string; + contentXtreamId?: number; + contentType?: string; + } + | undefined + ): boolean { + return ownsContent(info, { + routePlaylistId: this.xtreamStore.currentPlaylist()?.id, + routeContentId: this.bindings()?.vodId(), + alternative: this.bindings()?.activeSource?.() ?? null, + }); + } + /** Wires the host component's context signals. Call once at construction. */ bind(bindings: VodDetailsPlaybackBindings): void { this.bindings.set(bindings); @@ -171,6 +231,8 @@ export class VodDetailsPlaybackService { const presentation = resolveXtreamVodPlaybackPresentation(vodItem); this.addToRecentlyViewed(); + // Master's sparse-details fallback: a provider that omits the route + // id still has the stream id on the resolved source. const routeVodId = this.bindings()?.vodId(); const vodId = routeVodId != null && @@ -178,7 +240,10 @@ export class VodDetailsPlaybackService { routeVodId > 0 ? routeVodId : source.streamId; - const position = this.vodPlaybackPosition(); + // The ROUTE copy's row, not the last position seen: Resume starts the + // route's stream, and an alternative's timecode belongs to a + // different (playlist, stream) key. + const position = this.routePlaybackPosition(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); const contentInfo: PlayerContentInfo = { @@ -222,13 +287,7 @@ export class VodDetailsPlaybackService { } formatPosition(): string { - const position = this.vodPlaybackPosition(); - if (!position) return ''; - - const date = new Date(0); - date.setSeconds(position.positionSeconds); - const timeString = date.toISOString().substr(11, 8); - return timeString.startsWith('00:') ? timeString.substr(3) : timeString; + return formatPlaybackPosition(this.routePlaybackPosition()); } closeInlinePlayer(): void { @@ -243,14 +302,19 @@ export class VodDetailsPlaybackService { playlistId, position ), - onSaved: (position) => this.vodPlaybackPosition.set(position), + onSaved: (position) => this.trackPosition(position), }); + /** + * @returns whether `currentTime` can be believed yet — see the writer's + * resume latch. Multi-source reads the same answer before carrying a + * timecode to another source. + */ handleInlineTimeUpdate(event: { currentTime: number; duration: number; - }): void { - this.positionWriter.handleTimeUpdate(event); + }): boolean { + return this.positionWriter.handleTimeUpdate(event); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { @@ -267,6 +331,7 @@ export class VodDetailsPlaybackService { 'vod' ); this.vodPlaybackPosition.set(position); + this.routePlaybackPosition.set(position); } private addToRecentlyViewed(): void { @@ -278,14 +343,75 @@ export class VodDetailsPlaybackService { }); } + /** + * The single inline-vs-external fork. Public so multi-source can switch + * the playing source through exactly the same path a normal Play takes — + * a second, parallel start path is how the two drift apart. + * + * For an inline switch this is a single `.set()` on a signal the host + * template already renders through `@if`, so the player component and its + * engine survive and simply re-seek to `playback.startTime`. + */ + /** Bumped by every start; only the newest may launch after its close. */ + private startGeneration = 0; + + /** + * What we last launched externally, remembered independently of the + * controller's active source. + * + * `matchedExternalPlayback` cannot answer this during a switch: the + * controller marks the DESTINATION active before playback is handed over, + * so by the time we get here the running process no longer looks like + * ours and would be left playing beside its replacement. + */ + private launchedExternally: PlayerContentInfo | null = null; + + async startResolvedPlayback( + playback: ResolvedPortalPlayback + ): Promise { + const generation = ++this.startGeneration; + + // A switch REPLACES what is playing. With MPV or VLC and instance + // reuse off, the backend spawns a second detached player otherwise — + // both sources keep running and Stop owns only the newer one. + await closeRunningExternalSession( + runningExternalSession( + this.externalPlayback.activeSession(), + this.launchedExternally, + this.matchedExternalPlayback() + ), + (session) => this.externalPlayback.closeSession(session), + (message, error) => this.logger.warn(message, error) + ); + + // Closing is a round-trip, and a second pick across it would otherwise + // reach this line too: both would have seen the same session, closed + // it once, and then launched independently — two detached players + // again, the older one holding a source the user has moved on from. + if (generation !== this.startGeneration) { + return; + } + + // Same movie, different source: still a view. + this.addToRecentlyViewed(); + this.startPlayback(playback); + } + private startPlayback(playback: ResolvedPortalPlayback): void { + // EVERY start claims the generation, not just the switch path. Play, + // Resume and Restart reach here directly, and a switch still waiting + // on its `closeSession` would otherwise pass the check afterwards and + // launch on top of what the user just chose. + this.startGeneration++; this.positionWriter.reset(); if (this.portalPlayer.isEmbeddedPlayer()) { this.inlinePlayback.set(playback); + this.launchedExternally = null; return; } this.closeInlinePlayer(); + this.launchedExternally = playback.contentInfo ?? null; void this.portalPlayer.openResolvedPlayback(playback, true); } } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-auto-failover.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-auto-failover.spec.ts new file mode 100644 index 000000000..d93c26209 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-auto-failover.spec.ts @@ -0,0 +1,92 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { VodDetailsRouteComponent } from './vod-details-route.component'; +import { + configureVodDetailsRouteTestBed, + createVodDetailsRouteStubs, + resetVodDetailsRouteStubs, + silenceRouteLogging, +} from './vod-details-route.harness'; + +/** + * Who gets offered auto-failover, and what happens when the preference cannot + * be stored. Split from the playback-actions spec to keep both inside the + * repository's file-size rule. + */ +describe('VodDetailsRouteComponent — auto-failover', () => { + let fixture: ComponentFixture; + let restoreLogging: (() => void) | undefined; + const stubs = createVodDetailsRouteStubs(); + const { selectedPlayer, snackBarOpen, updateSettings } = stubs; + + beforeEach(async () => { + restoreLogging = silenceRouteLogging(); + resetVodDetailsRouteStubs(stubs); + await configureVodDetailsRouteTestBed(stubs); + + fixture = TestBed.createComponent(VodDetailsRouteComponent); + }); + + afterEach(() => { + restoreLogging?.(); + }); + + beforeEach(() => { + selectedPlayer.set(VideoPlayer.Html5Player); + updateSettings.mockReset().mockResolvedValue(undefined); + snackBarOpen.mockClear(); + }); + + it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])( + 'is not offered on %s', + (player) => { + // Those players never raise the playback diagnostic that + // calls onPlaybackFailed(), so the switch could never happen. + selectedPlayer.set(player); + + expect(fixture.componentInstance.autoFailoverSupported()).toBe( + false + ); + } + ); + + it.each([ + VideoPlayer.Html5Player, + VideoPlayer.VideoJs, + VideoPlayer.ArtPlayer, + ])('is offered on %s', (player) => { + selectedPlayer.set(player); + + expect(fixture.componentInstance.autoFailoverSupported()).toBe( + true + ); + }); + + it('tells the user when the preference could not be stored', async () => { + // updateSettings patches memory and REJECTS on a failed write, so + // without this the toggle looks saved and silently reverts on the + // next start — and the rejection is unhandled. + updateSettings.mockRejectedValue(new Error('disk full')); + + fixture.componentInstance.setAutoFailover(true); + await Promise.resolve(); + await Promise.resolve(); + + expect(snackBarOpen).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_SAVE_FAILED', + 'CLOSE', + expect.anything() + ); + }); + + it('stays quiet when the write succeeds', async () => { + fixture.componentInstance.setAutoFailover(true); + await Promise.resolve(); + await Promise.resolve(); + + expect(updateSettings).toHaveBeenCalledWith({ + vodAutoFailover: true, + }); + expect(snackBarOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts new file mode 100644 index 000000000..539614c0f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts @@ -0,0 +1,258 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { VodDetailsPlaybackService } from './vod-details-playback.service'; +import { VodDetailsRouteComponent } from './vod-details-route.component'; +import { + configureVodDetailsRouteTestBed, + createVodDetailsRouteStubs, + resetVodDetailsRouteStubs, + silenceRouteLogging, +} from './vod-details-route.harness'; + +/** + * What the page CLAIMS is playing. + * + * "Playing from ..." is a statement of fact about the stream on screen, and + * discovery marks a source active long before one exists — so the line has to + * be gated on playback rather than on selection. + */ +describe('VodDetailsRouteComponent — source caption', () => { + let fixture: ComponentFixture; + let restoreLogging: (() => void) | undefined; + const stubs = createVodDetailsRouteStubs(); + const { + activeSession, + addRecentItem, + checkFavoriteStatus, + closeSession, + constructVodStreamUrl, + currentPlaylist, + detailsError, + fetchVodDetailsWithMetadata, + getPlaybackPosition, + isFavorite, + isLoadingDetails, + selectedItem, + setSelectedItem, + toggleFavorite, + vodCategories, + vodStreams, + } = stubs; + + beforeEach(async () => { + restoreLogging = silenceRouteLogging(); + resetVodDetailsRouteStubs(stubs); + await configureVodDetailsRouteTestBed(stubs); + + fixture = TestBed.createComponent(VodDetailsRouteComponent); + }); + + afterEach(() => { + restoreLogging?.(); + }); + + /** + * Stand in for a discovered source list. + * + * The real one comes from a worker-backed discovery the route spec cannot + * reach, and every test here only needs "this row is the active one". + */ + function withActiveSource(playlistId: string, contentId: number): void { + Object.defineProperty( + fixture.componentInstance.multiSource, + 'sources', + { + configurable: true, + value: () => [ + { + id: `${playlistId}:xtream:${contentId}`, + playlistId, + playlistName: 'Portal One', + portalType: 'xtream', + contentId, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive: true, + isPinned: false, + isTried: true, + probe: { status: 'idle' }, + }, + ], + } + ); + } + + it('claims to be playing only while something is', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + withActiveSource('playlist-1', 650020); + + // Discovery marks a source active as soon as the page opens, so the + // caption would otherwise say "Playing from ..." before Play is + // pressed — and again after the player is closed. + expect(component.activeSourceCaption()).toBeNull(); + + // Mounting the player is a REQUEST to play. The stream may still be + // opening, or about to fail; naming a source now is a guess. + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + expect(component.activeSourceCaption()).toBeNull(); + + // The engine reporting time is the evidence that it really is playing. + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + + playback.inlinePlayback.set(null); + expect(component.activeSourceCaption()).toBeNull(); + }); + + it('waits for the next stream to start after Play is pressed again', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + // The inline path, or Restart would tear the host down and the test + // would pass on the player being gone rather than on the latch. + stubs.isEmbeddedPlayer.mockReturnValue(true); + withActiveSource('playlist-1', 650020); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + + // Restart keeps the same host mounted, so without clearing the latch + // the caption would carry the previous stream's claim into the new one. + component.playVod({ + movie_data: { stream_id: 650020, name: 'Example' }, + } as never); + expect(playback.inlinePlayback()).not.toBeNull(); + expect(component.activeSourceCaption()).toBeNull(); + }); + + it('waits again after a manual source switch', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + stubs.isEmbeddedPlayer.mockReturnValue(true); + withActiveSource('playlist-1', 650020); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + + // A switch mounts a DIFFERENT stream in the same host and marks the + // new source active at once, so the old stream's timeupdate must not + // vouch for it — it may still be opening, or never start at all. + // The exact callback `switchToSource` invokes once it has resolved a + // new source. Standing up the resolver here would test the host, not + // the route's half of the seam. + ( + component.multiSource as unknown as { + bindings: { + startPlayback: (playback: ResolvedPortalPlayback) => void; + }; + } + ).bindings.startPlayback({ + streamUrl: 'http://example.com/alt.mkv', + title: 'Example', + startTime: 3, + }); + // Replacing a running external player is a round-trip, so the handoff + // yields once before the new playback is mounted. + await Promise.resolve(); + + expect(playback.inlinePlayback()?.streamUrl).toBe( + 'http://example.com/alt.mkv' + ); + expect(component.activeSourceCaption()).toBeNull(); + + component.handleInlineTimeUpdate({ currentTime: 4, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + }); + + it('says nothing while an external player is still launching', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + withActiveSource('playlist-1', 650020); + + const session = (status: string) => ({ + player: 'mpv', + status, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + }, + }); + + // MPV has been asked to open; nothing is on screen yet. + activeSession.set(session('launching')); + expect(component.activeSourceCaption()).toBeNull(); + + // Its window is up, so the film really is playing — just not here. + activeSession.set(session('playing')); + expect(component.activeSourceCaption()).not.toBeNull(); + }); + + it('stops claiming playback once the error screen is up', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + withActiveSource('playlist-1', 650020); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + + // The host stays mounted through a failure, so the caption would go on + // naming a source while the diagnostic says it could not be played. + await component.onPlaybackFailed(); + expect(component.activeSourceCaption()).toBeNull(); + + // And comes back when the engine produces time again. + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + }); + + it('keeps the failure state when the chosen source will not resolve', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + withActiveSource('playlist-1', 650020); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + await component.onPlaybackFailed(); + expect(component.activeSourceCaption()).toBeNull(); + + // Picking an alternative off the error screen that cannot be resolved + // leaves the diagnostic up — so the caption must stay away too. + jest.spyOn(component.multiSource, 'play').mockResolvedValue(false); + component.playFromSource('playlist-2:xtream:991'); + await Promise.resolve(); + await Promise.resolve(); + + expect(component.activeSourceCaption()).toBeNull(); + }); +}); 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 new file mode 100644 index 000000000..3f7ef705e --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -0,0 +1,396 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { VodDetailsPlaybackService } from './vod-details-playback.service'; +import { VodDetailsRouteComponent } from './vod-details-route.component'; +import { + configureVodDetailsRouteTestBed, + createVodDetailsRouteStubs, + resetVodDetailsRouteStubs, + silenceRouteLogging, +} from './vod-details-route.harness'; + +/** + * What the primary button and the resume point do, as opposed to what the + * page renders. Split from the rendering spec to keep both inside the + * repository's file-size rule. + */ +describe('VodDetailsRouteComponent — playback actions', () => { + let fixture: ComponentFixture; + let restoreLogging: (() => void) | undefined; + const stubs = createVodDetailsRouteStubs(); + const { + currentPlaylist, + activeSession, + addRecentItem, + closeSession, + getPlaybackPosition, + selectedItem, + selectedPlayer, + snackBarOpen, + updateSettings, + } = stubs; + + beforeEach(async () => { + restoreLogging = silenceRouteLogging(); + resetVodDetailsRouteStubs(stubs); + await configureVodDetailsRouteTestBed(stubs); + + fixture = TestBed.createComponent(VodDetailsRouteComponent); + }); + + afterEach(() => { + restoreLogging?.(); + }); + + /** + * Stand in for a discovered source list. + * + * The real one comes from a worker-backed discovery the route spec cannot + * reach, and every test here only needs "this row is the active one". + */ + function withActiveSource(playlistId: string, contentId: number): void { + Object.defineProperty( + fixture.componentInstance.multiSource, + 'sources', + { + configurable: true, + value: () => [ + { + id: `${playlistId}:xtream:${contentId}`, + playlistId, + playlistName: 'Portal One', + portalType: 'xtream', + contentId, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive: true, + isPinned: false, + isTried: true, + probe: { status: 'idle' }, + }, + ], + } + ); + } + + 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. + withActiveSource('playlist-1', 4242); + 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({ + player: 'mpv', + status: 'playing', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + }, + }); + + const component = fixture.componentInstance; + const playPinned = jest.spyOn( + component.multiSource, + 'playPinnedSource' + ); + expect(component.isExternalStopAction()).toBe(true); + + await component.onPrimaryAction({} as XtreamVodDetails); + + // Consulting the pin first would launch a second player while the + // first keeps running — the control doing the opposite of its label. + expect(playPinned).not.toHaveBeenCalled(); + expect(closeSession).toHaveBeenCalled(); + }); + + it('follows external playback progress, which has no timeupdate', () => { + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + const reported = jest.spyOn(component.multiSource, 'reportPosition'); + + // MPV/VLC report only through the polled position, so this IS their + // live timecode. Treating it as a one-shot seed would freeze the + // resume point at the start and rewind a later source switch by + // however long the user had been watching. + playback.vodPlaybackPosition.set({ + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 7744, + }); + fixture.detectChanges(); + expect(reported).toHaveBeenLastCalledWith(120); + + playback.vodPlaybackPosition.set({ + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + positionSeconds: 3600, + durationSeconds: 7744, + }); + fixture.detectChanges(); + expect(reported).toHaveBeenLastCalledWith(3600); + }); + + it('holds the resume point until the engine has seeked to it', () => { + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie/650020.mp4', + title: 'City of McFarland', + startTime: 2538, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + }, + }); + const reported = jest.spyOn(component.multiSource, 'reportPosition'); + + // A resuming engine emits timeupdates at ~0 on its way to 2538. That + // is not where the film is, and multi-source must not switch or fail + // over back to the beginning because of it. + component.handleInlineTimeUpdate({ currentTime: 0.2, duration: 7744 }); + expect(reported).toHaveBeenLastCalledWith(2538); + + component.handleInlineTimeUpdate({ currentTime: 2540, duration: 7744 }); + expect(reported).toHaveBeenLastCalledWith(2540); + + // One-shot latch, not a filter: a deliberate seek backwards counts. + component.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 }); + expect(reported).toHaveBeenLastCalledWith(12); + }); + + it('drops the previous movie’s resume point on a reused route', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + fixture.detectChanges(); + playback.routePlaybackPosition.set({ + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + positionSeconds: 2538, + durationSeconds: 7744, + }); + + // The Similar rail reuses this component. Until the new lookup lands + // the button would offer the PREVIOUS movie's "Resume 42:18" — and + // start the new stream there. + currentPlaylist.set({ id: 'playlist-2' }); + fixture.detectChanges(); + + expect(playback.routePlaybackPosition()).toBeNull(); + expect(playback.hasPlaybackPosition()).toBe(false); + }); + + it('downloads the movie the route currently shows', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + fixture.detectChanges(); + + await fixture.componentInstance.downloadVod({ + // A DIFFERENT id in the payload: the route's id must win. + movie_data: { + stream_id: 111, + name: 'Example', + container_extension: 'mp4', + }, + } as never); + + // The id comes from the route params SIGNAL, not `snapshot.params`: + // the router reuses this component for detail-to-detail navigation + // (the Similar rail), and the snapshot still names the film the user + // came from — so the download would fetch the wrong movie. + expect(stubs.startDownload).toHaveBeenCalledWith( + expect.objectContaining({ xtreamId: 650020 }) + ); + }); + + it('replaces an alternative’s timecode when Resume starts the route copy', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + withActiveSource('playlist-1', 650020); + playback.routePlaybackPosition.set({ + playlistId: 'playlist-1', + contentXtreamId: 650020, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 7744, + }); + const reported = jest.spyOn(component.multiSource, 'reportPosition'); + + // An alternative left its own timecode in the controller. Resuming the + // ROUTE copy has to overwrite it, or a failure before the first + // timeupdate resolves the next source at a position from another copy. + component.multiSource.reportPosition(4200); + component.resumeVod({ + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, + } as never); + + expect(reported).toHaveBeenLastCalledWith(120); + }); + + it('does not start the route source when a newer action owns the screen', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + withActiveSource('playlist-1', 650020); + // Double-clicking Play while the pin is resolving: the second click + // supersedes the first, and the first must not conclude "no usable + // pin" and start the route source over what the second just began. + jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue( + 'superseded' + ); + const reported = jest.spyOn(component.multiSource, 'reportPosition'); + + await component.onPrimaryAction({ + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, + } as never); + + expect(reported).not.toHaveBeenCalled(); + }); + + it('takes the route wrappers when the primary button falls through', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + withActiveSource('playlist-1', 650020); + jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue( + 'unavailable' + ); + const reported = jest.spyOn(component.multiSource, 'reportPosition'); + + // Reaching the service directly would skip the bookkeeping a route + // start needs — the controller would keep an alternative's timecode. + void component.onPrimaryAction({ + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, + } as never); + + return Promise.resolve().then(() => { + expect(reported).toHaveBeenCalledWith(0); + }); + }); + + it('offers no resume point for a pinned copy watched through', async () => { + const component = fixture.componentInstance; + const resumeSecondsFor = component['msUi'].resumeSecondsFor; + + getPlaybackPosition.mockResolvedValue({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + positionSeconds: 6900, + durationSeconds: 7200, + }); + + // The button reads Play at 95%, so the start must mean zero — not a + // seek back to where the film ended. + await expect( + resumeSecondsFor({ + playlistId: 'playlist-2', + contentId: 991, + } as never) + ).resolves.toBeNull(); + }); + + it('still resumes a pinned copy left mid-film', async () => { + const component = fixture.componentInstance; + + getPlaybackPosition.mockResolvedValue({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + positionSeconds: 2538, + durationSeconds: 7200, + }); + + await expect( + component['msUi'].resumeSecondsFor({ + playlistId: 'playlist-2', + contentId: 991, + } as never) + ).resolves.toBe(2538); + }); + + it('restarts the pinned copy, not the route copy', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + withActiveSource('playlist-1', 650020); + // Resume honours the pin, so the Restart beside it must too — + // otherwise the button quietly switches the user's playlist. + Object.defineProperty(component['msUi'], 'primaryIsPinnedCopy', { + configurable: true, + value: () => true, + }); + const pinnedPlay = jest + .spyOn(component.multiSource, 'playPinnedSource') + .mockResolvedValue('played'); + + await component.restartVod({ + movie_data: { + stream_id: 650020, + name: 'Example', + container_extension: 'mp4', + }, + } as never); + + expect(pinnedPlay).toHaveBeenCalled(); + // Restart means zero, whichever copy it starts. + const resumeFor = pinnedPlay.mock.calls[0][0]; + await expect(resumeFor?.({} as never)).resolves.toBe(0); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 2fdb27db7..0db3123b6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -34,6 +34,19 @@ import { VodDetailsRouteComponent } from './vod-details-route.component'; }) class StubPortalInlinePlayerComponent { readonly playback = input(null); + // Multi-source wiring: the real player takes these, so the stub has to + // accept them or the template binding fails to compile. + readonly alternativeSources = input([]); + readonly allSources = input([]); + readonly sourcesMatchKind = input(null); + readonly sourcesAutoFailoverEnabled = input(false); + readonly sourcesAutoFailoverSupported = input(true); + readonly sourcesPlaybackLive = input(false); + readonly sourcesAutoFailoverToggled = output(); + readonly alternativeSourceRequested = output(); + readonly sourcePinRequested = output(); + readonly sourceCheckRequested = output(); + readonly playbackFailed = output(); readonly timeUpdate = output(); readonly closed = output(); readonly backClicked = output(); @@ -274,13 +287,18 @@ describe('VodDetailsRouteComponent fallback actions', () => { downloadsAvailable.set(true); fixture.detectChanges(); await fixture.whenStable(); - fixture.componentInstance.vodPlaybackPosition.set({ + const storedPosition = { contentXtreamId: 650020, contentType: 'vod', playlistId: 'playlist-1', positionSeconds: 42, durationSeconds: 120, - } satisfies PlaybackPositionData); + } satisfies PlaybackPositionData; + // Both, as `loadPosition` does: the Resume button reads the ROUTE + // copy's row, while `vodPlaybackPosition` follows whichever copy last + // reported — multi-source can put those on different streams. + fixture.componentInstance.vodPlaybackPosition.set(storedPosition); + fixture.componentInstance.routePlaybackPosition.set(storedPosition); fixture.detectChanges(); const host = fixture.nativeElement as HTMLElement; host.querySelector( diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index f7646e2f5..f32ebd009 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -29,7 +29,7 @@ @if (!isExternalStopAction()) { } } + + @if (multiSource.hasAlternatives()) { + + } + + @if (activeSourceCaption(); as caption) { +

+ + {{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }} + + {{ caption.source }} + @if (multiSource.hasAlternatives()) { + + @if (caption.alternativeCount === 1) { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE' + | translate + }} + } @else { + {{ + 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER' + | translate + : { count: caption.alternativeCount } + }} + } + + } +

+ } } @@ -115,6 +156,17 @@ @if (inlinePlayback(); as playback) { (null); private readonly backdropBackfillKey = signal(null); readonly inlinePlayback = this.playback.inlinePlayback; readonly vodPlaybackPosition = this.playback.vodPlaybackPosition; + /** The route copy's own row — what Resume acts on. */ + readonly routePlaybackPosition = this.playback.routePlaybackPosition; /** * Reactive route params: the component is reused when navigating @@ -191,6 +215,23 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ) ?? null ); }); + /** Movie identity for multi-source discovery; null until a title exists */ + private readonly multiSourceMovie = computed(() => + resolveVodMultiSourceMovie({ + playlistId: this.xtreamStore.currentPlaylist()?.id, + // `title` is the alias the Xtream data source actually writes + // (createPlaylist maps name -> title), so reading only `name` + // would fall back to the raw playlist UUID in the sources list. + playlistName: + this.xtreamStore.currentPlaylist()?.name ?? + this.xtreamStore.currentPlaylist()?.title, + vodId: this.selectedVodId(), + vodInfo: this.selectedVodInfo(), + catalogItem: this.selectedCatalogItem(), + containerExtension: + this.selectedItem()?.movie_data?.container_extension, + }) + ); readonly selectedVodInfo = computed(() => { const item = this.selectedItem(); return item && hasUsableXtreamVodMetadata(item) @@ -224,92 +265,78 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly externalPrimaryButtonState = this.playback.externalPrimaryButtonState; readonly vodPlaybackProgress = this.playback.vodPlaybackProgress; - readonly hasPlaybackPosition = this.playback.hasPlaybackPosition; - readonly isDownloaded = computed(() => { - const vodId = this.selectedVodId(); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return false; - this.downloadsService.downloads(); - return this.downloadsService.isDownloaded(vodId, playlistId, 'vod'); - }); + readonly hasPlaybackPosition = this.msUi.hasPlaybackPosition; - readonly isDownloading = computed(() => { - const vodId = this.selectedVodId(); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return false; - this.downloadsService.downloads(); - return this.downloadsService.isDownloading(vodId, playlistId, 'vod'); - }); - - readonly isPausedDownload = computed(() => { - const vodId = this.selectedVodId(); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return false; - this.downloadsService.downloads(); - return this.downloadsService.isPaused(vodId, playlistId, 'vod'); - }); + readonly isDownloaded = this.downloads.isDownloaded; + readonly isDownloading = this.downloads.isDownloading; + readonly isPausedDownload = this.downloads.isPausedDownload; readonly trailerEmbedUrl = computed(() => youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer) ); - /** TMDB recommendations matched against the loaded VOD catalog */ - readonly similarItems = computed(() => { - const info = this.selectedVodInfo(); - if (!info?.tmdb_recommendations?.length) { - return []; - } - return matchRecommendationsToCatalog( - info.tmdb_recommendations, - this.scopedVodStreams(), - { excludeId: this.selectedVodId() } - ); - }); + readonly similarItems = this.similar.similarItems; + readonly similarInPortals = this.similar.similarInPortals; - /** Recommendations found in the user's OTHER portals (Electron only) */ - private readonly crossPortalItems = signal([]); - readonly similarInPortals = computed(() => { - const localTitles = new Set( - this.similarItems().map( - (item) => normalizeTitleKeys(item.title).exact - ) - ); - return this.crossPortalItems().filter( - (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) - ); - }); - - private readonly loadCrossPortalSimilar = effect(() => { - const recommendations = this.selectedVodInfo()?.tmdb_recommendations; - const playlistId = this.xtreamStore.currentPlaylist()?.id; - untracked(() => { - this.crossPortalItems.set([]); - if ( - !recommendations?.length || - !this.crossPortalSimilar.isAvailable - ) { - return; - } - void this.crossPortalSimilar - .matchRecommendations(recommendations, 'movie', { - excludePlaylistId: playlistId, - }) - .then((items) => { - if ( - this.selectedVodInfo()?.tmdb_recommendations === - recommendations - ) { - this.crossPortalItems.set(items); - } - }); - }); - }); + /** + * The alternative the player is on, in playback's terms — null while the + * route's own source is playing, which the matcher already recognises. + */ constructor() { + this.downloads.bind({ routeContentId: this.selectedVodId }); + + this.similar.bind({ + vodInfo: this.selectedVodInfo, + routeContentId: this.selectedVodId, + }); + + this.msUi.bind({ + routeContentId: this.selectedVodId, + movieTitle: computed(() => this.multiSourceMovie()?.title ?? ''), + }); + this.playback.bind({ vodId: this.selectedVodId, vodInfo: this.selectedVodInfo, + activeSource: this.msUi.activeAlternativeSource, + }); + + effect(() => { + const position = this.playback.vodPlaybackPosition(); + if (!position) { + return; + } + + if (this.inlinePlayback()) { + // Seeding only: the inline player reports the live timecode + // itself, and this stored value lags it by up to the save + // throttle — applying it would rewind the switch. Before the + // first timeupdate there is nothing to protect, so a switch + // made straight off the Resume button still resumes. + this.multiSource.seedResumePosition(position.positionSeconds); + return; + } + + // MPV and VLC have no timeupdate to report; this polled position + // IS their live one, so a source switch after an hour in an + // external player must not rewind to where it started. + this.multiSource.reportPosition(position.positionSeconds); + }); + this.multiSource.bind({ + // Route every switch through the same inline-vs-external fork a + // normal Play uses, so the two paths cannot drift apart. + startPlayback: (playback) => { + // A switch mounts a DIFFERENT stream in the same host, so the + // evidence that the previous one was playing says nothing + // about this one — without clearing it the caption and the + // badge would claim the new source while it is still opening. + this.msUi.reset(); + void this.playback.startResolvedPlayback(playback); + }, + movie: this.multiSourceMovie, + playbackLive: this.playbackLive, }); // Initializes on first render and RE-initializes when the route @@ -324,7 +351,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.lastInitKey.set(initKey); this.inlinePlayback.set(null); + // Both, or the primary button keeps the previous movie's Resume + // label until the new lookup lands — and starts the new stream + // there. `loadPosition` is guarded on the same key, so an older + // lookup cannot repopulate either one. this.vodPlaybackPosition.set(null); + this.playback.routePlaybackPosition.set(null); + this.msUi.reset(); this.initializeVodDetails(playlistId, vodId); }); @@ -396,15 +429,78 @@ 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.multiSource.markRouteSourceActive(); + this.msUi.beginPlayback(); this.playback.playVod(vodItem); } + /** + * Restart from the beginning — of whatever the primary button acts on. + * + * When a pin points at another copy, Resume honours it, so Restart sitting + * beside it must too; calling `playVod` there would quietly switch the + * user to the route's playlist. + */ + async restartVod(vodItem: XtreamVodDetails | null): Promise { + if (this.msUi.primaryIsPinnedCopy()) { + const outcome = await this.multiSource.playPinnedSource(async () => + Promise.resolve(0) + ); + if (outcome !== 'unavailable') { + return; + } + } + + this.playVod(vodItem); + } + resumeVod(vodItem: XtreamVodDetails | null): void { + this.multiSource.markRouteSourceActive(); + this.msUi.beginPlayback(); + // The controller can still hold an ALTERNATIVE's timecode. A failure + // before the first timeupdate would otherwise resolve the next source + // at a position that belongs to a different copy. + this.multiSource.reportPosition( + this.playback.routePlaybackPosition()?.positionSeconds ?? 0 + ); this.playback.resumeVod(vodItem); } - onPrimaryAction(vodItem: XtreamVodDetails | null): void { - this.playback.onPrimaryAction(vodItem); + async onPrimaryAction(vodItem: XtreamVodDetails | null): Promise { + // When the button reads Stop, it stops. Consulting the pin first would + // make the control do the opposite of what it says — launching a + // second player while the first keeps running. + if (this.playback.isExternalStopAction()) { + this.playback.onPrimaryAction(vodItem); + return; + } + + // 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. + const pinned = await this.multiSource.playPinnedSource( + this.msUi.resumeSecondsFor + ); + // Only "no usable pin" falls through. A superseded attempt means a + // newer action already owns the screen — starting the route source + // here would override the playback that action just began. + if (pinned !== 'unavailable') { + return; + } + + // Through the route's OWN wrappers, not the service's: they carry the + // bookkeeping a route start needs — clearing the playback evidence and + // replacing whatever timecode an alternative left in the controller. + if (this.playback.hasPlaybackPosition()) { + this.resumeVod(vodItem); + return; + } + + this.playVod(vodItem); } stopExternalPlayback(): Promise { @@ -412,7 +508,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } formatPosition(): string { - return this.playback.formatPosition(); + return this.msUi.formatPosition(); } toggleFavorite(): void { @@ -442,11 +538,38 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playback.closeInlinePlayer(); } + readonly multiSourceTitle = this.msUi.multiSourceTitle; + readonly activeSourceCaption = this.msUi.activeSourceCaption; + + playFromSource(sourceId: string): void { + this.msUi.playFromSource(sourceId); + } + + pinSource(sourceId: string): void { + this.msUi.pinSource(sourceId); + } + + checkSource(sourceId: string): void { + this.msUi.checkSource(sourceId); + } + + readonly autoFailoverSupported = this.msUi.autoFailoverSupported; + + setAutoFailover(enabled: boolean): void { + this.msUi.setAutoFailover(enabled); + } + + onPlaybackFailed(): Promise { + return this.msUi.onPlaybackFailed(); + } + + readonly playbackLive = this.msUi.playbackLive; + handleInlineTimeUpdate(event: { currentTime: number; duration: number; }): void { - this.playback.handleInlineTimeUpdate(event); + this.msUi.handleInlineTimeUpdate(event); } showCopyNotification(): void { @@ -463,70 +586,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playback.handleExternalFallbackRequest(request); } - async resumePausedDownload(): Promise { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) { - return; - } - await this.downloadsService.resumeDownloadByContent( - this.selectedVodId(), - playlistId, - 'vod' - ); + resumePausedDownload(): Promise { + return this.downloads.resumePaused(); } - async downloadVod(vodItem: XtreamVodDetails | null): Promise { - if (!vodItem) { - return; - } - - const source = resolveXtreamVodPlaybackSource(vodItem); - if (!source) { - return; - } - - const presentation = resolveXtreamVodPlaybackPresentation(vodItem); - const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); - const routeVodId = Number(this.route.snapshot.params.vodId); - const id = - Number.isSafeInteger(routeVodId) && routeVodId > 0 - ? routeVodId - : source.streamId; - - const playlist = this.xtreamStore.currentPlaylist(); - if (!playlist) { - return; - } - - await this.downloadsService.startDownload({ - playlistId: playlist.id, - xtreamId: id, - contentType: 'vod', - title: presentation.title, - url: streamUrl, - posterUrl: presentation.posterUrl, - headers: { - userAgent: playlist.userAgent, - referer: playlist.referrer, - origin: playlist.origin, - }, - }); + downloadVod(vodItem: XtreamVodDetails | null): Promise { + return this.downloads.start(vodItem); } - async playFromLocal(): Promise { - const vodId = Number(this.route.snapshot.params.vodId); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return; - - const filePath = this.downloadsService.getDownloadedFilePath( - vodId, - playlistId, - 'vod' - ); - - if (filePath) { - await this.downloadsService.playDownload(filePath); - } + playFromLocal(): Promise { + return this.downloads.playLocal(); } private initializeVodDetails(playlistId: string, vodId: number): void { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts new file mode 100644 index 000000000..4b1125acc --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -0,0 +1,228 @@ +import { Location } from '@angular/common'; +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { DownloadsService, SettingsStore } from '@iptvnator/services'; +import { + VideoPlayer, + XtreamCategory, + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { VodDetailsRouteComponent } from './vod-details-route.component'; + +/** + * The TestBed every VOD-details route spec needs. + * + * The route pulls in the store, both playback ports, downloads, settings and + * the router, so standing it up costs ~150 lines. Three specs carried their + * own near-identical copy of that, which is how they drifted and how each one + * ran into the file-size rule. One harness, one set of stubs. + */ + +/** Every stub the harness installs, so specs can drive and assert on them. */ +export function createVodDetailsRouteStubs() { + return { + selectedItem: signal(null), + isLoadingDetails: signal(false), + detailsError: signal(null), + isFavorite: signal(false), + currentPlaylist: signal<{ + id: string; + userAgent?: string; + referrer?: string; + origin?: string; + } | null>(null), + vodStreams: signal[]>([]), + vodCategories: signal[]>([]), + fetchVodDetailsWithMetadata: jest.fn(), + checkFavoriteStatus: jest.fn(), + setSelectedItem: jest.fn(), + toggleFavorite: jest.fn(), + constructVodStreamUrl: jest + .fn() + .mockReturnValue('http://example.com/movie/650020.mp4'), + addRecentItem: jest.fn(), + cancelDetailsRequest: jest.fn(), + vodStreamsPlaylistId: signal(null), + downloads: signal([]), + getPlaybackPosition: jest.fn().mockResolvedValue(null), + savePlaybackPosition: jest.fn().mockResolvedValue(undefined), + activeSession: signal(null), + closeSession: jest.fn(), + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback: jest.fn(), + snackBarOpen: jest.fn(), + startDownload: jest.fn().mockResolvedValue(undefined), + locationBack: jest.fn(), + selectedPlayer: signal(VideoPlayer.Html5Player), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; +} + +export type VodDetailsRouteStubs = ReturnType< + typeof createVodDetailsRouteStubs +>; + +/** Back to the state a fresh `beforeEach` expects. */ +export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void { + stubs.selectedItem.set(null); + stubs.isLoadingDetails.set(false); + stubs.detailsError.set(null); + stubs.isFavorite.set(false); + stubs.currentPlaylist.set(null); + stubs.vodStreams.set([]); + stubs.vodCategories.set([]); + stubs.activeSession.set(null); + stubs.selectedPlayer.set(VideoPlayer.Html5Player); + stubs.updateSettings.mockClear().mockResolvedValue(undefined); + + for (const value of Object.values(stubs)) { + if (jest.isMockFunction(value) && value !== stubs.updateSettings) { + value.mockClear(); + } + } +} + +/** + * Silences the route's own debug chatter without hiding anything else — a + * genuine warning from the component under test must still reach the console. + */ +export function silenceRouteLogging(): () => void { + const consoleDebug = console.debug.bind(console); + const consoleWarn = console.warn.bind(console); + const debugSpy = jest + .spyOn(console, 'debug') + .mockImplementation((...args: unknown[]) => { + if ( + args[0] === '[VodDetailsRoute]' || + args[0] === '[VodDetailsPlayback]' + ) { + return; + } + + consoleDebug(...args); + }); + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation((...args: unknown[]) => { + if ( + args[0] === '[VodDetailsRoute]' && + args[1] === 'Deferring VOD details init: playlist not ready' + ) { + return; + } + + consoleWarn(...args); + }); + + return () => { + debugSpy.mockRestore(); + warnSpy.mockRestore(); + }; +} + +export async function configureVodDetailsRouteTestBed( + stubs: VodDetailsRouteStubs +): Promise { + await TestBed.configureTestingModule({ + imports: [VodDetailsRouteComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({ vodId: '650020', categoryId: '235' }), + snapshot: { + params: { vodId: '650020', categoryId: '235' }, + }, + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + { + provide: XtreamStore, + useValue: { + selectedItem: stubs.selectedItem, + isLoadingDetails: stubs.isLoadingDetails, + detailsError: stubs.detailsError, + isFavorite: stubs.isFavorite, + currentPlaylist: stubs.currentPlaylist, + vodStreams: stubs.vodStreams, + vodCategories: stubs.vodCategories, + fetchVodDetailsWithMetadata: + stubs.fetchVodDetailsWithMetadata, + checkFavoriteStatus: stubs.checkFavoriteStatus, + setSelectedItem: stubs.setSelectedItem, + toggleFavorite: stubs.toggleFavorite, + constructVodStreamUrl: stubs.constructVodStreamUrl, + addRecentItem: stubs.addRecentItem, + cancelDetailsRequest: stubs.cancelDetailsRequest, + vodStreamsPlaylistId: stubs.vodStreamsPlaylistId, + }, + }, + { + provide: SettingsStore, + useValue: { + theme: signal('dark'), + player: stubs.selectedPlayer, + updateSettings: stubs.updateSettings, + }, + }, + { + provide: DownloadsService, + useValue: { + isAvailable: signal(false), + downloads: stubs.downloads, + isDownloaded: jest.fn().mockReturnValue(false), + isDownloading: jest.fn().mockReturnValue(false), + startDownload: stubs.startDownload, + getDownloadedFilePath: jest.fn(), + playDownload: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: stubs.activeSession, + closeSession: stubs.closeSession, + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: stubs.getPlaybackPosition, + savePlaybackPosition: stubs.savePlaybackPosition, + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: stubs.isEmbeddedPlayer, + openResolvedPlayback: stubs.openResolvedPlayback, + }, + }, + { provide: MatSnackBar, useValue: { open: stubs.snackBarOpen } }, + { provide: Location, useValue: { back: stubs.locationBack } }, + ], + }).compileComponents(); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts new file mode 100644 index 000000000..b05d62baa --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts @@ -0,0 +1,120 @@ +import { + Injectable, + Signal, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, +} from '@iptvnator/services'; +import { + normalizeTitleKeys, + XtreamVodInfo, +} from '@iptvnator/shared/interfaces'; +import { + SimilarCatalogItem, + matchRecommendationsToCatalog, +} from '../tmdb-similar.util'; + +/** + * The "Similar" rail on the VOD details page. + * + * Two sources feed it: TMDB recommendations matched against the catalog this + * playlist already loaded, and — in Electron — the same recommendations + * matched across every OTHER imported playlist. The second list is filtered + * against the first so one film does not appear twice under different portals. + * + * Component-provided: the cross-portal lookup is per-movie and its result must + * die with the page rather than leak into the next film. + */ +@Injectable() +export class VodDetailsSimilarService { + private readonly xtreamStore = inject(XtreamStore); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + + private vodInfo: Signal = signal(null); + private routeContentId: Signal = signal(NaN); + + bind(bindings: { + vodInfo: Signal; + routeContentId: Signal; + }): void { + this.vodInfo = bindings.vodInfo; + this.routeContentId = bindings.routeContentId; + } + + /** + * The catalog only when it belongs to the playlist on screen — otherwise + * a rail would match against another portal's streams. + */ + private readonly scopedVodStreams = computed(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + return playlistId && + this.xtreamStore.vodStreamsPlaylistId() === playlistId + ? this.xtreamStore.vodStreams() + : []; + }); + + /** TMDB recommendations matched against the loaded VOD catalog. */ + readonly similarItems = computed(() => { + const info = this.vodInfo(); + if (!info?.tmdb_recommendations?.length) { + return []; + } + + return matchRecommendationsToCatalog( + info.tmdb_recommendations, + this.scopedVodStreams(), + { excludeId: this.routeContentId() } + ); + }); + + /** Recommendations found in the user's OTHER portals (Electron only). */ + private readonly crossPortalItems = signal([]); + + readonly similarInPortals = computed(() => { + const localTitles = new Set( + this.similarItems().map( + (item) => normalizeTitleKeys(item.title).exact + ) + ); + + return this.crossPortalItems().filter( + (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) + ); + }); + + private readonly loadCrossPortalSimilar = effect(() => { + const recommendations = this.vodInfo()?.tmdb_recommendations; + const playlistId = this.xtreamStore.currentPlaylist()?.id; + + untracked(() => { + this.crossPortalItems.set([]); + if ( + !recommendations?.length || + !this.crossPortalSimilar.isAvailable + ) { + return; + } + + void this.crossPortalSimilar + .matchRecommendations(recommendations, 'movie', { + excludePlaylistId: playlistId, + }) + .then((items) => { + // The user can open another film across the lookup, and + // the rail must not describe the one they left. + if ( + this.vodInfo()?.tmdb_recommendations === recommendations + ) { + this.crossPortalItems.set(items); + } + }); + }); + }); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.spec.ts new file mode 100644 index 000000000..9bfc2047a --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.spec.ts @@ -0,0 +1,76 @@ +import { signal } from '@angular/core'; +import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; +import { createVodSourceCounts } from './vod-multi-source-counts'; + +function source( + id: string, + playlistId: string, + isActive = false +): VodSourceDescriptor { + return { + id, + playlistId, + playlistName: playlistId, + portalType: 'xtream', + contentId: 1, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive, + isPinned: false, + isTried: false, + probe: { status: 'idle' }, + } as VodSourceDescriptor; +} + +describe('createVodSourceCounts', () => { + it('counts alternative streams for the chip', () => { + const counts = createVodSourceCounts( + signal([ + source('a', 'playlist-1', true), + source('b', 'playlist-2'), + source('c', 'playlist-2'), + ]) + ); + + expect(counts.alternativeCount()).toBe(2); + expect(counts.hasAlternatives()).toBe(true); + }); + + it('counts OTHER playlists for the caption', () => { + // The caption says "also found in N other playlists", so a playlist's + // two copies are one playlist. + const counts = createVodSourceCounts( + signal([ + source('a', 'playlist-1', true), + source('b', 'playlist-2'), + source('c', 'playlist-2'), + ]) + ); + + expect(counts.alternativePlaylistCount()).toBe(1); + }); + + it('does not count the playlist being watched as another', () => { + // Playing one of playlist-2's two copies leaves the other listed as an + // alternative, which put playlist-2's own id in the set. + const counts = createVodSourceCounts( + signal([ + source('a', 'playlist-1'), + source('b', 'playlist-2', true), + source('c', 'playlist-2'), + ]) + ); + + expect(counts.alternativePlaylistCount()).toBe(1); + }); + + it('has nothing to offer for a single source', () => { + const counts = createVodSourceCounts( + signal([source('a', 'playlist-1', true)]) + ); + + expect(counts.hasAlternatives()).toBe(false); + expect(counts.alternativePlaylistCount()).toBe(0); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.ts new file mode 100644 index 000000000..a49f0a4bc --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-counts.ts @@ -0,0 +1,47 @@ +import { Signal, computed } from '@angular/core'; +import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; + +/** + * What the chip and the caption count. + * + * Kept apart because the two numbers differ and the distinction is easy to + * lose: the chip counts STREAMS, while the caption's "also found in N others" + * counts PLAYLISTS — the popover groups a playlist's copies under it, so three + * copies inside one portal must not read as three portals. + */ +export interface VodSourceCounts { + /** Every source except the one in use. */ + alternatives: Signal; + /** The chip appears only when there is genuinely somewhere else to go. */ + hasAlternatives: Signal; + alternativeCount: Signal; + alternativePlaylistCount: Signal; +} + +export function createVodSourceCounts( + sources: Signal +): VodSourceCounts { + const alternatives = computed(() => + sources().filter((source) => !source.isActive) + ); + + return { + alternatives, + hasAlternatives: computed(() => alternatives().length > 0), + alternativeCount: computed(() => alternatives().length), + alternativePlaylistCount: computed(() => { + const playlists = new Set( + alternatives().map((source) => source.playlistId) + ); + // A playlist holding two copies puts its own id in that set via + // the copy that is not playing, and the caption would then count + // the playlist the user is already watching as an "other". + const active = sources().find((source) => source.isActive); + if (active) { + playlists.delete(active.playlistId); + } + + return playlists.size; + }), + }; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.spec.ts new file mode 100644 index 000000000..9bd08742b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.spec.ts @@ -0,0 +1,103 @@ +import { audioDiffersFactually } from '@iptvnator/portal/shared/data-access'; +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import { currentSourceRow } from './vod-multi-source-current-row'; +import { resolveVodMultiSourceMovie } from './vod-multi-source-identity'; + +/** + * The row standing for the source the film is already playing from. + * + * It is never resolved — nothing needs to fetch a URL for the stream already on + * screen — so it is the one row that can silently hold no provider facts, and + * every comparison against it then has nothing on one side. + */ + +const MOVIE = { + playlistId: 'playlist-1', + playlistName: 'Portal One', + vodId: 101, + catalogItem: { title: 'Dune' }, +}; + +function alternative(language: string): VodSourceCandidate { + return { + id: 'playlist-2:xtream:991', + playlistId: 'playlist-2', + playlistName: 'Portal Two', + portalType: 'xtream', + contentId: 991, + rawTitle: 'Dune', + matchConfidence: 'exact', + year: null, + // Already canonical, as `applyApiMetadata` would have produced. + audioLanguage: { value: language, provenance: 'api' }, + } as VodSourceCandidate; +} + +describe('currentSourceRow', () => { + it('states no facts before get_vod_info has landed', () => { + const movie = resolveVodMultiSourceMovie({ ...MOVIE, vodInfo: null }); + + const row = currentSourceRow(movie as never); + + // Silence is the honest reading of "not known yet" — a guess here + // would be published with `api` provenance. + expect(row.audio).toBeUndefined(); + expect(row.container).toBeUndefined(); + expect(row.quality).toBeUndefined(); + }); + + it('carries the provider facts the route already loaded', () => { + const movie = resolveVodMultiSourceMovie({ + ...MOVIE, + vodInfo: { + name: 'Dune', + audio: { codec_name: 'ac3' }, + video: { codec_name: 'h264', width: 1920, height: 1080 }, + } as never, + containerExtension: 'mkv', + }); + + const row = currentSourceRow(movie as never); + + // Only the video codecs have canonical spellings; an audio name is + // passed through as the provider wrote it. + expect(row.audio).toEqual({ value: 'ac3', provenance: 'api' }); + expect(row.container).toEqual({ value: 'mkv', provenance: 'api' }); + expect(row.quality).toEqual({ value: '1080p', provenance: 'api' }); + }); + + it('lets the dub warning fire on a route-to-alternative switch', () => { + const movie = resolveVodMultiSourceMovie({ + ...MOVIE, + vodInfo: { + name: 'Dune', + audio: { codec_name: 'ac3', tags: { language: 'rus' } }, + } as never, + }); + + const row = currentSourceRow(movie as never); + + // `audioDiffersFactually` needs a fact on BOTH sides, so an empty + // route row made the warning structurally unreachable on the + // commonest switch there is — route to alternative. It only ever + // fired between two alternatives that had both been resolved. + expect(audioDiffersFactually(row, alternative('en'))).toBe(true); + expect(audioDiffersFactually(row, alternative('ru'))).toBe(false); + }); + + it('does not call a codec change a dub change', () => { + const movie = resolveVodMultiSourceMovie({ + ...MOVIE, + vodInfo: { name: 'Dune', audio: { codec_name: 'ac3' } } as never, + }); + + const row = currentSourceRow(movie as never); + + // The row states a codec and nothing about language. AAC and AC3 + // routinely carry the same dub, so there is nothing to warn about — + // and warning anyway trains people to ignore the one that matters. + expect(row.audio?.value).toBe('ac3'); + expect(row.audioLanguage).toBeUndefined(); + expect(audioDiffersFactually(row, alternative('en'))).toBe(false); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts new file mode 100644 index 000000000..84a7aa313 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts @@ -0,0 +1,36 @@ +import { applyApiMetadata } from '@iptvnator/portal/shared/data-access'; +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import type { VodMultiSourceMovie } from './vod-multi-source-identity'; + +/** + * The row standing for the source the route is already playing. + * + * Discovery only returns OTHER playlists, so without this the popover would + * list the alternatives and say nothing about where the film is coming from + * right now — and the "playing" badge would have nothing to attach to. + * + * It carries the provider's stated facts when the page has them. An + * alternative gets its facts from the resolve that precedes playing it, but + * this row is never resolved — the film is already playing from it — so + * without this it held no metadata at all. Everything comparing two sources + * then had nothing on one side: `audioDiffersFactually` in particular returns + * false whenever either side is silent, so the "dub may differ" warning could + * not fire on a route-to-alternative switch, which is the commonest one there + * is. + */ +export function currentSourceRow( + movie: VodMultiSourceMovie +): VodSourceCandidate { + const row: VodSourceCandidate = { + id: `${movie.playlistId}:xtream:${movie.contentId}`, + playlistId: movie.playlistId, + playlistName: movie.playlistName, + portalType: 'xtream', + contentId: movie.contentId, + rawTitle: movie.title, + matchConfidence: 'exact', + year: movie.year ?? null, + }; + + return movie.metadata ? applyApiMetadata(row, movie.metadata) : row; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts new file mode 100644 index 000000000..6dcbe4a0b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts @@ -0,0 +1,294 @@ +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, + MOVIE_B, + PROBE_OK, + createDeferred, + resolveWith, +} from './vod-multi-source-host.fixtures'; + +/** + * What the database ends up holding. + * + * The row on screen is a promise that the preference survives reopening the + * movie, so a write that did not land must not be shown as one — and the + * aliases a movie can be looked up by are not all safe to write to or delete. + */ +describe('VodMultiSourceHostService — pin persistence', () => { + let service: VodMultiSourceHostService; + + const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); + 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 { + 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, playbackLive }) + ); + }); + + it('lets a pin made during a rediscovery win over the old one', 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]); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + // Enrichment reruns discovery for the same film... + const slow = createDeferred<{ + sources: VodSourceCandidate[]; + matchKind: string; + }>(); + discovery.discover.mockReturnValueOnce(slow.promise); + const reloading = service.load({ ...MOVIE_A, tmdbId: 603 }); + while (discovery.discover.mock.calls.length < 2) { + await Promise.resolve(); + } + + // ...and the user pins a different source while it is still out. + await service.togglePin(ALT_THREE.id); + + slow.resolve({ + sources: [ALT_TWO, ALT_THREE], + matchKind: 'title-year', + }); + await reloading; + + // The snapshot that rerun started with is now stale. Restoring it + // would leave the row and Play on a source the database no longer + // holds. + expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + it('stores itself under its own keys, never under a remake’s', async () => { + // With a TMDB id there are two keys naming this film and one shared + // with every remake, so both lists are worth asserting on. + await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 }); + expect(pins.get.mock.calls[0][0]).toEqual([ + 'tmdb:603', + 'title:the matrix:1999', + 'title:the matrix:', + ]); + + await service.togglePin(ALT_TWO.id); + + expect(pins.set).toHaveBeenCalledWith( + expect.objectContaining({ matchKey: 'tmdb:603' }), + expect.any(Array), + expect.any(Array) + ); + // Both lists ride along with the write, so none of it can half-apply. + const [, retired, aliases] = pins.set.mock.calls[0] as [ + unknown, + string[], + string[], + ]; + // This film's pre-enrichment key takes the SAME decision instead of + // being deleted: a reopen that has only a title and a year still finds + // the pin, and finds this source rather than the one just replaced. + expect(aliases).toContain('title:the matrix:1999'); + expect(retired).not.toContain('title:the matrix:1999'); + // The yearless form is shared by every remake: a Dune (2021) pin must + // not answer for — or delete — a row that may be Dune (1984)'s. + expect(aliases).not.toContain('title:the matrix:'); + expect(retired).not.toContain('title:the matrix:'); + // And never the row just written. + expect(retired).not.toContain('tmdb:603'); + }); + + it('survives a reopen that has not been enriched yet', async () => { + // The whole point of the pin is that it outlives the page. Enrichment + // is asynchronous and optional, so the movie that owned a `tmdb:` key + // when the user pinned it opens again as nothing but a title and a + // year — and asks for the pin under THAT identity. + const rows = new Map(); + pins.set.mockImplementation( + async ( + pin: { matchKey: string }, + retire: string[] = [], + aliases: string[] = [] + ) => { + for (const key of [pin.matchKey, ...aliases]) { + rows.set(key, { ...pin, matchKey: key }); + } + for (const key of retire) { + rows.delete(key); + } + return true; + } + ); + pins.get.mockImplementation(async (keys: string[]) => { + for (const key of keys) { + if (rows.has(key)) { + return rows.get(key); + } + } + return null; + }); + + await loadMovie([ALT_TWO, ALT_THREE], { ...MOVIE_A, tmdbId: 603 }); + await service.togglePin(ALT_THREE.id); + expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true); + + // Away to another film and back. That is what makes this a REOPEN: + // the session key is (playlist, content), so a different movie is what + // drops the in-memory controller and forces the pin to be re-read from + // storage — the same thing reopening the page does. + await loadMovie([], MOVIE_B); + await loadMovie([ALT_TWO, ALT_THREE], MOVIE_A); + + // MOVIE_A carries no TMDB id, so this lookup asks only for the title + // forms. Stored under the enriched key alone the preference would be + // invisible here, and Play would quietly start the route source. + expect(pins.get).toHaveBeenLastCalledWith([ + 'title:the matrix:1999', + 'title:the matrix:', + ]); + expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true); + }); + + it('keeps the stored pin when the replacement write fails', async () => { + await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 }); + pins.set.mockResolvedValue(false); + + await service.togglePin(ALT_TWO.id); + + // One transaction: a refused write retires nothing either. + expect(pins.clear).not.toHaveBeenCalled(); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + 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); + + 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('retires the aliases in the same call that writes the pin', async () => { + await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 }); + + await service.togglePin(ALT_TWO.id); + + // Previously a write followed by a clear: if the clear failed, the + // surviving alias was read BEFORE the canonical key on the next open + // and started the source the user had just replaced. Neither + // half-outcome had an honest answer, so there is only one call now. + expect(pins.set).toHaveBeenCalledTimes(1); + expect(pins.clear).not.toHaveBeenCalled(); + const [, , aliases] = pins.set.mock.calls[0] as [ + unknown, + string[], + string[], + ]; + expect(aliases).toContain('title:the matrix:1999'); + }); + + 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); + }); +}); 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 new file mode 100644 index 000000000..7e8783ba4 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -0,0 +1,344 @@ +import { + ALT_THREE, + ALT_TWO, + CURRENT_A_ID, + MOVIE_A, + createDeferred, + setupVodMultiSourceHost, +} 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', () => { + const harness = setupVodMultiSourceHost(); + const { + discovery, + pins, + playbackLive, + resolver, + startPlayback, + vodAutoFailover, + } = harness; + const loadMovie = harness.loadMovie; + const rowFor = harness.rowFor; + let service = harness.service; + + beforeEach(() => { + service = harness.reset(); + }); + + 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('played'); + 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('played'); + + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 2538 } + ); + }); + + 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( + 'played' + ); + + expect(resumeFor).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }) + ); + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 3600 } + ); + }); + + it('honours the pin again after its player is closed', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + await service.play(ALT_TWO.id); + + // Playing it makes its row active AND live: Play must not relaunch + // what is already on screen. + playbackLive.set(true); + expect(service.pendingPinnedSourceId()).toBeNull(); + + // Closing the player leaves the row selected. Reading `isActive` + // alone here sends the next Play to the route copy and ignores the + // stored preference until the page is reopened. + playbackLive.set(false); + expect(service.pendingPinnedSourceId()).toBe(ALT_TWO.id); + }); + + it('restarts a pinned copy that was watched through', 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); + + // The caller applies the same in-progress rule the button uses, so a + // finished copy arrives here as null. Anything else would show Play + // and then start near the end. + await expect( + service.playPinnedSource(jest.fn().mockResolvedValue(null)) + ).resolves.toBe('played'); + + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 0 } + ); + }); + + it('starts a pinned source that was never watched from the beginning', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + // The position the PAGE loaded belongs to the route's copy. + service.seedResumePosition(2538); + + await expect( + service.playPinnedSource(jest.fn().mockResolvedValue(null)) + ).resolves.toBe('played'); + + // Positions are keyed by (playlist, stream). Carrying the route copy's + // timecode into a copy the user has never opened drops them 42 minutes + // into an unstarted film — and the first save writes it back under + // this source's key, making the mistake permanent. + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 0 } + ); + }); + + it('leaves the position alone when the host cannot look one up', 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); + + // No lookup was performed, so "never watched" was never established — + // zeroing here would throw away a position nobody contradicted. + await expect(service.playPinnedSource()).resolves.toBe('played'); + + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 2538 } + ); + }); + + it('lists the route’s own copy once when the pin keeps it', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: MOVIE_A.playlistId, + contentId: MOVIE_A.contentId, + portalType: 'xtream', + }); + + // Pinning the copy the route is on makes discovery return that very + // row, and prepending the current source again would list one stream + // twice — a phantom copy in the grouping and a chip that counts it. + await loadMovie([ + { + ...ALT_TWO, + id: CURRENT_A_ID, + playlistId: MOVIE_A.playlistId, + contentId: MOVIE_A.contentId, + }, + ]); + + expect(service.sources().map((source) => source.id)).toEqual([ + CURRENT_A_ID, + ]); + expect(service.hasAlternatives()).toBe(false); + }); + + it('hands the playing badge back when the route stream starts', async () => { + await loadMovie([ALT_TWO]); + await expect(service.play(ALT_TWO.id)).resolves.toBe(true); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + + // The user closed that player and pressed Play. The route's own stream + // is what runs now, so the picker and caption must not keep naming the + // alternative as "Playing". + service.markRouteSourceActive(); + + expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(false); + }); + + it('waits for the stored pin before letting Play fall through', async () => { + const slow = createDeferred<{ + sources: VodSourceCandidate[]; + matchKind: string; + }>(); + discovery.discover.mockReturnValueOnce(slow.promise); + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + + const loading = service.load(MOVIE_A); + + // Play pressed while the lookup is still out. Answering "nothing is + // pinned" here would start the route's own source and make a persisted + // preference depend on worker latency. + const playing = service.playPinnedSource(); + slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' }); + await loading; + + await expect(playing).resolves.toBe('played'); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + }); + + it('leaves Play alone when nothing is pinned', async () => { + await loadMovie([ALT_TWO]); + + await expect(service.playPinnedSource()).resolves.toBe('unavailable'); + 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); + + // Both key lists travel with the write, in one transaction. This film + // has no TMDB id, so its title key IS the canonical one and there is + // nothing further to store it under or retire. + expect(pins.set).toHaveBeenCalledWith(pin, [], []); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + await service.togglePin(ALT_TWO.id); + + expect(pins.clear).toHaveBeenCalled(); + // Unpinning writes nothing further. + 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); + }); +}); 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 new file mode 100644 index 000000000..8a89de854 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -0,0 +1,361 @@ +import { TestBed } from '@angular/core/testing'; +import { + ALT_THREE, + ALT_TWO, + MOVIE_A, + MOVIE_B, + createDeferred, + resolveWith, + resolvedFor, + setupVodMultiSourceHost, +} from './vod-multi-source-host.fixtures'; + +describe('VodMultiSourceHostService — stale resolutions', () => { + const harness = setupVodMultiSourceHost(); + const { + discovery, + movie, + pins, + probes, + resolver, + startPlayback, + vodAutoFailover, + } = harness; + const loadMovie = harness.loadMovie; + const rowFor = harness.rowFor; + let service = harness.service; + + /** Two consecutive switches, so the "previous" side carries real audio. */ + + beforeEach(() => { + service = harness.reset(); + }); + + it('drops a switch once the movie identity goes null mid-navigation', async () => { + await loadMovie([ALT_TWO]); + + const slow = createDeferred>(); + resolver.resolve.mockReturnValueOnce(slow.promise); + + const pending = service.play(ALT_TWO.id); + + // Route navigation empties the identity BEFORE the next movie's load() + // runs. Without invalidating the session here, the guard would still + // pass and the old movie's source would start over the new page. + movie.set(null); + TestBed.tick(); + + slow.resolve(resolvedFor(ALT_TWO, 0)); + await expect(pending).resolves.toBe(false); + + expect(startPlayback).not.toHaveBeenCalled(); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(false); + }); + + it('lets a source picked during the pinned lookup win', 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]); + + const lookup = createDeferred(); + let insideLookup = false; + const pinnedPlay = service.playPinnedSource(() => { + insideLookup = true; + return lookup.promise; + }); + while (!insideLookup) { + await Promise.resolve(); + } + + // The user picks a different source while the pinned resume position + // is still being read. + await service.play(ALT_THREE.id); + startPlayback.mockClear(); + lookup.resolve(0); + + // The older pinned attempt must not finish last and replace it. + await expect(pinnedPlay).resolves.toBe('superseded'); + expect(startPlayback).not.toHaveBeenCalled(); + expect(rowFor(ALT_THREE.id)?.isActive).toBe(true); + }); + + it('tells a superseded pinned play apart from an unusable pin', 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]); + + const slow = createDeferred>(); + resolver.resolve.mockReturnValueOnce(slow.promise); + const pinnedPlay = service.playPinnedSource(); + + // Wait until the pinned play is genuinely inside its resolve, so the + // second click is what supersedes it rather than a race on setup. + while (resolver.resolve.mock.calls.length === 0) { + await Promise.resolve(); + } + + // A second Play (double-click) supersedes the first while it resolves. + await service.play(ALT_THREE.id); + slow.resolve(resolvedFor(ALT_TWO, 0)); + + // Reporting "unavailable" here would have the caller start the ROUTE + // source over the playback the newer click just began. + await expect(pinnedPlay).resolves.toBe('superseded'); + }); + + it('drops a slower switch that a newer selection already superseded', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + + const slow = createDeferred>(); + resolver.resolve + .mockReturnValueOnce(slow.promise) + .mockImplementation(resolveWith()); + + // User clicks ALT_TWO, then changes their mind and clicks + // ALT_THREE before the first resolution comes back. + const first = service.play(ALT_TWO.id); + const second = await service.play(ALT_THREE.id); + expect(second).toBe(true); + + slow.resolve(resolvedFor(ALT_TWO, 0)); + await expect(first).resolves.toBe(false); + + // The late arrival must not steal the active source back, nor + // repoint Undo at itself. + expect(rowFor(ALT_THREE.id)?.isActive).toBe(true); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(false); + expect(startPlayback).toHaveBeenLastCalledWith( + expect.objectContaining({ + streamUrl: expect.stringContaining(String(ALT_THREE.contentId)), + }) + ); + }); + + it('drops a switch whose movie was navigated away from', async () => { + await loadMovie([ALT_TWO]); + service.reportPosition(2538); + + const slow = createDeferred>(); + resolver.resolve.mockReturnValueOnce(slow.promise); + + const pending = service.play(ALT_TWO.id); + + // A different movie opens while the resolution is still in flight. + await loadMovie([ALT_TWO], MOVIE_B); + startPlayback.mockClear(); + + slow.resolve(resolvedFor(ALT_TWO, 2538)); + await expect(pending).resolves.toBe(false); + + // The old film's source must not start inside the new session — + // that would also restart it from the new session's zero position. + expect(startPlayback).not.toHaveBeenCalled(); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(false); + }); + + it('keeps a switch alive when the same movie is rediscovered', async () => { + await loadMovie([ALT_TWO]); + + const slow = createDeferred>(); + resolver.resolve.mockReturnValueOnce(slow.promise); + + const pending = service.play(ALT_TWO.id); + + // Enrichment lands mid-resolution and reruns discovery for the SAME + // film. That is a refresh, not a navigation: cancelling here would + // make the user's click do nothing at all. + await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 }); + + slow.resolve(resolvedFor(ALT_TWO, 0)); + await expect(pending).resolves.toBe(true); + + expect(startPlayback).toHaveBeenCalledTimes(1); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + }); + + it('waits for a discovery still in flight before giving up', async () => { + const slow = createDeferred<{ + sources: VodSourceCandidate[]; + matchKind: string; + }>(); + discovery.discover.mockReturnValueOnce(slow.promise); + 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 + // alternatives landing a moment later and nothing left to retry them. + const failingOver = service.failover(); + slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' }); + await loading; + + await expect(failingOver).resolves.not.toBeNull(); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + }); + + it('abandons a failover whose movie was left during the wait', async () => { + const slow = createDeferred<{ + sources: VodSourceCandidate[]; + matchKind: string; + }>(); + discovery.discover.mockReturnValueOnce(slow.promise); + 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 + // whatever controller is current afterwards would answer A's playback + // failure by starting one of B's alternatives. + await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B); + startPlayback.mockClear(); + + slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' }); + await loadingA; + + await expect(failingOver).resolves.toBeNull(); + expect(startPlayback).not.toHaveBeenCalled(); + }); + + it('leaves the spinner on the row that is still resolving', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + + const slowTwo = createDeferred>(); + const slowThree = createDeferred>(); + 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('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('superseded'); + expect(startPlayback).not.toHaveBeenCalled(); + }); + + it('drops a switch the route’s own Play superseded', async () => { + await loadMovie([ALT_TWO]); + + const slow = createDeferred>(); + resolver.resolve.mockReturnValueOnce(slow.promise); + const pending = service.play(ALT_TWO.id); + + // The user gave up waiting and pressed Play on the route's own stream. + // The older resolution must not come back and replace it. + service.markRouteSourceActive(); + startPlayback.mockClear(); + + slow.resolve(resolvedFor(ALT_TWO, 0)); + await expect(pending).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', + playlistId: candidate.playlistId, + contentId: candidate.contentId, + portalType: 'xtream', + }); + + pins.get.mockResolvedValue(pinFor(ALT_TWO)); + await loadMovie([ALT_TWO]); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + const slow = createDeferred(); + pins.clear.mockReturnValueOnce(slow.promise); + const pending = service.togglePin(ALT_TWO.id); + + // Another movie opens — with a pin of its own — before the clear + // comes back. + pins.get.mockResolvedValue(pinFor(ALT_THREE)); + await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B); + + slow.resolve(true); + await pending; + + // The late unpin belongs to the film the user left. Applying it here + // would clear the pin this movie just loaded, and its Play action + // would silently stop starting from the preferred source. + expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + it('drops a probe result whose movie was navigated away from', async () => { + await loadMovie([ALT_TWO]); + + const slow = createDeferred>(); + resolver.resolve.mockReturnValueOnce(slow.promise); + + const pending = service.check(ALT_TWO.id); + await loadMovie([ALT_TWO], MOVIE_B); + + slow.resolve(resolvedFor(ALT_TWO, undefined)); + await pending; + + // A stale probe must not attach itself to the new movie's row. + expect(probes.probe).not.toHaveBeenCalled(); + expect(rowFor(ALT_TWO.id)?.probe.status).toBe('idle'); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts new file mode 100644 index 000000000..7f989f718 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts @@ -0,0 +1,276 @@ +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, + MOVIE_B, + PROBE_OK, + createDeferred, + resolveWith, + type DiscoveryResult, +} from './vod-multi-source-host.fixtures'; + +/** + * When a session starts, and when it merely refreshes. + * + * These two are one question: metadata enrichment reruns discovery for the + * film already on screen, and getting that wrong either loses the session or + * never rebuilds the pin keys. + */ +describe('VodMultiSourceHostService — session lifecycle', () => { + let service: VodMultiSourceHostService; + + const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); + 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() }; + + /** Runs pending root effects, then drains the work they started. */ + async function flushEffects(): Promise { + for (let index = 0; index < 6; index += 1) { + TestBed.tick(); + await Promise.resolve(); + } + } + + async function loadMovie( + sources: VodSourceCandidate[], + target: VodMultiSourceMovie = MOVIE_A + ): Promise { + 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, playbackLive }) + ); + }); + + it('discovers once per bound movie identity', async () => { + movie.set(MOVIE_A); + await flushEffects(); + + expect(discovery.discover).toHaveBeenCalledTimes(1); + expect(discovery.discover).toHaveBeenCalledWith({ + title: 'The Matrix', + year: 1999, + currentPlaylistId: 'playlist-1', + keepContentId: null, + }); + + movie.set({ ...MOVIE_A }); + await flushEffects(); + expect(discovery.discover).toHaveBeenCalledTimes(1); + + movie.set(MOVIE_B); + await flushEffects(); + expect(discovery.discover).toHaveBeenCalledTimes(2); + }); + + it('rediscovers the same movie once enrichment describes it better', async () => { + movie.set(MOVIE_A); + await flushEffects(); + + // The year and TMDB id arrive with get_vod_info, after the catalog + // title opened the page. Discovery has to run again — that is how a + // yearless search gets its year and a `tmdb:` pin becomes findable. + movie.set({ ...MOVIE_A, tmdbId: 603 }); + await flushEffects(); + + expect(discovery.discover).toHaveBeenCalledTimes(2); + }); + + it('does not burn a source the user only selected', async () => { + // One alternative, so the route copy is the ONLY fallback left and + // the outcome cannot depend on how candidates are ranked. + await loadMovie([ALT_TWO]); + + // Straight to the alternative from the picker: the route copy was + // selected by discovery but never played, so it is still a fallback. + await expect(service.play(ALT_TWO.id)).resolves.toBe(true); + expect(rowFor(CURRENT_A_ID)?.isTried).toBe(false); + + vodAutoFailover.set(true); + + // Burning it at discovery made this failure report the options + // exhausted while a healthy copy sat untouched. + await expect(service.failover()).resolves.not.toBeNull(); + expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true); + }); + + it('keeps the playing source when enrichment reloads the same movie', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + // The route copy actually plays first, so its turn is genuinely spent. + service.markRouteSourceActive(); + await expect(service.play(ALT_TWO.id)).resolves.toBe(true); + service.reportPosition(2538); + + await loadMovie([ALT_TWO, ALT_THREE], { ...MOVIE_A, tmdbId: 603 }); + + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + expect(rowFor(CURRENT_A_ID)?.isActive).toBe(false); + // The source it already left stays burned, so "each source at most + // once" still holds across the refresh. + expect(rowFor(CURRENT_A_ID)?.isTried).toBe(true); + expect(service.previousSourceId()).toBe(CURRENT_A_ID); + + vodAutoFailover.set(true); + await expect(service.failover()).resolves.not.toBeNull(); + + expect(rowFor(ALT_THREE.id)?.isActive).toBe(true); + // And the live position survived, so it does not restart the film. + expect(resolver.resolve).toHaveBeenLastCalledWith( + expect.objectContaining({ id: ALT_THREE.id }), + { startTime: 2538 } + ); + }); + + it('keeps the playing row when the refined year rejects it', async () => { + await loadMovie([ALT_TWO]); + await expect(service.play(ALT_TWO.id)).resolves.toBe(true); + + // Enrichment supplies the release year, and the year gate now rejects + // the copy the yearless search had admitted. Off the LIST is right — + // it is a different film. Off the SCREEN is not: it is the one + // streaming, and the caption must not name a playlist that is not. + await loadMovie([], { ...MOVIE_A, year: 2021 }); + + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + expect(rowFor(CURRENT_A_ID)?.isActive).toBe(false); + }); + + it('resets the failover session when a new movie loads', async () => { + await loadMovie([ALT_TWO]); + vodAutoFailover.set(true); + + await expect(service.failover()).resolves.not.toBeNull(); + await expect(service.failover()).resolves.toBeNull(); + expect(service.previousSourceId()).toBe(CURRENT_A_ID); + + await loadMovie([ALT_TWO], MOVIE_B); + + expect(service.previousSourceId()).toBeNull(); + expect(service.lastSwitch()).toBeNull(); + await expect(service.failover()).resolves.not.toBeNull(); + }); + + it('discards a discovery that resolves after the movie changed', async () => { + const first = createDeferred(); + discovery.discover.mockReturnValueOnce(first.promise); + + const loadA = service.load(MOVIE_A); + // 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; + await loadB; + + // 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, + ]); + }); + + it('takes on provider facts that arrive without changing the identity', async () => { + movie.set(MOVIE_A); + await flushEffects(); + expect(rowFor(CURRENT_A_ID)?.audio).toBeUndefined(); + + // A sparse panel answers `get_vod_info` with no year and no TMDB id, + // so the movie key is unchanged and nothing reruns discovery. The + // route row would keep stating nothing, and every comparison against + // it stays one-sided — the dub warning could never fire. + movie.set({ + ...MOVIE_A, + metadata: { audioCodec: 'ac3', containerExtension: 'mkv' }, + }); + await flushEffects(); + + expect(discovery.discover).toHaveBeenCalledTimes(1); + expect(rowFor(CURRENT_A_ID)?.audio).toEqual({ + value: 'ac3', + provenance: 'api', + }); + expect(rowFor(CURRENT_A_ID)?.container).toEqual({ + value: 'mkv', + provenance: 'api', + }); + }); + + it('keeps what the row already knows when facts arrive', async () => { + movie.set(MOVIE_A); + await flushEffects(); + await service.check(CURRENT_A_ID); + expect(rowFor(CURRENT_A_ID)?.probe?.status).toBe('ok'); + + movie.set({ ...MOVIE_A, metadata: { audioCodec: 'ac3' } }); + await flushEffects(); + + // Rebuilding the row from the movie would be the obvious way to do + // this, and it would silently throw away a probe the user asked for. + expect(rowFor(CURRENT_A_ID)?.probe?.status).toBe('ok'); + expect(rowFor(CURRENT_A_ID)?.audio?.value).toBe('ac3'); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts new file mode 100644 index 000000000..9062fd5f4 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts @@ -0,0 +1,197 @@ +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 { VodMultiSourceHostService } from './vod-multi-source-host.service'; +/** + * Shared fixtures for the multi-source host specs, extracted so the + * race-condition suite can live in its own file rather than duplicating setup. + */ +import type { + ResolvedPortalPlayback, + VodSourceCandidate, + VodSourceField, +} from '@iptvnator/shared/interfaces'; +import type { VodMultiSourceMovie } from './vod-multi-source-identity'; + +export const MOVIE_A: VodMultiSourceMovie = { + playlistId: 'playlist-1', + playlistName: 'Playlist One', + contentId: 101, + title: 'The Matrix', + year: 1999, +}; +export const MOVIE_B: VodMultiSourceMovie = { + ...MOVIE_A, + contentId: 202, + title: 'Blade Runner', + year: 1982, +}; // a second movie, to prove load() fully resets the session +export const CURRENT_A_ID = 'playlist-1:xtream:101'; + +export const API_AC3: VodSourceField = { value: 'ac3', provenance: 'api' }; +export const API_AAC: VodSourceField = { value: 'aac', provenance: 'api' }; +export const PARSED_DUB: VodSourceField = { + value: 'Дубляж', + provenance: 'parsed', +}; +export const API_LANG_RUS: VodSourceField = { + value: 'rus', + provenance: 'api', +}; +export const API_LANG_ENG: VodSourceField = { + value: 'eng', + provenance: 'api', +}; +export const PROBE_OK = { status: 'ok', httpStatus: 200, latencyMs: 42 }; + +export type DiscoveryResult = { + sources: VodSourceCandidate[]; + matchKind: string; +}; +/** + * What a resolve should state about the audio. + * + * Two fields, because they answer different questions: `audio` is the codec + * (or a guessed dub marker) and is display-only, while `audioLanguage` is the + * one thing allowed to drive the "dub may differ" warning. + */ +export type AudioFacts = Pick; +export type AudioPicker = (c: VodSourceCandidate) => Partial; + +export function alternative(index: number): VodSourceCandidate { + return { + id: `playlist-${index}:xtream:${900 + index}`, + playlistId: `playlist-${index}`, + playlistName: `Playlist ${index}`, + portalType: 'xtream', + contentId: 900 + index, + rawTitle: 'The Matrix', + matchConfidence: 'exact', + year: 1999, + }; +} + +export const [ALT_TWO, ALT_THREE] = [alternative(2), alternative(3)]; + +export function resolvedFor( + candidate: VodSourceCandidate, + startTime: number | undefined, + audio?: Partial +) { + const playback: ResolvedPortalPlayback = { + streamUrl: `http://${candidate.playlistId}/${candidate.contentId}.mkv`, + title: candidate.rawTitle, + isLive: false, + startTime, + }; + return { playback, candidate: { ...candidate, ...audio } }; +} + +/** Stands in for the resolver: echoes the start time, injects audio facts. */ +export function resolveWith(audioFor?: AudioPicker) { + return async ( + candidate: VodSourceCandidate, + options?: { startTime?: number } + ) => resolvedFor(candidate, options?.startTime, audioFor?.(candidate)); +} + +export function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** + * The TestBed every host spec needs. + * + * Five specs carried their own identical copy of this — the same five stubs, + * the same reset, the same bind. One harness so they cannot drift, and so a + * new case does not cost sixty lines of setup. + */ +export function setupVodMultiSourceHost() { + const movie = signal(null); + /** Whatever is on screen; the pin path distinguishes it from selection. */ + const playbackLive = signal(false); + 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() }; + + const harness = { + movie, + playbackLive, + vodAutoFailover, + startPlayback, + discovery, + resolver, + pins, + probes, + service: null as unknown as VodMultiSourceHostService, + + /** Call from `beforeEach`; returns the freshly bound service. */ + reset(): VodMultiSourceHostService { + jest.resetAllMocks(); + movie.set(null); + playbackLive.set(false); + 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 } }, + ], + }); + + harness.service = TestBed.inject(VodMultiSourceHostService); + TestBed.runInInjectionContext(() => + harness.service.bind({ startPlayback, movie, playbackLive }) + ); + return harness.service; + }, + + async loadMovie( + sources: VodSourceCandidate[], + target: VodMultiSourceMovie = MOVIE_A + ): Promise { + discovery.discover.mockResolvedValue({ + sources, + matchKind: 'title-year', + }); + await harness.service.load(target); + }, + + rowFor(sourceId: string) { + return harness.service + .sources() + .find((source) => source.id === sourceId); + }, + }; + + return harness; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts new file mode 100644 index 000000000..1b85679d3 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -0,0 +1,358 @@ +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, + VodSourceField, +} 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, + API_AAC, + API_AC3, + API_LANG_ENG, + API_LANG_RUS, + CURRENT_A_ID, + MOVIE_A, + PARSED_DUB, + PROBE_OK, + createDeferred, + resolveWith, + type AudioFacts, + type DiscoveryResult, +} from './vod-multi-source-host.fixtures'; + +describe('VodMultiSourceHostService', () => { + let service: VodMultiSourceHostService; + + const movie = signal(null); + + // Whatever is on screen; the pin path distinguishes it from selection. + + const playbackLive = signal(false); + 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 { + discovery.discover.mockResolvedValue({ + sources, + matchKind: 'title-year', + }); + await service.load(target); + } + + function rowFor(sourceId: string) { + return service.sources().find((source) => source.id === sourceId); + } + + function resolvedIds(): string[] { + return resolver.resolve.mock.calls.map((call) => call[0].id); + } + + /** Two consecutive switches, so the "previous" side carries real audio. */ + async function switchTwice( + first: Partial | undefined, + second: Partial + ) { + await loadMovie([ALT_TWO, ALT_THREE]); + resolver.resolve.mockImplementation( + resolveWith((candidate) => + candidate.id === ALT_TWO.id ? (first ?? {}) : second + ) + ); + + await service.play(ALT_TWO.id); + await service.play(ALT_THREE.id); + + return service.lastSwitch(); + } + + 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, playbackLive }) + ); + }); + + it('counts only sources other than the playing one as alternatives', async () => { + await loadMovie([]); + + expect(service.sources()).toHaveLength(1); + expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true); + expect(service.hasAlternatives()).toBe(false); + expect(service.alternativeCount()).toBe(0); + + await loadMovie([ALT_TWO]); + + expect(service.sources()).toHaveLength(2); + expect(service.hasAlternatives()).toBe(true); + expect(service.alternativeCount()).toBe(1); + }); + + it('counts playlists, not copies, for the "found in N others" caption', async () => { + // Two copies inside ONE other playlist. The popover groups them under + // that single portal, so a caption reading "also found in 2 other + // playlists" would contradict the list it invites the user to open. + const secondCopy: VodSourceCandidate = { + ...ALT_TWO, + id: `${ALT_TWO.playlistId}:xtream:999`, + contentId: 999, + }; + await loadMovie([ALT_TWO, secondCopy]); + + expect(service.alternativeCount()).toBe(2); + expect(service.alternativePlaylistCount()).toBe(1); + + await loadMovie([ALT_TWO, secondCopy, ALT_THREE]); + expect(service.alternativePlaylistCount()).toBe(2); + }); + + it('carries the live timecode into the resolve call and the playback', async () => { + await loadMovie([ALT_TWO]); + service.reportPosition(2538); + + await expect(service.play(ALT_TWO.id)).resolves.toBe(true); + + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 2538 } + ); + expect(startPlayback).toHaveBeenCalledTimes(1); + expect(startPlayback).toHaveBeenCalledWith( + expect.objectContaining({ startTime: 2538 }) + ); + }); + + it('never puts a credential-bearing playlist name in the notice', async () => { + // Users routinely name a playlist after the URL they pasted, and this + // string goes straight into a toast over the video. + const leaky = { + ...ALT_TWO, + playlistName: + 'http://portal.example.com:8080/get.php?username=alice&password=hunter2', + }; + await loadMovie([leaky]); + + await service.play(leaky.id); + + const notice = service.lastSwitch(); + expect(notice?.playlistName).not.toContain('hunter2'); + expect(notice?.playlistName).not.toContain('alice'); + expect(notice?.playlistName).toBe('portal.example.com:8080'); + }); + + it('announces every switch with the target playlist', async () => { + await loadMovie([ALT_TWO]); + service.reportPosition(2538); + + await service.play(ALT_TWO.id); + + expect(service.lastSwitch()).toEqual( + expect.objectContaining({ + playlistName: ALT_TWO.playlistName, + resumeSeconds: 2538, + }) + ); + expect(service.previousSourceId()).toBe(CURRENT_A_ID); + }); + + it.each< + [string, Partial | undefined, Partial, boolean] + >([ + [ + 'fires when both sides state a different language', + { audioLanguage: API_LANG_RUS }, + { audioLanguage: API_LANG_ENG }, + true, + ], + [ + 'is silent when the language is the same', + { audioLanguage: API_LANG_RUS }, + { audioLanguage: API_LANG_RUS }, + false, + ], + [ + // A codec is not a dub: AAC and AC3 routinely carry the same one, + // so this fired on every identical-language re-encode. + 'is silent for a codec change alone', + { audio: API_AC3 }, + { audio: API_AAC }, + false, + ], + [ + 'is silent for a guessed dub marker', + { audioLanguage: PARSED_DUB }, + { audioLanguage: API_LANG_ENG }, + false, + ], + [ + 'is silent without both languages', + undefined, + { audioLanguage: API_LANG_ENG }, + false, + ], + ])('the dub warning %s', async (_name, first, second, expected) => { + expect(await switchTwice(first, second)).toEqual( + expect.objectContaining({ audioMayDiffer: expected }) + ); + }); + + it('does not fail over while auto-failover is off', async () => { + await loadMovie([ALT_TWO]); + resolver.resolve.mockClear(); + + await expect(service.failover()).resolves.toBeNull(); + + expect(resolver.resolve).not.toHaveBeenCalled(); + expect(startPlayback).not.toHaveBeenCalled(); + }); + + it('fails over and announces it once auto-failover is enabled', async () => { + await loadMovie([ALT_TWO]); + vodAutoFailover.set(true); + + const notice = await service.failover(); + + expect(notice).toEqual( + expect.objectContaining({ playlistName: ALT_TWO.playlistName }) + ); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + expect(startPlayback).toHaveBeenCalledTimes(1); + }); + + it('never visits a source twice while failing over', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + vodAutoFailover.set(true); + + await expect(service.failover()).resolves.not.toBeNull(); + await expect(service.failover()).resolves.not.toBeNull(); + await expect(service.failover()).resolves.toBeNull(); + + const visited = resolvedIds(); + expect(visited).toHaveLength(2); + expect(new Set(visited).size).toBe(visited.length); + expect(visited).not.toContain(CURRENT_A_ID); + }); + + it('keeps going when the best candidate cannot be resolved', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + vodAutoFailover.set(true); + // Top-ranked source has a dead account: get_vod_info yields nothing. + resolver.resolve.mockResolvedValueOnce(null); + + // Production calls failover() once, on the original failure — giving up + // here would strand a perfectly healthy lower-ranked source. + await expect(service.failover()).resolves.not.toBeNull(); + + const [failedId, playingId] = resolvedIds(); + expect(playingId).not.toBe(failedId); + expect(rowFor(failedId)?.isActive).toBe(false); + expect(rowFor(failedId)?.isTried).toBe(true); + expect(rowFor(playingId)?.isActive).toBe(true); + expect(startPlayback).toHaveBeenCalledTimes(1); + }); + + it('gives up once every candidate fails to resolve', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + vodAutoFailover.set(true); + resolver.resolve.mockResolvedValue(null); + + // Each attempt marks its source tried, so this terminates rather than + // spinning through the same candidate forever. + await expect(service.failover()).resolves.toBeNull(); + expect(startPlayback).not.toHaveBeenCalled(); + }); + + it('abandons a waiting failover once the user starts something else', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + vodAutoFailover.set(true); + + // A same-movie rediscovery is still out when the stream fails. + const slow = createDeferred(); + discovery.discover.mockReturnValueOnce(slow.promise); + const reloading = service.load({ ...MOVIE_A, tmdbId: 603 }); + while (discovery.discover.mock.calls.length < 2) { + await Promise.resolve(); + } + const failingOver = service.failover(); + + // Across that wait the user restarts the route's own stream. The + // session is unchanged — it only moves when the FILM does — so + // checking it alone lets failover treat what they just started as the + // thing that failed and switch away from it. + service.markRouteSourceActive(); + + slow.resolve({ + sources: [ALT_TWO, ALT_THREE], + matchKind: 'title-year', + }); + await reloading; + + await expect(failingOver).resolves.toBeNull(); + expect(startPlayback).not.toHaveBeenCalled(); + }); + + it('reports unknown, never fail, for a source it cannot check', async () => { + await loadMovie([ALT_TWO]); + resolver.resolve.mockResolvedValueOnce(null); + + await service.check(ALT_TWO.id); + + expect(rowFor(ALT_TWO.id)?.probe.status).toBe('unknown'); + expect(probes.probe).not.toHaveBeenCalled(); + + await service.check(ALT_TWO.id); + + // Probed with the playlist's own playback headers, or a panel that + // requires them answers 403 and a working source looks dead. + expect(probes.probe).toHaveBeenCalledWith( + `http://${ALT_TWO.playlistId}/${ALT_TWO.contentId}.mkv`, + 'HEAD', + expect.objectContaining({ userAgent: undefined }) + ); + expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK); + }); +}); 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 new file mode 100644 index 000000000..a2b28c9b4 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -0,0 +1,444 @@ +import { + computed, + effect, + inject, + Injectable, + signal, + type Signal, +} from '@angular/core'; +import { + applyApiMetadata, + VodMultiSourceController, + VodSourceDiscoveryService, + VodSourceResolverService, +} from '@iptvnator/portal/shared/data-access'; +import { + SettingsStore, + StreamProbeService, + VodSourcePinService, +} from '@iptvnator/services'; +import { + vodMultiSourceMovieKey, + vodMultiSourceSessionKey, + type VodMultiSourceMovie, +} from './vod-multi-source-identity'; +import { + runDiscovery, + runFailover, + toPinnedOutcome, + switchToSource, + type SwitchOutcome, +} from './vod-multi-source-session'; +import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice'; +import { createVodSourceCounts } from './vod-multi-source-counts'; +import { currentSourceRow } from './vod-multi-source-current-row'; +import { probeSource } from './vod-multi-source-probe'; +import { + pinnedSourceAwaitingPlay, + type PinnedPlayOutcome, + pinnedSourceIdOf, + startPinnedSource, + pinKeysFor, + readPin, + commitPinToggle, + type PinKeySets, +} from './vod-multi-source-pin'; +import { + type ResolvedPortalPlayback, + type VodSourceCandidate, + type VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; + +/** + * 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. The host supplies the + * movie identity and the playback seam through `bind()`, so this never + * reaches into the route component. + */ + +export interface VodMultiSourceBindings { + /** Applies a playback — inline swap or external launch, host's choice. */ + startPlayback: (playback: ResolvedPortalPlayback) => void; + /** The movie on screen, or null while its identity is not yet knowable. */ + movie: Signal; + /** + * Whether a stream is on screen right now — NOT whether a row is selected. + * A pinned row stays selected after its player is closed. + */ + playbackLive: Signal; +} + +export type { VodMultiSourceSwitchNotice }; + +@Injectable() +export class VodMultiSourceHostService { + private readonly discovery = inject(VodSourceDiscoveryService); + private readonly resolver = inject(VodSourceResolverService); + private readonly pins = inject(VodSourcePinService); + private readonly probes = inject(StreamProbeService); + private readonly settingsStore = inject(SettingsStore); + + private controller = new VodMultiSourceController(); + private bindings: VodMultiSourceBindings | null = null; + private pinKeys: PinKeySets = { lookup: [], write: [] }; + /** Bumped per `load()`: discards a discovery the next one superseded. */ + private discoveryToken = 0; + /** Bumped when the FILM changes; a rediscovery of the same one is a + * refresh and must not cancel work in flight for it. */ + private sessionToken = 0; + /** 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; + /** The row standing for the playlist the route is on. */ + private routeSourceId: string | null = null; + /** The provider facts already overlaid on that row, to overlay each once. */ + private routeFactsKey: string | null = null; + /** Resolves once the discovery on the way has published its sources. */ + private loadInFlight: Promise | null = null; + + /** True while `session`/`switch` still describe the operation in flight. */ + private isCurrentSwitch(session: number, attempt: number): boolean { + return session === this.sessionToken && attempt === this.switchToken; + } + /** The source that was playing before the most recent switch. */ + private readonly _previousSourceId = signal(null); + + private readonly _sources = signal([]); + private readonly _busySourceId = signal(null); + /** Emitted after a switch so the host can toast it — never silent. */ + private readonly _lastSwitch = signal( + null + ); + + readonly sources = this._sources.asReadonly(); + readonly busySourceId = this._busySourceId.asReadonly(); + readonly lastSwitch = this._lastSwitch.asReadonly(); + readonly previousSourceId = this._previousSourceId.asReadonly(); + + private readonly counts = createVodSourceCounts(this._sources); + readonly alternatives = this.counts.alternatives; + readonly hasAlternatives = this.counts.hasAlternatives; + readonly alternativeCount = this.counts.alternativeCount; + readonly alternativePlaylistCount = this.counts.alternativePlaylistCount; + readonly matchKind = computed(() => this.controller.matchKind()); + + /** Opt-in and off by default — a silent switch is never acceptable. */ + readonly autoFailoverEnabled = computed( + () => this.settingsStore.vodAutoFailover?.() === true + ); + + /** Live getter: the bridge's own `isAvailable` can change under us. */ + get isAvailable(): boolean { + return this.discovery.isAvailable; + } + + /** + * 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; + + effect(() => { + const movie = bindings.movie(); + if (!movie) { + // Navigating away empties the identity before the next movie's + // `load()` runs, so bumping here — not only there — closes the + // window in which a resolution still in flight for the + // PREVIOUS movie would start playing over the page being left. + this.discoveryToken++; + this.sessionToken++; + return; + } + + const key = vodMultiSourceMovieKey(movie); + if (this.lastMovieKey === key) { + // Same film, described the same way — but `get_vod_info` can + // land without touching title, year or TMDB id while adding + // the provider's facts about the stream. Rediscovery would be + // wasted work; only the route's own row is missing anything. + this.refreshRouteFacts(movie); + return; + } + this.lastMovieKey = key; + this.routeFactsKey = null; + + void this.load(movie); + }); + } + + /** + * Overlay the provider's facts onto the route's own row, in place. + * + * The row is built when discovery runs, which on a sparse panel happens + * before `get_vod_info` answers — and if that answer adds no year and no + * TMDB id, the movie key does not change, so nothing rebuilds the row and + * it keeps stating nothing. Every comparison against it is then one-sided: + * the dub warning in particular cannot fire at all. + * + * Merged onto the existing row rather than rebuilt from the movie, so a + * probe result already sitting on it survives. + */ + private refreshRouteFacts(movie: VodMultiSourceMovie): void { + const facts = movie.metadata; + const routeSourceId = this.routeSourceId; + if (!facts || !routeSourceId) { + return; + } + + const factsKey = JSON.stringify(facts); + if (this.routeFactsKey === factsKey) { + return; + } + + const existing = this.controller.findSource(routeSourceId); + if (!existing) { + return; + } + + this.routeFactsKey = factsKey; + this.controller.updateSource(applyApiMetadata(existing, facts)); + this.publish(); + } + + /** + * (Re)discover the sources for a movie. + * + * A DIFFERENT film starts a fresh session — the router REUSES this + * component for detail→detail navigation, so everything resets, above all + * the tried-source set that makes failover terminate. The SAME film + * arriving again is a refresh: enrichment changes the movie key on + * purpose, but nothing about what is playing, and resetting would take the + * film off the source the user switched to. + */ + async load(movie: VodMultiSourceMovie): Promise { + const finished = this.discover(movie); + this.loadInFlight = finished; + try { + await finished; + } finally { + if (this.loadInFlight === finished) { + this.loadInFlight = null; + } + } + } + + private async discover(movie: VodMultiSourceMovie): Promise { + const token = ++this.discoveryToken; + const identity = vodMultiSourceSessionKey(movie); + const sameMovie = identity === this.movieIdentity; + this.movieIdentity = identity; + + if (!sameMovie) { + this.sessionToken++; + this.controller = new VodMultiSourceController(); + this._sources.set([]); + this._lastSwitch.set(null); + this._busySourceId.set(null); + this._previousSourceId.set(null); + } + this.pinKeys = pinKeysFor(movie); + + if (!this.discovery.isAvailable) { + return; + } + + const routeSource = currentSourceRow(movie); + this.routeSourceId = routeSource.id; + + await runDiscovery({ + controller: this.controller, + isCurrent: () => token === this.discoveryToken, + readPin: () => readPin(this.pins, this.pinKeys.lookup), + applyPin: (pin) => { + this.controller.setPinnedSource(pinnedSourceIdOf(pin)); + // Where it was found is the only ambiguous key an unpin may + // remove, being the row the user can actually see. + this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey }; + }, + discover: (keepContentId) => + this.discovery.discover({ + title: movie.title, + year: movie.year, + currentPlaylistId: movie.playlistId, + keepContentId, + }), + routeSource, + publish: () => this.publish(), + }); + } + + /** The pinned source, when it is not the one the route already plays. */ + readonly pendingPinnedSourceId = computed(() => + pinnedSourceAwaitingPlay( + this._sources(), + this.bindings?.playbackLive() === true + ) + ); + + /** + * Start from the pinned source if there is one. `unavailable` means there + * is nothing pinned to honour, leaving the caller's own Play path in + * charge; a superseded attempt must NOT fall through that way. + */ + playPinnedSource( + resumeFor?: (source: VodSourceCandidate) => Promise + ): Promise { + const session = this.sessionToken; + // Claim a switch generation up front. The discovery wait and the + // resume lookup are both awaits, and a source the user picks across + // either one has to win — otherwise this older attempt finishes last + // and replaces what they just chose. + const attempt = ++this.switchToken; + return startPinnedSource({ + controller: this.controller, + loadInFlight: this.loadInFlight, + pinnedSourceId: () => this.pendingPinnedSourceId(), + resumeFor, + isCurrent: () => this.isCurrentSwitch(session, attempt), + play: (sourceId) => this.runPlay(sourceId), + }); + } + + /** Play from a specific source once; does not change the pin. */ + async play(sourceId: string): Promise { + return (await this.runPlay(sourceId)) === 'played'; + } + + /** As `play`, but keeping the distinction the pinned path needs. */ + private async runPlay(sourceId: string): Promise { + const candidate = this.controller.findSource(sourceId); + if (!candidate || !this.bindings) { + return 'unavailable'; + } + + this._busySourceId.set(sourceId); + try { + return toPinnedOutcome(await this.switchTo(candidate)); + } finally { + // Only while this attempt still owns the spinner, or a slower + // pick would clear the row that is still resolving. + if (this._busySourceId() === sourceId) { + this._busySourceId.set(null); + } + } + } + + /** Pin or unpin this source as the movie's preferred one. */ + async togglePin(sourceId: string): Promise { + const session = this.sessionToken; + const pinned = await commitPinToggle({ + pins: this.pins, + keys: this.pinKeys, + candidate: this.controller.findSource(sourceId), + isPinned: this._sources().some( + (source) => source.id === sourceId && source.isPinned + ), + isCurrent: () => session === this.sessionToken, + }); + + if (pinned === undefined) { + return; + } + + this.controller.setPinnedSource(pinned); + this.publish(); + } + + /** User-triggered availability check for one row. */ + check(sourceId: string): Promise { + return probeSource(sourceId, { + controller: this.controller, + resolver: this.resolver, + probes: this.probes, + isCurrent: (session) => session === this.sessionToken, + session: this.sessionToken, + publish: () => this.publish(), + }); + } + + /** + * Move to the best untried source after a playback failure. Null when + * auto-failover is off or every source has been tried, and the caller + * shows the honest error screen instead. + */ + async failover(): Promise { + if (!this.autoFailoverEnabled()) { + return null; + } + + // A stream can fail faster than the database answers. Concluding + // "nowhere to go" against a controller whose discovery has not landed + // yet would strand the user on the error screen with alternatives + // arriving a moment later and nothing left to retry them. + // + // The switch generation is claimed up front and rechecked after, as + // the pinned path does. The session alone is not enough: it only + // changes when the FILM does, and the user can pick another source — + // or restart the route's own — across this wait. Failover would then + // treat what they just started as the stream that failed and switch + // away from it. + const session = this.sessionToken; + const attempt = ++this.switchToken; + await this.loadInFlight; + if (!this.isCurrentSwitch(session, attempt)) { + return null; + } + + const switched = await runFailover(this.controller, (candidate) => + this.switchTo(candidate) + ); + return switched ? this._lastSwitch() : null; + } + + /** + * Hand the "playing" badge back to the route's own row, and retire a + * switch still resolving — every caller is starting the route's stream, + * so an older resolution would replace what the user just asked for. + */ + markRouteSourceActive(): void { + this.switchToken++; + if (this.routeSourceId) { + this.controller.markPlaying(this.routeSourceId); + this.publish(); + } + } + + /** The live position, fed ahead of the persist throttle. */ + reportPosition(seconds: number): void { + this.controller.setResumeSeconds(seconds); + } + /** The stored position, standing in until the player reports its own. */ + seedResumePosition(seconds: number): void { + this.controller.seedResumeSeconds(seconds); + } + + private switchTo(candidate: VodSourceCandidate): Promise { + const bindings = this.bindings; + if (!bindings) { + return Promise.resolve('superseded'); + } + + const session = this.sessionToken; + const attempt = ++this.switchToken; + + return switchToSource(candidate, { + controller: this.controller, + resolve: (target, options) => + this.resolver.resolve(target, options), + startPlayback: (playback) => bindings.startPlayback(playback), + isCurrent: () => this.isCurrentSwitch(session, attempt), + setPreviousSource: (id) => this._previousSourceId.set(id), + setNotice: (notice) => this._lastSwitch.set(notice), + publish: () => this.publish(), + }); + } + + private publish(): void { + this._sources.set(this.controller.sources()); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts new file mode 100644 index 000000000..a3fc2d6bd --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts @@ -0,0 +1,173 @@ +import { + resolveVodMultiSourceMovie, + vodMultiSourceMovieKey, + vodMultiSourceSessionKey, + type VodMultiSourceMovie, +} from './vod-multi-source-identity'; + +const BASE = { + playlistId: 'playlist-1', + playlistName: 'Portal One', + vodId: 101, + vodInfo: null, + catalogItem: null, +}; + +function movie(overrides: Partial = {}) { + return { + playlistId: 'playlist-1', + playlistName: 'Portal One', + contentId: 101, + title: 'Dune', + year: null, + ...overrides, + } as VodMultiSourceMovie; +} + +describe('resolveVodMultiSourceMovie', () => { + it('is null until a title is knowable', () => { + expect(resolveVodMultiSourceMovie(BASE)).toBeNull(); + }); + + it('is null without a usable playlist or vod id', () => { + const withTitle = { ...BASE, catalogItem: { title: 'Dune' } }; + expect( + resolveVodMultiSourceMovie({ ...withTitle, playlistId: null }) + ).toBeNull(); + expect( + resolveVodMultiSourceMovie({ ...withTitle, vodId: 0 }) + ).toBeNull(); + }); + + it('takes the title from the catalog row before metadata arrives', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: 'Dune' }, + }); + + expect(resolved).toEqual( + expect.objectContaining({ title: 'Dune', contentId: 101 }) + ); + }); + + it('prefers the provider metadata title once it lands', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: 'Dune' }, + vodInfo: { + name: 'Dune: Part Two', + releasedate: '2024-02-27', + tmdb_id: 693134, + } as never, + }); + + expect(resolved?.title).toBe('Dune: Part Two'); + expect(resolved?.year).toBe(2024); + expect(resolved?.tmdbId).toBe(693134); + }); + + it('ignores a year that belongs to the movie’s name', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: '2001: A Space Odyssey' }, + }); + + // Reading any year anywhere calls this a 2001 film. Every genuine copy + // states 1968 and fails the year gate, so the movie has no alternative + // sources at all — and its pin key moves as soon as enrichment + // supplies the real year. + expect(resolved?.year).toBeNull(); + }); + + it('reads a release tag in either shape', () => { + expect( + resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: 'Dune (2021)' }, + })?.year + ).toBe(2021); + expect( + resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: 'The Matrix 1999' }, + })?.year + ).toBe(1999); + }); + + it('prefers the stated release date over anything in the title', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: 'The Matrix 1999' }, + vodInfo: { + name: 'The Matrix 1999', + releasedate: '1999-03-31', + } as never, + }); + + expect(resolved?.year).toBe(1999); + }); + + it('falls back to the playlist id when the name is empty', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + playlistName: ' ', + catalogItem: { title: 'Dune' }, + }); + + expect(resolved?.playlistName).toBe('playlist-1'); + }); +}); + +describe('vodMultiSourceMovieKey', () => { + it('is stable for the same movie described the same way', () => { + expect(vodMultiSourceMovieKey(movie())).toBe( + vodMultiSourceMovieKey(movie()) + ); + }); + + it('changes when enrichment supplies the release year', () => { + // The catalog title usually arrives before get_vod_info. If the key + // ignored the year, discovery would stay yearless forever because the + // host would never see the identity change. + expect(vodMultiSourceMovieKey(movie({ year: 2021 }))).not.toBe( + vodMultiSourceMovieKey(movie()) + ); + }); + + it('changes when enrichment supplies a TMDB id', () => { + // Without this, a `tmdb:`-keyed pin saved earlier would never be found, + // because the pin lookup keys are only rebuilt on a reload. + expect(vodMultiSourceMovieKey(movie({ tmdbId: 438631 }))).not.toBe( + vodMultiSourceMovieKey(movie()) + ); + }); + + it('changes for a different movie in the same playlist', () => { + expect(vodMultiSourceMovieKey(movie({ contentId: 202 }))).not.toBe( + vodMultiSourceMovieKey(movie()) + ); + }); +}); + +describe('vodMultiSourceSessionKey', () => { + it('ignores everything enrichment can change', () => { + // The whole point of the second key: the movie key SHOULD react to a + // year and a TMDB id arriving, so discovery reruns — but that rerun + // must not be mistaken for a different film and reset the session + // out from under the source that is playing. + expect( + vodMultiSourceSessionKey( + movie({ title: 'Dune (2021)', year: 2021, tmdbId: 438631 }) + ) + ).toBe(vodMultiSourceSessionKey(movie())); + }); + + it('separates two movies and two playlists', () => { + expect(vodMultiSourceSessionKey(movie({ contentId: 202 }))).not.toBe( + vodMultiSourceSessionKey(movie()) + ); + expect( + vodMultiSourceSessionKey(movie({ playlistId: 'playlist-2' })) + ).not.toBe(vodMultiSourceSessionKey(movie())); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts new file mode 100644 index 000000000..72e413b5d --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts @@ -0,0 +1,126 @@ +import { + providerVodMetadataOf, + type ProviderVodMetadata, +} from '@iptvnator/portal/shared/data-access'; +import { + extractYear, + releaseTagYear, + type XtreamVodInfo, + type XtreamVodStream, +} from '@iptvnator/shared/interfaces'; + +/** + * Who the movie on screen is, in the portal-agnostic terms multi-source needs. + * + * Pulled out of the host service so the "which of these three shapes carries + * the title" question stays a pure, testable function. + */ +export interface VodMultiSourceMovie { + playlistId: string; + playlistName: string; + contentId: number; + title: string; + year: number | null; + tmdbId?: number | string | null; + /** + * The provider's stated facts about THIS copy, when `get_vod_info` has + * landed. Deliberately outside `vodMultiSourceMovieKey`: it describes the + * stream, not which film this is, and rediscovering on a codec string + * would restart the search for no gain. + */ + metadata?: ProviderVodMetadata; +} + +type CatalogItem = + | (Partial & { title?: string }) + | null + | undefined; + +/** + * Resolve the movie identity, or null while it is not yet knowable. + * + * The title can arrive from either the catalog row (present immediately) or + * `get_vod_info` (arrives later, and again after TMDB enrichment replaces + * `selectedItem`). Returning null until one exists keeps discovery from + * firing on an empty query. + */ +export function resolveVodMultiSourceMovie(input: { + playlistId: string | null | undefined; + playlistName: string | null | undefined; + vodId: number; + vodInfo: XtreamVodInfo | null; + catalogItem: CatalogItem; + /** From `movie_data`, which sits beside `info` rather than inside it. */ + containerExtension?: string | null; +}): VodMultiSourceMovie | null { + const { playlistId, vodId, vodInfo, catalogItem } = input; + + if (!playlistId || !Number.isFinite(vodId) || vodId <= 0) { + return null; + } + + const title = + vodInfo?.name?.trim() || + catalogItem?.title?.trim() || + catalogItem?.name?.trim(); + if (!title) { + return null; + } + + return { + playlistId, + playlistName: input.playlistName?.trim() || playlistId, + contentId: vodId, + title, + // The title is read for a release TAG only. Here the year is part of + // the movie's identity — it gates discovery and keys the pin — so a + // year that is part of the NAME ("2001: A Space Odyssey") would reject + // every genuine 1968 copy and move the pin key the moment enrichment + // supplies the real one. + year: extractYear(vodInfo?.releasedate) ?? releaseTagYear(title), + tmdbId: vodInfo?.tmdb_id, + // Only once `get_vod_info` has landed. Before that the row simply + // states nothing, which is the honest reading of "not known yet". + metadata: vodInfo + ? providerVodMetadataOf({ + info: vodInfo, + movie_data: { + container_extension: + input.containerExtension ?? undefined, + }, + }) + : undefined, + }; +} + +/** + * Identity key for "this is still the same movie, described the same way". + * + * Includes every field that changes matching or pin lookup, not just the ids. + * The catalog title usually arrives before `get_vod_info` supplies the TMDB id + * and release year; if the key ignored those, enrichment would never trigger a + * reload, discovery would stay yearless and a `tmdb:`-keyed pin would never be + * found. + */ +export function vodMultiSourceMovieKey(movie: VodMultiSourceMovie): string { + return [ + movie.playlistId, + movie.contentId, + movie.title, + movie.year ?? '', + movie.tmdbId ?? '', + ].join(':'); +} + +/** + * Identity of the FILM, made of the only two things that cannot change while + * it stays on screen. + * + * The key above deliberately reacts to metadata, so enrichment re-runs + * discovery. This one answers the question that arises at that same moment: + * is this a different movie — reset everything — or the same one being + * described better, whose session must survive? + */ +export function vodMultiSourceSessionKey(movie: VodMultiSourceMovie): string { + return `${movie.playlistId}:${movie.contentId}`; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts new file mode 100644 index 000000000..2d0efa981 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts @@ -0,0 +1,43 @@ +import { audioDiffersFactually } from '@iptvnator/portal/shared/data-access'; +import { + playlistDisplayLabel, + type VodSourceCandidate, +} from '@iptvnator/shared/interfaces'; + +/** What a switch tells the user. Lives with the code that builds it. */ +export interface VodMultiSourceSwitchNotice { + playlistName: string; + resumeSeconds: number; + /** Both sides state an audio track AS FACT and those facts differ. */ + audioMayDiffer: boolean; + quality?: string; + container?: string; +} + +/** + * Builds what the user is told after a switch. + * + * A switch is never silent, so this always produces a notice. The dub warning + * is deliberately conservative: it fires only when BOTH sides state an audio + * track as fact, because warning on a guess would train people to ignore it. + */ +export function buildSwitchNotice( + candidate: VodSourceCandidate, + resolved: VodSourceCandidate, + previous: VodSourceCandidate | null | undefined, + resumeSeconds: number +): VodMultiSourceSwitchNotice { + return { + // Safe by construction, not by call site: users routinely name a + // playlist after the URL they pasted, credentials and all, and this + // string goes into a toast that sits over the video. + playlistName: playlistDisplayLabel( + candidate.playlistName, + candidate.playlistId + ), + resumeSeconds, + audioMayDiffer: audioDiffersFactually(previous, resolved), + quality: resolved.quality?.value, + container: resolved.container?.value, + }; +} 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 new file mode 100644 index 000000000..767d3a061 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -0,0 +1,310 @@ +import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-access'; +import type { VodSourcePinService } from '@iptvnator/services'; +import { + buildVodSourceMatchKey, + buildVodSourceMatchKeyCandidates, + buildVodSourceMatchKeyWriteKeys, + type VodSourceCandidate, + type VodSourceDescriptor, + type VodSourcePin, +} from '@iptvnator/shared/interfaces'; + +/** + * Reading and writing the per-movie pinned source. + * + * Split out of the host service to keep it inside its line budget; the pin is a + * self-contained errand with its own key handling, and nothing else in the + * 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. + * + * `isActive` alone cannot answer this: it means SELECTED, and the pinned row + * stays selected after its player is closed. Skipping the pin then would send + * the next Play to the route copy and quietly ignore the stored preference + * until the page is reopened. + */ +export function pinnedSourceAwaitingPlay( + sources: readonly VodSourceDescriptor[], + playbackLive: boolean +): string | null { + const pinned = sources.find((source) => source.isPinned); + if (!pinned) { + return null; + } + + return pinned.isActive && playbackLive ? null : pinned.id; +} + +/** The row id the controller uses, derived from a stored pin. */ +export function pinnedSourceIdOf(pin: VodSourcePin): string { + return `${pin.playlistId}:${pin.portalType}:${pin.contentId}`; +} + +export async function readPin( + pins: Pick, + matchKeys: readonly string[] +): Promise { + return matchKeys.length > 0 ? pins.get([...matchKeys]) : null; +} + +/** + * Persist the pin for `candidate` under EVERY key that names exactly this + * film, having first cleared any remaining alias it could otherwise be found + * under. + * + * Storing it once, under the most-trusted key alone, is not enough: a movie's + * identity grows. Pin Dune while `tmdb:438631` is known and the decision goes + * only there — then reopen it, and the page starts out with nothing but a + * title and a year, so its lookup asks for `title:dune:2021` and finds + * nothing. The pin is ignored until enrichment lands, and if enrichment is off + * or never answers, permanently. Writing both keys makes the preference + * readable at every stage of the same film's identity. + * + * `keys.write` is exactly the right set to spread across, because it holds + * only keys that name ONE film. The yearless `title:{base}:` form is + * deliberately not among them — every remake shares it, 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 would start the 2021 source. That + * alias stays readable, for genuinely pre-enrichment pins, and unwritten. + */ +export async function writePin( + pins: Pick, + keys: PinKeySets, + candidate: VodSourceCandidate +): Promise { + const [primary, ...aliases] = keys.write; + const matchKey = primary ?? buildVodSourceMatchKey(candidate); + if (!matchKey) { + return false; + } + + // ONE transaction, not a write followed by a clear. Split in two there is + // no honest outcome for a half-failure: a surviving alias is read BEFORE + // the canonical key on the next open, so reporting success starts the + // source the user just replaced — while reporting failure leaves the + // canonical row durable and the UI showing a pin that is no longer the + // stored one. + const written = new Set([matchKey, ...aliases]); + return pins.set( + { + matchKey, + playlistId: candidate.playlistId, + contentId: candidate.contentId, + portalType: candidate.portalType, + }, + // Whatever is left is the ambiguous row this session actually read — + // the only one an unpin may take, and one this write must not keep. + retirablePinKeys(keys).filter((key) => !written.has(key)), + aliases + ); +} + +/** Clears every alias, so unpinning is not undone by a stale row. */ +export async function erasePin( + pins: Pick, + matchKeys: readonly string[] +): Promise { + return matchKeys.length > 0 ? pins.clear([...matchKeys]) : false; +} + +/** + * Pin or unpin `candidate`, and report the id the controller should now hold. + * + * `undefined` means the toggle did not happen at all — no key to store it + * under, no such row, or the write did not land — and the caller must leave + * the current pin exactly as it was rather than showing one that was not + * saved. A pin the database refused is worse than no pin: the icon promises + * the preference will be there next time, and it will not be. + */ +export async function togglePinnedSource( + pins: Pick, + keys: PinKeySets, + candidate: VodSourceCandidate | null, + isPinned: boolean +): Promise { + if (keys.write.length === 0) { + return undefined; + } + + if (isPinned) { + return (await erasePin(pins, retirablePinKeys(keys))) + ? null + : undefined; + } + + if (!candidate) { + return undefined; + } + + return (await writePin(pins, keys, candidate)) ? candidate.id : undefined; +} + +/** What committing a pin toggle needs from the host. */ +export interface PinToggleDeps { + pins: Pick; + keys: PinKeySets; + candidate: VodSourceCandidate | null; + isPinned: boolean; + /** False once another movie owns the screen — the write is awaited. */ + isCurrent: () => boolean; +} + +/** + * Toggle the pin and report the id the controller should hold, or `undefined` + * when nothing should change. + * + * The write is an IPC round-trip and another movie can own the screen by the + * time it returns. Committing then would apply THIS film's answer to THAT + * film's controller — an unpin would clear the pin it just loaded, and its + * Play button would stop honouring it. + */ +export async function commitPinToggle( + deps: PinToggleDeps +): Promise { + const pinned = await togglePinnedSource( + deps.pins, + deps.keys, + deps.candidate, + deps.isPinned + ); + + return deps.isCurrent() ? pinned : 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; +} + +/** + * The full pinned-play errand: wait out a discovery still in flight, confirm + * this film still owns the screen, then start the pinned source. + * + * The wait matters — the pin arrives WITH discovery, so answering before the + * lookup lands would make a persisted preference lose to worker latency — and + * so does the re-check after it, because the user can navigate across it. + */ +export async function startPinnedSource( + deps: Omit & { + loadInFlight: Promise | null; + pinnedSourceId: () => string | null; + } +): Promise { + await deps.loadInFlight; + if (!deps.isCurrent()) { + return 'superseded'; + } + + return playPinned({ ...deps, pinnedSourceId: deps.pinnedSourceId() }); +} + +/** 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; + /** False once another movie owns the screen — the lookup is awaited. */ + isCurrent: () => boolean; + play: (sourceId: string) => Promise; +} + +/** + * Why a pinned play ended. + * + * `unavailable` is the ONLY outcome the caller may fall through on. A + * superseded attempt means something newer already owns the screen — treating + * that as "no usable pin" and starting the route source overrides the playback + * the newer action just began. + */ +export type PinnedPlayOutcome = 'played' | 'superseded' | 'unavailable'; + +/** + * 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 { + const pinned = deps.controller.findSource(deps.pinnedSourceId ?? ''); + if (!pinned) { + return 'unavailable'; + } + + const looked = !!deps.resumeFor; + 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 'superseded'; + } + + if (looked) { + // `null` means this copy has never been watched, and the controller is + // still holding the ROUTE copy's position. Carrying that across would + // drop the user an hour into a film they have not started here — and + // the first save would write that timecode under this source's key. + deps.controller.setResumeSeconds(stored ?? 0); + } + + return deps.play(pinned.id); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts new file mode 100644 index 000000000..910136796 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts @@ -0,0 +1,68 @@ +import type { + VodMultiSourceController, + VodSourceResolverService, +} from '@iptvnator/portal/shared/data-access'; +import type { StreamProbeService } from '@iptvnator/services'; + +/** + * The on-demand availability check for a single row. + * + * Two awaits happen here — resolving a URL, then contacting it — and the user + * can open another movie in between. Writing either result into the + * replacement controller would attach a stale verdict to an unrelated source, + * so the session is revalidated after each one. + */ +export interface ProbeSourceDeps { + controller: VodMultiSourceController; + resolver: Pick; + probes: Pick; + session: number; + isCurrent: (session: number) => boolean; + publish: () => void; +} + +export async function probeSource( + sourceId: string, + deps: ProbeSourceDeps +): Promise { + const { controller, session, isCurrent, publish } = deps; + const candidate = controller.findSource(sourceId); + if (!candidate) { + return; + } + + controller.setProbe(sourceId, { status: 'probing' }); + publish(); + + const resolved = await deps.resolver.resolve(candidate); + if (!isCurrent(session)) { + return; + } + + if (!resolved) { + // No URL could be built — that is "cannot check", not "this is dead". + controller.setProbe(sourceId, { status: 'unknown' }); + publish(); + return; + } + + controller.updateSource(resolved.candidate); + // Probe the way this playlist actually plays: a panel that requires its + // own User-Agent, Referer or Origin refuses a bare request, and calling a + // working stream unavailable would poison the failover ranking too. + const result = await deps.probes.probe( + resolved.playback.streamUrl, + 'HEAD', + { + userAgent: resolved.playback.userAgent, + referer: resolved.playback.referer, + origin: resolved.playback.origin, + } + ); + if (!isCurrent(session)) { + return; + } + + controller.setProbe(sourceId, result); + publish(); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts new file mode 100644 index 000000000..9ac20f0fd --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts @@ -0,0 +1,265 @@ +import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-access'; +import type { + ResolvedPortalPlayback, + VodSourceCandidate, + VodSourceMatchKind, + VodSourcePin, +} from '@iptvnator/shared/interfaces'; +import { + buildSwitchNotice, + type VodMultiSourceSwitchNotice, +} from './vod-multi-source-notice'; +import { pinnedCopyInPlaylist } from './vod-multi-source-pin'; + +/** + * Session mechanics for one open movie: how a discovery folds into the state + * that is already there, and how failover walks the ranked candidates. + * + * Pure functions over the controller — no I/O, no signals — so the host + * service stays the wiring layer and these stay testable on their own. + */ + +/** + * Why a switch attempt ended. `unresolvable` is the only outcome failover may + * continue past — `superseded` means something newer already owns the screen. + */ +export type SwitchOutcome = 'switched' | 'unresolvable' | 'superseded'; + +/** + * Put a discovery result into the controller without losing the session. + * + * Discovery runs again for the SAME movie when metadata enrichment finally + * supplies its year and TMDB id, and by then the user may have switched to + * another playlist. Re-listing the sources must not quietly hand the film + * back to the route's own playlist: the player would keep streaming the + * source it switched to while every caption named a different one, and + * failover would see the playing source as untried. + */ +export function applyDiscoveredSources( + controller: VodMultiSourceController, + current: VodSourceCandidate, + allDiscovered: VodSourceCandidate[], + matchKind: VodSourceMatchKind +): void { + // The kept-copy exception can return the route's OWN row when the pin + // points at it, and prepending `current` again would list one stream + // twice — a phantom "copy" in the grouping and a chip counting it. + const discovered = allDiscovered.filter( + (source) => source.id !== current.id + ); + + const activeId = controller.activeSourceId(); + // Only a source the user actually switched to needs protecting. When the + // route's own row is playing, the refreshed one supersedes it — it is the + // same row, now carrying the enriched title and year. + const switchedTo = + activeId && activeId !== current.id + ? controller.findSource(activeId) + : null; + + if (!switchedTo) { + controller.setSources([current, ...discovered], matchKind); + controller.setActiveSource(current.id); + return; + } + + // The rerun can legitimately drop the playing row: enrichment supplies the + // release year, and the year gate then rejects a copy that a yearless + // search had admitted ("Dune" 1984 while watching the 2021 film). Dropping + // it from the LIST is right — it is not the same film. Dropping it from + // the SCREEN is not: it is what the user is watching, so it stays as a row + // and keeps the playing badge rather than letting the caption name a + // playlist that is not streaming anything. + const isDiscovered = discovered.some( + (source) => source.id === switchedTo.id + ); + // Catalog rows only ever carry guesses parsed off the title, so the row + // that a real resolve turned into facts wins over its rediscovered twin. + const sources = isDiscovered + ? discovered.map((source) => + source.id === switchedTo.id ? switchedTo : source + ) + : [...discovered, switchedTo]; + + controller.setSources([current, ...sources], matchKind); + controller.markPlaying(switchedTo.id); +} + +/** + * A switch outcome in the pinned-play vocabulary. + * + * Only `unresolvable` may fall through to the route source; a superseded + * attempt means something newer already owns the screen. + */ +export function toPinnedOutcome( + outcome: SwitchOutcome +): 'played' | 'superseded' | 'unavailable' { + if (outcome === 'switched') { + return 'played'; + } + + return outcome === 'superseded' ? 'superseded' : 'unavailable'; +} + +/** What `switchToSource` needs from the host, without reaching into it. */ +export interface SwitchDeps { + controller: VodMultiSourceController; + resolve: ( + candidate: VodSourceCandidate, + options: { startTime: number } + ) => Promise<{ + playback: ResolvedPortalPlayback; + candidate: VodSourceCandidate; + } | null>; + startPlayback: (playback: ResolvedPortalPlayback) => void; + /** False once a newer switch, or another movie, owns the screen. */ + isCurrent: () => boolean; + setPreviousSource: (sourceId: string | null) => void; + setNotice: (notice: VodMultiSourceSwitchNotice) => void; + publish: () => void; +} + +/** + * Put one source on screen, carrying the timecode across. + * + * The controller is taken from `deps` rather than read live, because `load()` + * swaps in a fresh one for a new movie and the continuation after the await + * must never touch that one. + */ +export async function switchToSource( + candidate: VodSourceCandidate, + deps: SwitchDeps +): Promise { + const { controller } = deps; + + // The LIVE position, not the persisted one: the stored value lags by up to + // the save throttle and switching would visibly rewind. + const resumeSeconds = Math.floor(controller.getResumeSeconds()); + const previous = controller.findSource(controller.activeSourceId() ?? ''); + + const resolved = await deps.resolve(candidate, { + startTime: resumeSeconds, + }); + if (!deps.isCurrent()) { + // Superseded mid-flight — dropping the result is the whole point. + return 'superseded'; + } + + if (!resolved) { + // Mark it tried without making it active: a source we cannot even + // build a URL for must not be offered again by failover, but it never + // started playing either. + controller.markTried(candidate.id); + deps.publish(); + return 'unresolvable'; + } + + controller.updateSource(resolved.candidate); + deps.setPreviousSource(previous?.id ?? null); + controller.markPlaying(candidate.id); + controller.setResumeSeconds(resumeSeconds); + deps.startPlayback(resolved.playback); + + deps.setNotice( + buildSwitchNotice( + candidate, + resolved.candidate, + previous, + resumeSeconds + ) + ); + deps.publish(); + return 'switched'; +} + +/** + * Move to the best untried source after a playback failure. + * + * Keeps going past candidates that cannot be resolved at all: a dead account + * or a failing `get_vod_info` on the top-ranked source must not end the + * attempt, because production calls this once — on the original failure — so + * giving up here would strand a healthy lower-ranked source. Every attempt is + * marked tried, which is what makes the loop terminate. + */ +export async function runFailover( + controller: VodMultiSourceController, + switchTo: (candidate: VodSourceCandidate) => Promise +): Promise { + // Whatever is on screen just failed, so it is spent — whichever way it got + // there. Relying on the start paths to have marked it would leave one hole + // per path, and the cost of missing it is an infinite ping-pong between + // two sources. + const failing = controller.activeSourceId(); + if (failing) { + controller.markTried(failing); + } + + for (;;) { + const target = controller.pickFailoverTarget(); + if (!target) { + return false; + } + + const outcome = await switchTo(target); + if (outcome === 'switched') { + return true; + } + if (outcome === 'superseded') { + // A newer switch or another movie already owns the screen. + return false; + } + // 'unresolvable': the candidate is now marked tried, so the next pick + // is strictly a different source and the loop terminates. + } +} + +/** What one discovery pass needs from the host, without reaching into it. */ +export interface DiscoveryRunDeps { + controller: VodMultiSourceController; + /** False once a newer `load()` superseded this one. */ + isCurrent: () => boolean; + readPin: () => Promise; + /** Applied the moment it is read — see below. */ + applyPin: (pin: VodSourcePin) => void; + discover: (keepContentId: number | null) => Promise<{ + sources: VodSourceCandidate[]; + matchKind: VodSourceMatchKind; + }>; + routeSource: VodSourceCandidate; + publish: () => void; +} + +/** + * One discovery pass, from the pin lookup to the published source list. + * + * The pin is read first because discovery has to be told which copy of the + * film to keep, and applied immediately rather than after the source lookup: + * holding that snapshot across the await lets it overwrite a pin the user + * makes in the meantime, leaving the row naming a source the database no + * longer holds. Applying it first makes the later write simply win. + */ +export async function runDiscovery(deps: DiscoveryRunDeps): Promise { + const pin = await deps.readPin(); + if (!deps.isCurrent()) { + return; + } + + if (pin) { + deps.applyPin(pin); + } + + const result = await deps.discover( + pinnedCopyInPlaylist(pin, deps.routeSource.playlistId) + ); + if (!deps.isCurrent()) { + return; + } + + applyDiscoveredSources( + deps.controller, + deps.routeSource, + result.sources, + result.matchKind + ); + deps.publish(); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts new file mode 100644 index 000000000..0a3befd53 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts @@ -0,0 +1,309 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { + PlaybackPositionData, + VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; +import { + createPrimaryActionPosition, + formatPlaybackPosition, + isResumablePosition, +} from './vod-primary-action-position'; + +/** + * Which position the primary button describes. + * + * The page loads the ROUTE copy's row, but a pin means the button plays a + * different copy — with its own row, or none at all. Reading the wrong one + * makes the button lie about where it will start. + */ + +const ROUTE_PLAYLIST = 'playlist-1'; +const ROUTE_CONTENT = 650020; + +function source( + overrides: Partial = {} +): VodSourceDescriptor { + return { + id: `${ROUTE_PLAYLIST}:xtream:${ROUTE_CONTENT}`, + playlistId: ROUTE_PLAYLIST, + playlistName: 'Portal One', + portalType: 'xtream', + contentId: ROUTE_CONTENT, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive: true, + isPinned: false, + isTried: false, + probe: { status: 'idle' }, + ...overrides, + } as VodSourceDescriptor; +} + +function position(seconds: number): PlaybackPositionData { + return { + playlistId: 'any', + contentXtreamId: 1, + contentType: 'vod', + positionSeconds: seconds, + durationSeconds: 7200, + }; +} + +const ALT = source({ + id: 'playlist-2:xtream:991', + playlistId: 'playlist-2', + contentId: 991, + isPinned: true, + isActive: false, +}); + +function setup( + sources: VodSourceDescriptor[], + routePosition: PlaybackPositionData | null, + load: (source: VodSourceDescriptor) => Promise +) { + const sourcesSignal = signal(sources); + const livePosition = signal(null); + const api = TestBed.runInInjectionContext(() => + createPrimaryActionPosition({ + sources: sourcesSignal, + routePlaylistId: signal(ROUTE_PLAYLIST), + routeContentId: signal(ROUTE_CONTENT), + routePosition: signal(routePosition), + livePosition, + load, + }) + ); + return { api, sourcesSignal, livePosition }; +} + +describe('createPrimaryActionPosition', () => { + it('describes the route copy when nothing is pinned', () => { + const load = jest.fn(); + const { api } = setup([source()], position(2538), load); + TestBed.tick(); + + expect(api.hasPosition()).toBe(true); + expect(api.position()?.positionSeconds).toBe(2538); + expect(load).not.toHaveBeenCalled(); + }); + + it('describes the pinned copy once its row is loaded', async () => { + const { api } = setup([source(), ALT], position(2538), async () => + position(4200) + ); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + + // Otherwise the button offers to resume at the ROUTE copy's timecode + // and then starts the pinned copy somewhere else entirely. + expect(api.position()?.positionSeconds).toBe(4200); + }); + + it('claims nothing while the pinned copy’s row is still loading', () => { + const { api } = setup( + [source(), ALT], + position(2538), + () => new Promise(() => undefined) + ); + TestBed.tick(); + + // The route copy's "Resume 42:18" would be a label for a stream this + // button is not going to start — and a click across this window looks + // the pinned position up separately and begins somewhere else. + expect(api.position()).toBeNull(); + expect(api.hasPosition()).toBe(false); + }); + + it('reads Play for a pinned copy that was never watched', async () => { + const { api } = setup( + [source(), ALT], + position(2538), + async () => null + ); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + + // "Never watched" is an answer, not an absence: falling back to the + // route copy here would show "Resume 42:18" on a copy that starts at 0. + expect(api.position()).toBeNull(); + expect(api.hasPosition()).toBe(false); + }); + + it('follows the pinned copy while it is being watched', async () => { + const { api, livePosition } = setup( + [source(), ALT], + position(2538), + async () => position(600) + ); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + expect(api.position()?.positionSeconds).toBe(600); + + // Playing the pinned copy leaves its stored row behind; the button + // would keep offering the timecode it had before this session. + livePosition.set({ + playlistId: ALT.playlistId, + contentXtreamId: ALT.contentId, + contentType: 'vod', + positionSeconds: 3000, + durationSeconds: 7200, + }); + TestBed.tick(); + + expect(api.position()?.positionSeconds).toBe(3000); + }); + + it('ignores live progress from a copy the button will not play', async () => { + const { api, livePosition } = setup( + [source(), ALT], + position(2538), + async () => position(600) + ); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + + livePosition.set({ + playlistId: 'playlist-9', + contentXtreamId: 5, + contentType: 'vod', + positionSeconds: 3000, + durationSeconds: 7200, + }); + TestBed.tick(); + + expect(api.position()?.positionSeconds).toBe(600); + }); + + it('ignores a pin on the route’s own copy', () => { + const load = jest.fn(); + const { api } = setup( + [source({ isPinned: true })], + position(2538), + load + ); + TestBed.tick(); + + // Same row: the position the page already loaded IS this copy's. + expect(api.position()?.positionSeconds).toBe(2538); + expect(load).not.toHaveBeenCalled(); + }); + + it('stops describing the old pin the moment a new one is chosen', async () => { + const load = jest + .fn() + .mockResolvedValueOnce(position(4200)) + .mockImplementationOnce( + () => new Promise(() => undefined) + ); + + const { api, sourcesSignal } = setup( + [source(), ALT], + position(2538), + load + ); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + expect(api.position()?.positionSeconds).toBe(4200); + + sourcesSignal.set([ + source(), + source({ + id: 'playlist-3:xtream:77', + playlistId: 'playlist-3', + contentId: 77, + isPinned: true, + isActive: false, + }), + ]); + TestBed.tick(); + + // A row IS loaded — the one belonging to the copy that was pinned a + // moment ago. Treating "loaded something" as "loaded this" makes the + // button offer the old copy's timecode for the new one. + expect(api.position()).toBeNull(); + expect(api.hasPosition()).toBe(false); + }); + + it('drops a lookup the pin outran', async () => { + let resolveFirst: (value: PlaybackPositionData | null) => void = () => { + /* replaced below */ + }; + const load = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce(position(60)); + + const { api, sourcesSignal } = setup( + [source(), ALT], + position(2538), + load + ); + TestBed.tick(); + + const other = source({ + id: 'playlist-3:xtream:77', + playlistId: 'playlist-3', + contentId: 77, + isPinned: true, + isActive: false, + }); + sourcesSignal.set([source(), other]); + TestBed.tick(); + await Promise.resolve(); + TestBed.tick(); + + // The first lookup lands late, describing a copy the button no longer + // plays. + resolveFirst(position(4200)); + await Promise.resolve(); + TestBed.tick(); + + expect(api.position()?.positionSeconds).toBe(60); + }); +}); + +describe('isResumablePosition', () => { + it('is false for a copy watched through', () => { + // The primary button reads Play at this point, so a pinned start must + // not seek back to where the film ended. + expect( + isResumablePosition({ ...position(6900), durationSeconds: 7200 }) + ).toBe(false); + }); + + it('is false at the very beginning', () => { + expect(isResumablePosition(position(0))).toBe(false); + expect(isResumablePosition(null)).toBe(false); + }); + + it('is true in the middle', () => { + expect(isResumablePosition(position(2538))).toBe(true); + }); +}); + +describe('formatPlaybackPosition', () => { + it('drops the hour when there is none', () => { + expect(formatPlaybackPosition(position(123))).toBe('02:03'); + }); + + it('keeps the hour when there is one', () => { + expect(formatPlaybackPosition(position(3723))).toBe('01:02:03'); + }); + + it('is empty without a position', () => { + expect(formatPlaybackPosition(null)).toBe(''); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts new file mode 100644 index 000000000..b9b8ce4a6 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts @@ -0,0 +1,150 @@ +import { Signal, computed, effect, signal } from '@angular/core'; +import { getPortalPlaybackProgressPercent } from '@iptvnator/portal/shared/util'; +import type { + PlaybackPositionData, + VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; + +/** + * The position the PRIMARY button acts on. + * + * The page loads the route copy's position, but a pin means the button plays + * a different copy — and positions are keyed by (playlist, stream), so that + * copy has its own row. Reading the route's would let the button say + * "Resume 42:00" and then start an unwatched copy from zero, or say "Play" + * and jump into the middle of one that was already watched. + * + * When a pin names another copy its row governs, INCLUDING when there is no + * row: never watched means Play, not Resume. + */ + +export interface PrimaryActionPositionDeps { + /** Discovered sources; the pinned one, if any, carries `isPinned`. */ + sources: Signal; + /** The route's own (playlist, stream), for telling "another copy" apart. */ + routePlaylistId: Signal; + routeContentId: Signal; + /** The route copy's position, as loaded by the page. */ + routePosition: Signal; + /** + * The last position seen from any copy. The pinned copy's stored row goes + * stale the moment the user watches it, and re-reading the database on + * every update would be a round-trip per timeupdate. + */ + livePosition: Signal; + load: (source: VodSourceDescriptor) => Promise; +} + +export interface PrimaryActionPosition { + position: Signal; + /** Whether the button should read Resume rather than Play. */ + hasPosition: Signal; + /** The pinned copy the button acts on, when it is not the route's own. */ + foreignPin: Signal; +} + +/** + * Whether a stored position is worth resuming from. + * + * Shared so the label and the start point cannot disagree: a copy watched to + * the end shows Play, and starting it must then mean starting it, not seeking + * back to the 95% mark it was left at. + */ +export function isResumablePosition( + position: PlaybackPositionData | null +): boolean { + const progress = getPortalPlaybackProgressPercent(position); + return progress > 0 && progress < 90; +} + +export function createPrimaryActionPosition( + deps: PrimaryActionPositionDeps +): PrimaryActionPosition { + /** The pinned copy, only while it is NOT the route's own row. */ + const foreignPin = computed(() => { + const pinned = deps.sources().find((source) => source.isPinned); + if ( + !pinned || + (pinned.playlistId === deps.routePlaylistId() && + pinned.contentId === deps.routeContentId()) + ) { + return null; + } + + return pinned; + }); + + const pinnedPosition = signal(null); + /** Distinguishes "not looked up yet" from "looked up, never watched". */ + const pinnedLoadedFor = signal(null); + + effect(() => { + const pinned = foreignPin(); + if (!pinned) { + pinnedPosition.set(null); + pinnedLoadedFor.set(null); + return; + } + + if (pinnedLoadedFor() === pinned.id) { + return; + } + + void deps.load(pinned).then((position) => { + // The pin can change across the lookup — applying a stale answer + // would describe a copy the button no longer plays. + if (foreignPin()?.id !== pinned.id) { + return; + } + + pinnedPosition.set(position); + pinnedLoadedFor.set(pinned.id); + }); + }); + + const position = computed(() => { + const pinned = foreignPin(); + if (!pinned) { + return deps.routePosition(); + } + + // The pin names a copy whose row has not been read yet. Showing the + // ROUTE copy's `Resume 42:18` here would be a label for a stream this + // button is not going to start — and a click across this window looks + // the pinned position up separately and begins somewhere else. Say + // nothing until the answer is in; empty beats wrong. + // + // Compared by identity rather than mere presence: re-pinning leaves + // the PREVIOUS copy's row loaded, and "something was loaded once" would + // let the button wear that copy's timecode until the new lookup lands. + if (pinnedLoadedFor() !== pinned.id) { + return null; + } + + // What just played beats what was stored before it did. + const live = deps.livePosition(); + const isPinnedCopy = + live?.playlistId === pinned.playlistId && + live?.contentXtreamId === pinned.contentId; + + return isPinnedCopy ? live : pinnedPosition(); + }); + + const hasPosition = computed(() => isResumablePosition(position())); + + return { position, hasPosition, foreignPin }; +} + +/** `01:02:03`, or `02:03` for anything under an hour. */ +export function formatPlaybackPosition( + position: PlaybackPositionData | null +): string { + if (!position) { + return ''; + } + + const date = new Date(0); + date.setSeconds(position.positionSeconds); + const timeString = date.toISOString().substring(11, 19); + return timeString.startsWith('00:') ? timeString.substring(3) : timeString; +} diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 48a487587..7827a0d89 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -16,3 +16,5 @@ export * from './lib/settings-store.service'; export * from './lib/sort.service'; export * from './lib/tmdb'; export * from './lib/xtream-pending-restore.service'; +export * from './lib/stream-probe.service'; +export * from './lib/vod-source-pin.service'; diff --git a/libs/services/src/lib/playlist-backup.service.pins.spec.ts b/libs/services/src/lib/playlist-backup.service.pins.spec.ts new file mode 100644 index 000000000..98eea4d2c --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.pins.spec.ts @@ -0,0 +1,250 @@ +import { XtreamPlaylistBackupEntry } from '@iptvnator/shared/interfaces'; +import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; +import { + createRestoreCollaborators, + createXtreamManifest, +} from './playlist-backup.xtream-fixtures'; + +/** + * VOD source pins through backup and restore. + * + * A pin is carried under the playlist it points AT, its match key names the + * film rather than the portal, and a present-but-empty collection is an + * answer — split from the hidden-category suite, which owns its own concern. + */ +describe('PlaylistBackupService Xtream source pins', () => { + const electronWindow = window as unknown as { electron?: unknown }; + + beforeEach(() => { + electronWindow.electron = {}; + }); + + afterEach(() => { + delete electronWindow.electron; + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('refuses to export a backup whose pin read failed', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + playlistsService: collaborators.playlistsService, + databaseService: collaborators.databaseService, + vodSourcePinService: { + isAvailable: true, + listForPlaylistOrThrow: jest + .fn() + .mockRejectedValue(new Error('database is locked')), + set: jest.fn().mockResolvedValue(true), + }, + }); + + // `sourcePins` is authoritative and restore CLEARS the playlist's pins + // before applying it, so a read that failed and reported "none" would + // produce a file that looks complete and wipes every pin the user had + // — through the one feature meant to protect them. Failing the export + // is the only honest outcome. + await expect(service.exportBackup()).rejects.toThrow(); + }); + + it('exports the pins that point at this playlist', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + playlistsService: collaborators.playlistsService, + databaseService: collaborators.databaseService, + vodSourcePinService: { + isAvailable: true, + listForPlaylistOrThrow: jest.fn().mockResolvedValue([ + { + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ]), + set: jest.fn().mockResolvedValue(true), + }, + }); + + const backup = await service.exportBackup(); + + const entry = backup.manifest + .playlists[0] as XtreamPlaylistBackupEntry; + // Without this every "main source" choice vanishes on restore, with + // nothing in the archive to say it was ever made. + expect(entry.userState.sourcePins).toEqual([ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ]); + }); + + it('restores pins against the imported playlist, not the exported one', async () => { + const collaborators = createRestoreCollaborators(); + const replacePins = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + isAvailable: true, + listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), + replaceForPlaylist: replacePins, + }, + }); + + const manifest = createXtreamManifest( + [], + [{ matchKey: 'tmdb:603', contentId: 501 }] + ); + + await service.importBackup(JSON.stringify(manifest)); + + // One call, not a clear plus writes: a write failing partway would + // leave the old pins gone and the archive half-applied. The match key + // identifies the film and carries over as-is; the playlist id is this + // installation's, not the archive's. + expect(replacePins).toHaveBeenCalledTimes(1); + expect(replacePins).toHaveBeenCalledWith('xtream-1', [ + { + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + }, + ]); + }); + + it('reports pins that could not be written', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + isAvailable: true, + listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), + // Reported rather than thrown, so ignoring the result would + // drop every preference while the summary claims the import + // succeeded. + replaceForPlaylist: jest.fn().mockResolvedValue(false), + }, + }); + + const manifest = createXtreamManifest( + [], + [{ matchKey: 'tmdb:603', contentId: 501 }] + ); + + const summary = await service.importBackup(JSON.stringify(manifest)); + + expect(summary).toEqual( + expect.objectContaining({ merged: 0, failed: 1 }) + ); + }); + + it('drops pins the backup does not contain', async () => { + const collaborators = createRestoreCollaborators(); + const clearPins = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + isAvailable: true, + listForPlaylistOrThrow: jest.fn().mockResolvedValue([ + { + matchKey: 'tmdb:999', + playlistId: 'xtream-1', + contentId: 7, + portalType: 'xtream', + }, + ]), + replaceForPlaylist: clearPins, + }, + }); + + // Present-but-empty is an answer, like the playback positions cleared + // beside it: leaving the current pins would resurrect preferences the + // archive deliberately does not contain. + await service.importBackup( + JSON.stringify(createXtreamManifest([], [])) + ); + + // By playlist and in one statement, so the surplus cannot survive and + // a partial application cannot be left behind. + expect(clearPins).toHaveBeenCalledWith('xtream-1', []); + }); + + it('omits the collection entirely when it cannot read pins', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + playlistsService: collaborators.playlistsService, + databaseService: collaborators.databaseService, + vodSourcePinService: { + // The PWA, where pins do not exist at all. + isAvailable: false, + listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), + }, + }); + + const backup = await service.exportBackup(); + + const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry; + // NOT `[]`. "Could not look" is a third answer, and writing it as + // "there are none" turns a web-made archive into an instruction that + // deletes every pin when imported on the desktop. + expect(entry.userState.sourcePins).toBeUndefined(); + }); + + it('leaves pins alone for an archive that has no opinion', async () => { + const collaborators = createRestoreCollaborators(); + const clearPins = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + // A pin EXISTS, or an empty list would make this pass whether + // or not the clear was skipped. + listForPlaylistOrThrow: jest.fn().mockResolvedValue([ + { + matchKey: 'tmdb:999', + playlistId: 'xtream-1', + contentId: 7, + portalType: 'xtream', + }, + ]), + set: jest.fn().mockResolvedValue(true), + clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: clearPins, + }, + }); + + // No `sourcePins` field at all — an older archive, which says nothing + // about pins rather than saying there are none. + await service.importBackup(JSON.stringify(createXtreamManifest([]))); + + expect(clearPins).not.toHaveBeenCalled(); + }); + + it('imports an archive written before pins existed', async () => { + const collaborators = createRestoreCollaborators(); + const setPin = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + isAvailable: true, + listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), + set: setPin, + clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: jest.fn().mockResolvedValue(true), + }, + }); + + // No `sourcePins` at all — absence is age, not damage. + const summary = await service.importBackup( + JSON.stringify(createXtreamManifest([])) + ); + + expect(summary).toEqual( + expect.objectContaining({ merged: 1, failed: 0 }) + ); + expect(setPin).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index ab5fd7962..16a1c6b58 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -150,6 +150,15 @@ describe('PlaylistBackupService export → import round-trip', () => { updatedAt: '2026-07-05T20:00:00.000Z', }, ], + sourcePins: [ + { + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ], epgUrls: ['https://epg.example.com/guide.xml'], }; } diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index f3e5a57a4..a14f9f3af 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -2,6 +2,7 @@ import { of } from 'rxjs'; import { PlaybackPositionData, Playlist, + VodSourcePin, XtreamBackupFavoriteItem, XtreamBackupRecentlyViewedItem, } from '@iptvnator/shared/interfaces'; @@ -61,6 +62,12 @@ export function createPlaylistBackupService( clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), savePlaybackPosition: jest.fn().mockResolvedValue(undefined), }, + vodSourcePinService: { + listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), + set: jest.fn().mockResolvedValue(true), + clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: jest.fn().mockResolvedValue(true), + }, pendingRestoreService: { set: jest.fn(), clear: jest.fn(), @@ -100,6 +107,7 @@ export interface FakeBackupBackendState { xtreamFavorites: FakeXtreamContentRow[]; xtreamRecent: FakeXtreamContentRow[]; playbackPositions: PlaybackPositionData[]; + sourcePins: VodSourcePin[]; epgUrls: string[]; } @@ -231,6 +239,33 @@ export function createStatefulBackupCollaborators( state.playbackPositions.push({ ...position }); }, }, + vodSourcePinService: { + listForPlaylistOrThrow: async (playlistId: string) => + state.sourcePins + .filter((pin) => pin.playlistId === playlistId) + .map((pin) => ({ ...pin })), + // The real table keys on matchKey alone, so a second write for the + // same film replaces the first rather than adding a row. + set: async (pin: VodSourcePin) => { + state.sourcePins = state.sourcePins.filter( + (existing) => existing.matchKey !== pin.matchKey + ); + state.sourcePins.push({ ...pin }); + return true; + }, + clear: async (matchKeys: string[]) => { + state.sourcePins = state.sourcePins.filter( + (pin) => !matchKeys.includes(pin.matchKey) + ); + return true; + }, + clearForPlaylist: async (playlistId: string) => { + state.sourcePins = state.sourcePins.filter( + (pin) => pin.playlistId !== playlistId + ); + return true; + }, + }, pendingRestoreService: new XtreamPendingRestoreService(), }; } diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 26cd646bf..881041f5f 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from 'rxjs'; import { PlaylistsService } from './playlists.service'; import { SettingsStore } from './settings-store.service'; import { DatabaseService } from './database-electron.service'; +import { VodSourcePinService } from './vod-source-pin.service'; import { PlaybackPositionService } from './playback-position.service'; import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; import { @@ -21,6 +22,7 @@ import { XtreamBackupCategoryType, XtreamBackupContentType, XtreamPlaylistBackupEntry, + XtreamBackupSourcePin, XtreamPendingRestoreState, createRandomId, } from '@iptvnator/shared/interfaces'; @@ -59,6 +61,7 @@ export class PlaylistBackupService { private readonly settingsStore = inject(SettingsStore); private readonly databaseService = inject(DatabaseService); private readonly playbackPositionService = inject(PlaybackPositionService); + private readonly vodSourcePinService = inject(VodSourcePinService); private readonly pendingRestoreService = inject( XtreamPendingRestoreService ); @@ -252,6 +255,14 @@ export class PlaylistBackupService { favorites: [], recentlyViewed: [], playbackPositions: [], + // NOT `[]`. Pins are Electron-only, so out here we cannot + // read them — which is not the same as knowing there are + // none. Restore treats the collection as authoritative and + // clears the playlist's pins before applying it, so an + // empty array exported from the web would wipe them the + // moment the archive was imported on the desktop. Omitted, + // the archive says "no opinion", exactly as one written + // before pins existed does. }, }; } @@ -263,6 +274,7 @@ export class PlaylistBackupService { favorites, recent, playbackPositions, + sourcePins, ] = await Promise.all([ this.databaseService.getAllXtreamCategories(playlist._id, 'live'), this.databaseService.getAllXtreamCategories(playlist._id, 'movies'), @@ -270,6 +282,12 @@ export class PlaylistBackupService { this.databaseService.getFavorites(playlist._id), this.databaseService.getRecentItems(playlist._id), this.playbackPositionService.getAllPlaybackPositions(playlist._id), + // Throws rather than reading a failure as "no pins": restore + // treats this collection as authoritative and clears the + // playlist's pins before applying it, so an empty list born of a + // failed read would wipe them. `null` where the store is not + // reachable at all, which is a different answer again — see below. + this.readSourcePinsForExport(playlist._id), ]); return { @@ -305,10 +323,46 @@ export class PlaylistBackupService { playbackPositions: playbackPositions.map((item) => ({ ...item, })), + // Carried under the playlist they point AT, so a restore that + // does not include that portal cannot resurrect a preference + // for something the archive never had. + // + // Present ONLY when this runtime can actually read them. The + // collection is authoritative on restore, so stating `[]` + // where the answer is "could not look" turns the archive into + // an instruction to delete. + ...(sourcePins ? { sourcePins } : {}), }, }; } + /** + * The playlist's pins for the archive, or `null` when this runtime cannot + * read them at all. + * + * Three outcomes, and conflating any two of them loses data: pins exist, + * there are none, and "could not look". Only the first two belong in an + * archive — the third has to leave `sourcePins` out entirely, so restore + * treats it as no opinion rather than as an instruction to delete. A read + * that FAILS is neither: it throws, and the export fails with it. + */ + private async readSourcePinsForExport( + playlistId: string + ): Promise { + if (!this.vodSourcePinService.isAvailable) { + return null; + } + + const pins = + await this.vodSourcePinService.listForPlaylistOrThrow(playlistId); + + return pins.map((pin) => ({ + matchKey: pin.matchKey, + contentId: pin.contentId, + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + })); + } + private buildStalkerEntry(playlist: Playlist): StalkerPlaylistBackupEntry { return { portalType: 'stalker', @@ -460,6 +514,17 @@ export class PlaylistBackupService { `Xtream backup "${entry.title}" has incomplete user state.` ); } + + // Absent in archives written before multi-source existed, so + // its absence is not damage — only a wrong type is. + if ( + entry.userState.sourcePins !== undefined && + !Array.isArray(entry.userState.sourcePins) + ) { + throw new PlaylistBackupError( + `Xtream backup "${entry.title}" has incomplete user state.` + ); + } break; case 'stalker': if ( @@ -750,6 +815,7 @@ export class PlaylistBackupService { }); } + private async restoreXtreamEntry( playlistId: string, entry: XtreamPlaylistBackupEntry @@ -839,6 +905,39 @@ export class PlaylistBackupService { playbackPosition ); } + + // Present-but-empty is an answer, like the positions cleared above: a + // backup that holds no pin for this playlist means the user had none, + // so leaving the current ones would resurrect preferences the archive + // deliberately does not contain. Absent (an older archive) means "no + // opinion", and those are left alone. + if (!state.sourcePins) { + return; + } + + // ONE call, not a clear followed by writes. Split up, a write that + // fails partway leaves the previous pins already deleted and only part + // of the archive applied — a state belonging to neither, reported as a + // failure the user has no way to undo. The match key identifies the + // film and survives untouched; only the playlist has a new id here. + const replaced = await this.vodSourcePinService.replaceForPlaylist( + playlistId, + state.sourcePins.map((pin) => ({ + matchKey: pin.matchKey, + playlistId, + contentId: pin.contentId, + portalType: 'xtream' as const, + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + })) + ); + + // Reported rather than thrown, so ignoring it would drop every + // preference while the summary claims the import succeeded. + if (!replaced) { + throw new PlaylistBackupError( + `Restoring the pinned sources for "${playlistId}" failed.` + ); + } } private async restoreXtreamCategoryVisibility( diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index dcfa79507..61f8fe308 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -7,6 +7,10 @@ import { XtreamPlaylistBackupEntry, } from '@iptvnator/shared/interfaces'; import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; +import { + createRestoreCollaborators, + createXtreamManifest, +} from './playlist-backup.xtream-fixtures'; /** * Regression coverage for issue #1017: hidden Xtream categories must be @@ -18,109 +22,6 @@ import { createPlaylistBackupService } from './playlist-backup.service.test-help describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { const electronWindow = window as unknown as { electron?: unknown }; - // Wire-shape rows as returned by the DB worker's category ops. - const categoryRowsByType: Record = { - live: [ - { - id: 11, - playlist_id: 'xtream-1', - name: 'News', - type: 'live', - xtream_id: 101, - hidden: true, - }, - { - id: 12, - playlist_id: 'xtream-1', - name: 'Sports', - type: 'live', - xtream_id: 102, - hidden: false, - }, - ], - movies: [ - { - id: 21, - playlist_id: 'xtream-1', - name: 'Drama', - type: 'movies', - xtream_id: 201, - hidden: true, - }, - ], - series: [], - }; - - const existingXtreamPlaylist = { - _id: 'xtream-1', - title: 'Xtream Portal', - count: 3, - importDate: '2026-04-20T00:00:00.000Z', - lastUsage: '2026-04-20T00:00:00.000Z', - autoRefresh: false, - serverUrl: 'http://portal.example.com', - username: 'user', - password: 'pass', - } as Playlist; - - function createXtreamManifest( - hiddenCategories: unknown[] - ): PlaylistBackupManifestV1 { - return { - kind: PLAYLIST_BACKUP_KIND, - version: PLAYLIST_BACKUP_VERSION, - exportedAt: '2026-04-21T00:00:00.000Z', - includeSecrets: true, - playlists: [ - { - portalType: 'xtream', - exportedId: 'xtream-1', - title: 'Xtream Portal', - autoRefresh: false, - connection: { - serverUrl: 'http://portal.example.com', - username: 'user', - password: 'pass', - }, - userState: { - hiddenCategories, - favorites: [], - recentlyViewed: [], - playbackPositions: [], - }, - } as unknown as XtreamPlaylistBackupEntry, - ], - }; - } - - function createRestoreCollaborators() { - return { - playlistsService: { - addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), - getAllData: jest.fn(() => of([existingXtreamPlaylist])), - getRawPlaylistById: jest.fn(() => of('#EXTM3U')), - handlePlaylistParsing: jest.fn(), - }, - databaseService: { - getAllXtreamCategories: jest.fn( - (_playlistId: string, type: string) => - Promise.resolve(categoryRowsByType[type] ?? []) - ), - getFavorites: jest.fn().mockResolvedValue([]), - getRecentItems: jest.fn().mockResolvedValue([]), - getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), - hasXtreamCategories: jest.fn().mockResolvedValue(true), - hasXtreamContent: jest.fn().mockResolvedValue(true), - restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), - updateCategoryVisibility: jest.fn().mockResolvedValue(true), - }, - pendingRestoreService: { - set: jest.fn(), - clear: jest.fn(), - }, - }; - } - beforeEach(() => { electronWindow.electron = {}; }); @@ -190,6 +91,12 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { ); }); + + + + + + it('rejects entries with missing user-state collections instead of wiping user data', async () => { const collaborators = createRestoreCollaborators(); const service = createPlaylistBackupService(collaborators); diff --git a/libs/services/src/lib/playlist-backup.xtream-fixtures.ts b/libs/services/src/lib/playlist-backup.xtream-fixtures.ts new file mode 100644 index 000000000..7e7e95004 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.xtream-fixtures.ts @@ -0,0 +1,119 @@ +import { of } from 'rxjs'; +import { + Playlist, + PlaylistBackupManifestV1, + PLAYLIST_BACKUP_KIND, + PLAYLIST_BACKUP_VERSION, + XtreamPlaylistBackupEntry, +} from '@iptvnator/shared/interfaces'; + +/** + * The Xtream restore scaffolding both backup suites need — the existing + * playlist, a manifest builder, and the collaborator stubs. Shared so the + * hidden-category and source-pin suites cannot drift apart. + */ + +// Wire-shape rows as returned by the DB worker's category ops. +const categoryRowsByType: Record = { + live: [ + { + id: 11, + playlist_id: 'xtream-1', + name: 'News', + type: 'live', + xtream_id: 101, + hidden: true, + }, + { + id: 12, + playlist_id: 'xtream-1', + name: 'Sports', + type: 'live', + xtream_id: 102, + hidden: false, + }, + ], + movies: [ + { + id: 21, + playlist_id: 'xtream-1', + name: 'Drama', + type: 'movies', + xtream_id: 201, + hidden: true, + }, + ], + series: [], +}; + +export const existingXtreamPlaylist = { + _id: 'xtream-1', + title: 'Xtream Portal', + count: 3, + importDate: '2026-04-20T00:00:00.000Z', + lastUsage: '2026-04-20T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'http://portal.example.com', + username: 'user', + password: 'pass', +} as Playlist; + +export function createXtreamManifest( + hiddenCategories: unknown[], + sourcePins?: unknown[] +): PlaylistBackupManifestV1 { + return { + kind: PLAYLIST_BACKUP_KIND, + version: PLAYLIST_BACKUP_VERSION, + exportedAt: '2026-04-21T00:00:00.000Z', + includeSecrets: true, + playlists: [ + { + portalType: 'xtream', + exportedId: 'xtream-1', + title: 'Xtream Portal', + autoRefresh: false, + connection: { + serverUrl: 'http://portal.example.com', + username: 'user', + password: 'pass', + }, + userState: { + hiddenCategories, + favorites: [], + recentlyViewed: [], + playbackPositions: [], + ...(sourcePins ? { sourcePins } : {}), + }, + } as unknown as XtreamPlaylistBackupEntry, + ], + }; +} + +export function createRestoreCollaborators() { + return { + playlistsService: { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => of([existingXtreamPlaylist])), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + handlePlaylistParsing: jest.fn(), + }, + databaseService: { + getAllXtreamCategories: jest.fn( + (_playlistId: string, type: string) => + Promise.resolve(categoryRowsByType[type] ?? []) + ), + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), + hasXtreamCategories: jest.fn().mockResolvedValue(true), + hasXtreamContent: jest.fn().mockResolvedValue(true), + restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), + updateCategoryVisibility: jest.fn().mockResolvedValue(true), + }, + pendingRestoreService: { + set: jest.fn(), + clear: jest.fn(), + }, + }; +} diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index af65ae281..7e07930a1 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -29,6 +29,7 @@ const DEFAULT_SETTINGS: Settings = { webPlayerSharedControls: false, playerAmbientMode: false, playerUpNextRail: true, + vodAutoFailover: false, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, @@ -222,6 +223,9 @@ export const SettingsStore = signalStore( playerUpNextRail: store.playerUpNextRail?.() ?? DEFAULT_SETTINGS.playerUpNextRail, + vodAutoFailover: + store.vodAutoFailover?.() ?? + DEFAULT_SETTINGS.vodAutoFailover, streamFormat: store.streamFormat(), openStreamOnDoubleClick: store.openStreamOnDoubleClick(), language: store.language(), diff --git a/libs/services/src/lib/stream-probe.service.spec.ts b/libs/services/src/lib/stream-probe.service.spec.ts new file mode 100644 index 000000000..2d177ba8d --- /dev/null +++ b/libs/services/src/lib/stream-probe.service.spec.ts @@ -0,0 +1,197 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import type { StreamProbeHeaders } from '@iptvnator/shared/interfaces'; +import { StreamProbeService } from './stream-probe.service'; + +type ProbeFn = jest.Mock< + Promise<{ status: number; url: string; latencyMs?: number }>, + [string, ('GET' | 'HEAD')?, StreamProbeHeaders?] +>; + +/** + * jsdom owns `window`, so the bridge is attached to it rather than replacing + * it — assigning over `globalThis.window` silently does nothing here. + */ +function withBridge(probeStreamUrl: ProbeFn | undefined) { + const host = window as unknown as { electron?: unknown }; + if (probeStreamUrl) { + host.electron = { probeStreamUrl }; + } else { + delete host.electron; + } + const injector = Injector.create({ providers: [] }); + return runInInjectionContext(injector, () => new StreamProbeService()); +} + +function ok(status: number, latencyMs = 42): ProbeFn { + return jest.fn().mockResolvedValue({ status, url: 'u', latencyMs }); +} + +describe('StreamProbeService', () => { + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + + it('reports unknown, not fail, without a bridge', async () => { + const service = withBridge(undefined); + + expect(service.isAvailable).toBe(false); + await expect(service.probe('http://x/y.mkv')).resolves.toEqual({ + status: 'unknown', + }); + }); + + it('maps a success status to ok with its latency', async () => { + const probe = ok(200); + const service = withBridge(probe); + + const result = await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenCalledWith('http://x/y.mkv', 'HEAD', undefined); + expect(result).toEqual( + expect.objectContaining({ status: 'ok', latencyMs: 42 }) + ); + }); + + it('does not answer one playlist with another’s probe result', async () => { + const probe = jest + .fn() + .mockResolvedValueOnce({ status: 200, url: 'http://x/y.mkv' }) + // 404, not 403: this test is about cache keying, and a 403 would + // now earn a ranged-GET retry and muddy the call count. + .mockResolvedValueOnce({ status: 404, url: 'http://x/y.mkv' }); + const service = withBridge(probe as ProbeFn); + + const first = await service.probe('http://x/y.mkv', 'HEAD', { + userAgent: 'PlayerOne/1.0', + }); + const second = await service.probe('http://x/y.mkv', 'HEAD', { + userAgent: 'PlayerTwo/2.0', + }); + + // Two playlists can share a stream URL and require different headers. + // Reusing the first answer would state the second source is dead + // without ever having asked it. + expect(probe).toHaveBeenCalledTimes(2); + expect(first.status).toBe('ok'); + expect(second.status).toBe('fail'); + }); + + it('carries the playlist headers into both attempts', async () => { + const headers = { userAgent: 'MyPlayer/2.0', referer: 'http://x/' }; + const probe = jest + .fn() + .mockResolvedValueOnce({ status: 405, url: 'http://x/y.mkv' }) + .mockResolvedValueOnce({ status: 200, url: 'http://x/y.mkv' }); + const service = withBridge(probe as ProbeFn); + + await service.probe('http://x/y.mkv', 'HEAD', headers); + + // The GET retry has to carry them too, or the fallback answers 403 + // for a stream the first attempt was merely not allowed to HEAD. + expect(probe).toHaveBeenNthCalledWith( + 1, + 'http://x/y.mkv', + 'HEAD', + headers + ); + expect(probe).toHaveBeenNthCalledWith( + 2, + 'http://x/y.mkv', + 'GET', + headers + ); + }); + + it('maps a refusal to fail once the GET retry agrees', async () => { + // 403 earns a retry, but a 403 to the ranged GET too is a real answer. + const service = withBridge(ok(403)); + + await expect(service.probe('http://x/y.mkv')).resolves.toEqual( + expect.objectContaining({ status: 'fail', httpStatus: 403 }) + ); + }); + + it('maps status 0 to unknown — never to unavailable', async () => { + // 0 covers a timeout and a policy rejection as well as a dead host, so + // it means "could not check", not "the source is dead". + const service = withBridge(ok(0)); + + await expect(service.probe('http://x/y.mkv')).resolves.toEqual( + expect.objectContaining({ status: 'unknown', httpStatus: 0 }) + ); + }); + + it.each([400, 403, 405, 501])( + 'retries with a ranged GET when the server refuses HEAD with %i', + async (status) => { + // Plenty of stream servers reject HEAD yet serve the media over + // GET; reporting those as unavailable would be a confident lie. + // 400 and 403 are what a WAF returns for an unexpected HEAD, and + // treating those as dead ranks a working source below worse ones. + const probe = jest + .fn() + .mockResolvedValueOnce({ status, url: 'u' }) + .mockResolvedValueOnce({ status: 206, url: 'u', latencyMs: 30 }) + .mockName('probeStreamUrl') as ProbeFn; + const service = withBridge(probe); + + const result = await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenNthCalledWith( + 1, + 'http://x/y.mkv', + 'HEAD', + undefined + ); + expect(probe).toHaveBeenNthCalledWith( + 2, + 'http://x/y.mkv', + 'GET', + undefined + ); + expect(result.status).toBe('ok'); + } + ); + + it('does not retry a status that names the resource, not the method', async () => { + // 404 is about this URL; retrying it with another verb proves nothing. + const probe = ok(404); + const service = withBridge(probe); + + await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('serves a repeat probe from cache', async () => { + const probe = ok(200); + const service = withBridge(probe); + + await service.probe('http://x/y.mkv'); + await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('collapses concurrent probes of the same url into one request', async () => { + const probe = ok(200); + const service = withBridge(probe); + + await Promise.all([ + service.probe('http://x/y.mkv'), + service.probe('http://x/y.mkv'), + ]); + + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('does not cache an unknown outcome', async () => { + // Caching "could not measure" would keep a good source looking + // unverified for the whole TTL. + const probe = ok(0); + const service = withBridge(probe); + + await service.probe('http://x/y.mkv'); + expect(service.peek('http://x/y.mkv')).toBeNull(); + }); +}); diff --git a/libs/services/src/lib/stream-probe.service.ts b/libs/services/src/lib/stream-probe.service.ts new file mode 100644 index 000000000..fd8ecc8f9 --- /dev/null +++ b/libs/services/src/lib/stream-probe.service.ts @@ -0,0 +1,200 @@ +import { Injectable } from '@angular/core'; +import type { + StreamProbeHeaders, + VodSourceProbeResult, +} from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; + +/** + * On-demand stream reachability checks via the Electron main process. + * + * The renderer cannot do this itself: a browser request to an arbitrary IPTV + * host is CORS-blocked, and `no-cors` returns an opaque response in which 200, + * 403 and 404 are indistinguishable. So in the PWA `isAvailable` is false and + * every source stays honestly "unchecked" rather than being guessed at. + * + * Probing is explicitly user-triggered — this is not a background pinger. + */ + +/** Results older than this are re-fetched; a stream can die at any moment. */ +const CACHE_TTL_MS = 30_000; + +interface CacheEntry { + result: VodSourceProbeResult; + storedAt: number; +} + +@Injectable({ providedIn: 'root' }) +export class StreamProbeService { + private readonly cache = new Map(); + /** Deduplicates concurrent probes of the same URL into one request. */ + private readonly inFlight = new Map>(); + + get isAvailable(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.electron?.probeStreamUrl === 'function' + ); + } + + /** + * The key an answer is stored under. + * + * Not the URL alone: the same stream URL can be shared by two playlists + * that require different headers, and one of them answering 403 says + * nothing about the other. Reusing that result would state a source is + * dead without ever having asked it. + */ + private cacheKey( + url: string, + method: 'GET' | 'HEAD', + headers?: StreamProbeHeaders + ): string { + return [ + url, + method, + headers?.userAgent ?? '', + headers?.referer ?? '', + headers?.origin ?? '', + ].join('\u0000'); + } + + /** A cached result for this exact request, if one is still fresh. */ + peek( + url: string, + method: 'GET' | 'HEAD' = 'HEAD', + headers?: StreamProbeHeaders + ): VodSourceProbeResult | null { + const entry = this.cache.get(this.cacheKey(url, method, headers)); + if (!entry || Date.now() - entry.storedAt > CACHE_TTL_MS) { + return null; + } + return entry.result; + } + + /** + * @param headers what the owning playlist plays this stream with. A panel + * that requires them answers 401/403 without them, and a source that plays + * fine must never be reported as dead. + */ + async probe( + url: string, + method: 'GET' | 'HEAD' = 'HEAD', + headers?: StreamProbeHeaders + ): Promise { + if (!this.isAvailable || !url) { + // Not "offline" — we are simply unable to find out. + return { status: 'unknown' }; + } + + const key = this.cacheKey(url, method, headers); + const cached = this.peek(url, method, headers); + if (cached) { + return cached; + } + + const pending = this.inFlight.get(key); + if (pending) { + return pending; + } + + const request = this.runProbe(url, method, headers).finally(() => { + this.inFlight.delete(key); + }); + this.inFlight.set(key, request); + return request; + } + + /** Drops cached results, e.g. after a playlist refresh changed the URLs. */ + invalidate(): void { + this.cache.clear(); + } + + private async runProbe( + url: string, + method: 'GET' | 'HEAD', + headers?: StreamProbeHeaders + ): Promise { + let result: VodSourceProbeResult; + + try { + let response = await window.electron.probeStreamUrl( + url, + method, + headers + ); + + // Plenty of stream servers reject HEAD outright yet serve the media + // happily over GET. Reporting those as unavailable would be a + // confident lie, so retry once with the ranged GET the main process + // already supports. + if (method === 'HEAD' && refusesHeadRequests(response.status)) { + response = await window.electron.probeStreamUrl( + url, + 'GET', + headers + ); + } + + result = { + status: toProbeStatus(response.status), + httpStatus: response.status, + latencyMs: response.latencyMs, + probedAt: new Date().toISOString(), + }; + } catch (error) { + // The probed URL is a stream URL, and Xtream builds those out + // of the username and password — never log one raw. + console.warn('Stream probe failed:', redactSensitiveData(error)); + result = { status: 'unknown' }; + } + + // Only cache answers. An 'unknown' is a failure to measure, and + // caching it would keep a perfectly good source looking unverified + // for the whole TTL. + if (result.status !== 'unknown') { + this.cache.set(this.cacheKey(url, method, headers), { + result, + storedAt: Date.now(), + }); + } + + return result; + } +} + +/** + * Map an HTTP status onto a verdict. + * + * Status 0 means no response was obtained at all — a timeout, or a URL the + * redirect-safety policy refused. That is NOT evidence the source is dead, so + * it maps to 'unknown' and the UI keeps offering a check. + */ +/** + * Statuses that may mean "not this method" rather than "not this resource". + * + * 405/501 say so outright. 403 and 400 are the ones a WAF or a stream server + * returns for a HEAD it does not expect while serving the same URL over GET + * perfectly well — and calling that source unavailable is a confident lie that + * also ranks it below worse ones. The retry is bounded (one ranged GET), so + * being generous here costs a request, while being strict costs a working + * source. + */ +function refusesHeadRequests(httpStatus: number): boolean { + return ( + httpStatus === 400 || + httpStatus === 403 || + httpStatus === 405 || + httpStatus === 501 + ); +} + +function toProbeStatus(httpStatus: number): VodSourceProbeResult['status'] { + if (httpStatus === 0) { + return 'unknown'; + } + if (httpStatus >= 200 && httpStatus < 400) { + return 'ok'; + } + return 'fail'; +} diff --git a/libs/services/src/lib/vod-source-pin.service.spec.ts b/libs/services/src/lib/vod-source-pin.service.spec.ts new file mode 100644 index 000000000..d7b8a8c25 --- /dev/null +++ b/libs/services/src/lib/vod-source-pin.service.spec.ts @@ -0,0 +1,71 @@ +import { VodSourcePinService } from './vod-source-pin.service'; + +/** + * The two listing methods differ in ONE way that matters: what a failed read + * means. Backup depends on that difference — its `sourcePins` collection is + * authoritative and restore clears the playlist's pins before applying it, so + * a failure reported as "no pins" writes a backup that silently wipes them. + */ + +const electronWindow = window as unknown as { + electron?: Record; +}; + +function withBridge(dbListVodSourcePins: unknown) { + electronWindow.electron = { + // `isAvailable` probes for this one. + dbGetVodSourcePin: jest.fn(), + dbListVodSourcePins, + }; +} + +describe('VodSourcePinService listing', () => { + let service: VodSourcePinService; + let warn: jest.SpyInstance; + + beforeEach(() => { + service = new VodSourcePinService(); + warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + delete electronWindow.electron; + warn.mockRestore(); + }); + + it('returns the pins the bridge reports', async () => { + const pin = { + matchKey: 'tmdb:603', + playlistId: 'p1', + contentId: 501, + portalType: 'xtream' as const, + }; + withBridge(jest.fn().mockResolvedValue([pin])); + + await expect(service.listForPlaylist('p1')).resolves.toEqual([pin]); + await expect(service.listForPlaylistOrThrow('p1')).resolves.toEqual([ + pin, + ]); + }); + + it('reads a failure as "no pins" only for the lenient caller', async () => { + withBridge( + jest.fn().mockRejectedValue(new Error('database is locked')) + ); + + await expect(service.listForPlaylist('p1')).resolves.toEqual([]); + // The whole point of the second method: backup must be able to tell a + // failed read from an empty one, or it exports a wipe. + await expect(service.listForPlaylistOrThrow('p1')).rejects.toThrow( + 'database is locked' + ); + }); + + it('reports no pins, without throwing, outside Electron', async () => { + delete electronWindow.electron; + + // Not a failure — there is no pin store in the PWA at all, so an empty + // list is the true answer and a backup built there is complete. + await expect(service.listForPlaylistOrThrow('p1')).resolves.toEqual([]); + }); +}); diff --git a/libs/services/src/lib/vod-source-pin.service.ts b/libs/services/src/lib/vod-source-pin.service.ts new file mode 100644 index 000000000..b18c285f9 --- /dev/null +++ b/libs/services/src/lib/vod-source-pin.service.ts @@ -0,0 +1,191 @@ +import { Injectable } from '@angular/core'; +import type { VodSourcePin } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; + +/** + * Persistence for the per-movie pinned source ("play this film from here"). + * + * Electron-only, like the discovery that feeds it: without cross-playlist + * matching there is nothing to pin between. In the PWA every call is a no-op + * and `isAvailable` is false, so the UI hides the affordance rather than + * offering a button that silently forgets. + */ +@Injectable({ providedIn: 'root' }) +export class VodSourcePinService { + get isAvailable(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.electron?.dbGetVodSourcePin === 'function' + ); + } + + /** + * @param matchKeys every key this movie may be stored under, MOST TRUSTED + * FIRST. A film pinned before TMDB enrichment landed is keyed by title and + * prefers a `tmdb:` key afterwards; passing both means the id arriving + * later does not orphan the pin. + */ + async get(matchKeys: string[]): Promise { + if (!this.isAvailable || matchKeys.length === 0) { + return null; + } + + try { + return await window.electron.dbGetVodSourcePin(matchKeys); + } catch (error) { + console.warn( + 'Reading the pinned VOD source failed:', + redactSensitiveData(error) + ); + return null; + } + } + + /** + * Drop every pin pointing at this playlist, in one statement. + * + * `clear()` caps its key list, so clearing a playlist through it would + * silently leave the surplus behind while reporting success. + */ + async clearForPlaylist(playlistId: string): Promise { + if (!this.isAvailable || !playlistId) { + return false; + } + + try { + const result = + await window.electron.dbClearVodSourcePinsForPlaylist( + playlistId + ); + return result?.success === true; + } catch (error) { + console.warn( + 'Clearing pinned VOD sources failed:', + redactSensitiveData(error) + ); + return false; + } + } + + /** + * Every pin pointing at this playlist, empty when there are none OR when + * the read failed. Only for callers that treat both the same way. + */ + async listForPlaylist(playlistId: string): Promise { + try { + return await this.readForPlaylist(playlistId); + } catch (error) { + console.warn( + 'Listing pinned VOD sources failed:', + redactSensitiveData(error) + ); + return []; + } + } + + /** + * As above, but a failed read THROWS instead of reading as "none". + * + * Backup needs this distinction and nothing else does. An export writes + * `sourcePins` as the authoritative set for the playlist, and restore + * clears whatever is there before applying it — so a read that failed and + * returned `[]` produces a backup that looks complete and silently wipes + * every pin the user had. Losing the preferences is bad; losing them via a + * file created to protect them is worse. + */ + async listForPlaylistOrThrow(playlistId: string): Promise { + return this.readForPlaylist(playlistId); + } + + private async readForPlaylist(playlistId: string): Promise { + if (!this.isAvailable || !playlistId) { + return []; + } + + return (await window.electron.dbListVodSourcePins(playlistId)) ?? []; + } + + /** + * Store the pin, also under `aliasKeys`, retiring `retireKeys` — all in + * the same transaction, because a half-applied change has no honest + * outcome to report. + * + * @param aliasKeys further keys naming exactly this film. A movie's + * identity grows as enrichment lands, and a pin recorded only under the + * richest key is invisible to the next reopen that has not been enriched. + */ + async set( + pin: VodSourcePin, + retireKeys: string[] = [], + aliasKeys: string[] = [] + ): Promise { + if (!this.isAvailable) { + return false; + } + + try { + const result = await window.electron.dbSetVodSourcePin( + pin, + retireKeys, + aliasKeys + ); + return result?.success === true; + } catch (error) { + console.warn( + 'Pinning the VOD source failed:', + redactSensitiveData(error) + ); + return false; + } + } + + /** + * Make this playlist's pins exactly `pins`, in one transaction. + * + * Restore needs the clear and the writes to be indivisible. Done in steps, + * a write that fails partway leaves the user's previous pins already gone + * and only part of the archive applied — a state belonging to neither, and + * reported as a failure the user cannot act on. + */ + async replaceForPlaylist( + playlistId: string, + pins: VodSourcePin[] + ): Promise { + if (!this.isAvailable || !playlistId) { + return false; + } + + try { + const result = await window.electron.dbReplaceVodSourcePins( + playlistId, + pins + ); + return result?.success === true; + } catch (error) { + console.warn( + 'Replacing pinned VOD sources failed:', + redactSensitiveData(error) + ); + return false; + } + } + + /** Clears the pin under every alias, so unpinning cannot be undone by a + * stale row stored under the other key form. */ + async clear(matchKeys: string[]): Promise { + if (!this.isAvailable || matchKeys.length === 0) { + return false; + } + + try { + const result = await window.electron.dbClearVodSourcePin(matchKeys); + return result?.success === true; + } catch (error) { + console.warn( + 'Unpinning the VOD source failed:', + redactSensitiveData(error) + ); + return false; + } + } +} diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts index ed067e7a9..89d160a13 100644 --- a/libs/shared/database/src/lib/connection-migrations.spec.ts +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -7,6 +7,8 @@ const { indexMigrationStatements, runMigrations, cleanupLegacyTmdbSearchCache, + upgradeContentTitleFtsTokenizer, + contentTitleFtsStatement, } = __databaseConnectionTestHooks; type SqliteHandle = Parameters[0]; @@ -52,6 +54,16 @@ const completedMigrationStateRule: HandlerRule = [ { get: () => ({ value: 'done' }) }, ]; +/** The live title index, already carrying the folding tokenizer. */ +const foldedIndexRule: HandlerRule = [ + 'SELECT sql FROM sqlite_master', + { + get: () => ({ + sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram remove_diacritics 1')", + }), + }, +]; + describe('createTables', () => { it('executes every fresh-install statement against the connection in order', () => { const { exec, sqlite } = createSqliteMock([]); @@ -84,7 +96,7 @@ describe('runMigrations error tolerance', () => { } }); const { sqlite } = createSqliteMock( - [completedMigrationStateRule], + [completedMigrationStateRule, foldedIndexRule], exec ); @@ -109,7 +121,7 @@ describe('runMigrations error tolerance', () => { } }); const { sqlite } = createSqliteMock( - [completedMigrationStateRule], + [completedMigrationStateRule, foldedIndexRule], exec ); @@ -252,3 +264,110 @@ describe('runMigrations Xtream cache deduplication', () => { expect(deleteContentRun).toHaveBeenCalledWith(11); }); }); + +/** + * The title index folds diacritics, or the cross-playlist matcher cannot see + * accented titles at all: it compares normalized titles ("amelie") against an + * index built from the raw one ("Amélie"). + */ +describe('content title FTS tokenizer upgrade', () => { + it('asks for diacritic folding in the statement it creates', () => { + expect(compactSql(contentTitleFtsStatement(true))).toContain( + "tokenize='trigram remove_diacritics 1'" + ); + expect(compactSql(contentTitleFtsStatement(false))).toContain( + "tokenize='trigram'" + ); + }); + + it('recreates and rebuilds the index, then records the migration', () => { + const rebuild = { run: jest.fn() }; + const marker = { run: jest.fn() }; + const { sqlite, exec, transaction } = createSqliteMock([ + ['SELECT value FROM app_state', { get: () => undefined }], + ['INSERT INTO content_title_fts(content_title_fts)', rebuild], + ['INSERT INTO app_state', marker], + ]); + + upgradeContentTitleFtsTokenizer(sqlite); + + // The tokenizer is fixed at CREATE time, so the table has to go. + const statements = exec.mock.calls.map(([sql]) => compactSql(sql)); + expect(statements).toContainEqual( + expect.stringContaining('DROP TABLE IF EXISTS content_title_fts') + ); + expect(statements).toContainEqual( + expect.stringContaining("tokenize='trigram remove_diacritics 1'") + ); + expect(rebuild.run).toHaveBeenCalled(); + expect(marker.run).toHaveBeenCalled(); + // One transaction, so a rejected CREATE rolls the drop back and the + // working index survives. + expect(transaction).toHaveBeenCalled(); + }); + + it('does nothing once the migration has completed', () => { + const { sqlite, exec } = createSqliteMock([ + completedMigrationStateRule, + foldedIndexRule, + ]); + + upgradeContentTitleFtsTokenizer(sqlite); + + expect(exec).not.toHaveBeenCalled(); + }); + + it('rebuilds when the record says done but the index is not folded', () => { + const rebuild = { run: jest.fn() }; + const { sqlite, exec } = createSqliteMock([ + completedMigrationStateRule, + [ + 'SELECT sql FROM sqlite_master', + { + get: () => ({ + sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram')", + }), + }, + ], + ['INSERT INTO content_title_fts(content_title_fts)', rebuild], + ]); + + upgradeContentTitleFtsTokenizer(sqlite); + + // `createTables` declares this table too, with the plain tokenizer, so + // the marker and the live table can disagree. Trusting the marker + // leaves a silently degraded index: discovery simply stops finding + // "Pokémon" for "pokemon", with nothing to show that it should have. + const statements = exec.mock.calls.map(([sql]) => compactSql(sql)); + expect(statements).toContainEqual( + expect.stringContaining("tokenize='trigram remove_diacritics 1'") + ); + expect(rebuild.run).toHaveBeenCalled(); + }); + + it('leaves the index alone when the runtime rejects the tokenizer', () => { + const marker = { run: jest.fn() }; + const exec = jest.fn((statement: string) => { + if (statement.includes('remove_diacritics')) { + throw new Error('unknown tokenizer option'); + } + }); + const { sqlite } = createSqliteMock( + [ + ['SELECT value FROM app_state', { get: () => undefined }], + ['INSERT INTO app_state', marker], + ], + exec + ); + + upgradeContentTitleFtsTokenizer(sqlite); + + // The probe failed, so the real table was never dropped — and the + // migration is NOT marked done, so a newer SQLite retries it. + const statements = exec.mock.calls.map(([sql]) => compactSql(sql)); + expect(statements).not.toContainEqual( + expect.stringContaining('DROP TABLE IF EXISTS content_title_fts') + ); + expect(marker.run).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 244a3319a..c4c664b0e 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -28,6 +28,8 @@ const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY = 'migration:xtream-content-added-epoch-seconds:v1'; const CONTENT_TITLE_FTS_MIGRATION_KEY = 'migration:content-title-fts-trigram:v1'; +const CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY = + 'migration:content-title-fts-remove-diacritics:v1'; const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY = 'migration:epg-program-source-url-backfill:v1'; const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY = @@ -301,6 +303,18 @@ const CREATE_TABLE_STATEMENTS = [ updated_at TEXT DEFAULT (datetime('now')) )`, `CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id)`, + // VOD multi-source pins — the per-movie preferred playlist. Keyed by a + // portal-agnostic match key, since the same film has a different provider + // id in every portal. + `CREATE TABLE IF NOT EXISTS vod_source_pins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + match_key TEXT NOT NULL UNIQUE, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + content_id INTEGER NOT NULL, + portal_type TEXT NOT NULL CHECK (portal_type IN ('xtream', 'stalker', 'm3u')), + updated_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_vod_source_pins_playlist ON vod_source_pins(playlist_id)`, // Playback Positions table `CREATE TABLE IF NOT EXISTS playback_positions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -383,6 +397,8 @@ export const __databaseConnectionTestHooks = { ensureDownloadsPauseResumeSchema, normalizeXtreamContentAddedEpochs, ensureContentTitleFts, + upgradeContentTitleFtsTokenizer, + contentTitleFtsStatement, backfillEpgProgramSourceUrls, cleanupLegacyTmdbSearchCache, runMigrations, @@ -606,6 +622,134 @@ function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void { } } +/** + * The title index, folding diacritics when the runtime can. + * + * Cross-playlist matching compares NORMALIZED titles ("Amélie" -> "amelie"), + * but the index holds the raw title, and the trigram tokenizer does not fold + * diacritics by default. Every accented title was therefore invisible to it: + * two identical `Amélie` entries produced no candidates at all. + * + * `remove_diacritics` needs SQLite 3.45+, so an older runtime keeps the plain + * tokenizer rather than losing the index — accented titles stay unmatched + * there, which is exactly the behaviour it had before. + */ +function contentTitleFtsStatement(removeDiacritics: boolean): string { + const tokenize = removeDiacritics + ? `'trigram remove_diacritics 1'` + : `'trigram'`; + return `CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5( + title, + content='content', + content_rowid='id', + tokenize=${tokenize} + )`; +} + +/** Whether this SQLite accepts the folding tokenizer, asked without risk. */ +/** + * Whether the title index that actually exists folds diacritics, read from its + * own stored DDL rather than from the migration record. + * + * A missing table answers `false`, which is the useful answer: there is nothing + * folded to keep, so the caller rebuilds. + */ +function contentTitleFtsFoldsDiacritics(sqliteDb: Database.Database): boolean { + const row = sqliteDb + .prepare( + `SELECT sql FROM sqlite_master + WHERE type = 'table' AND name = 'content_title_fts'` + ) + .get() as { sql?: unknown } | undefined; + + return ( + typeof row?.sql === 'string' && row.sql.includes('remove_diacritics') + ); +} + +function supportsTrigramDiacriticFolding(sqliteDb: Database.Database): boolean { + try { + sqliteDb.exec( + `CREATE VIRTUAL TABLE temp.content_title_fts_probe USING fts5( + title, tokenize='trigram remove_diacritics 1' + )` + ); + sqliteDb.exec(`DROP TABLE temp.content_title_fts_probe`); + return true; + } catch { + return false; + } +} + +/** + * Rebuild the title index with diacritic folding. + * + * The tokenizer is fixed at CREATE time, so an existing database keeps the + * old one until the table is recreated. Wrapped in a transaction: if the + * CREATE is rejected the drop rolls back and the working index survives. + */ +function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean { + try { + const migrationState = sqliteDb + .prepare(`SELECT value FROM app_state WHERE key = ?`) + .get(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY) as + | { value?: unknown } + | undefined; + + // The marker alone is not evidence. `createTables` declares this table + // too, with the plain tokenizer, so a table recreated by that path + // after the marker was written would be silently unfolded — and a + // degraded index is invisible: discovery just stops finding "Pokémon" + // for "pokemon". Ask the live table instead of trusting the record. + if ( + migrationState?.value === 'done' && + contentTitleFtsFoldsDiacritics(sqliteDb) + ) { + return false; + } + + if (!supportsTrigramDiacriticFolding(sqliteDb)) { + // Not marked done: a later app version ships a newer SQLite, and + // this should upgrade itself then rather than stay degraded. + return false; + } + + const executeMigration = sqliteDb.transaction(() => { + sqliteDb.exec(`DROP TABLE IF EXISTS content_title_fts`); + sqliteDb.exec(contentTitleFtsStatement(true)); + sqliteDb + .prepare( + `INSERT INTO content_title_fts(content_title_fts) + VALUES ('rebuild')` + ) + .run(); + + sqliteDb + .prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ) + .run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY); + }); + + executeMigration(); + return true; + } catch (error) { + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? error) + : String(error); + + console.warn( + `Content title FTS tokenizer upgrade failed (continuing): ${message}` + ); + return false; + } +} + function ensureContentTitleFts(sqliteDb: Database.Database): void { try { const migrationState = sqliteDb @@ -939,7 +1083,11 @@ function runMigrations(sqliteDb: Database.Database): void { cleanupLegacyTmdbSearchCache(sqliteDb); ensureDownloadsPauseResumeSchema(sqliteDb); runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS); - ensureContentTitleFts(sqliteDb); + // The tokenizer upgrade recreates and rebuilds the index itself, so the + // plain rebuild below would only repeat work it just did. + if (!upgradeContentTitleFtsTokenizer(sqliteDb)) { + ensureContentTitleFts(sqliteDb); + } deduplicateXtreamCache(sqliteDb); normalizeXtreamContentAddedEpochs(sqliteDb); runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS); diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 4386f8b26..e87f3cbb4 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -19,6 +19,7 @@ import { // Manual EPG-to-channel mappings live in their own schema module; re-export // them so `import * as schema from './schema'` keeps a complete namespace. export * from './epg-mapping.schema'; +export * from './vod-source-pins.schema'; // Playlists table export const playlists = sqliteTable('playlists', { diff --git a/libs/shared/database/src/lib/vod-source-pins.schema.ts b/libs/shared/database/src/lib/vod-source-pins.schema.ts new file mode 100644 index 000000000..c55854ae4 --- /dev/null +++ b/libs/shared/database/src/lib/vod-source-pins.schema.ts @@ -0,0 +1,41 @@ +/** + * Drizzle ORM schema for VOD multi-source pins — the user's per-movie choice + * of which playlist a film should play from. + * + * Split out of schema.ts to keep that file within the repository's + * max-lines budget; re-exported from there so `import * as schema` + * consumers keep seeing a single schema namespace. + */ + +import { sql } from 'drizzle-orm'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +// Per-movie preferred source. Keyed by a portal-agnostic match key +// (`tmdb:{id}` or `title:{base}:{year}`) rather than by a provider id, +// because the same film has a different id in every portal. +// +// The playlist foreign key with ON DELETE CASCADE lives in the raw +// CREATE TABLE in connection.ts — which is what actually creates this table — +// rather than in a Drizzle `.references()`. Declaring it here would mean +// importing `playlists` from schema.ts, which re-exports this module, and that +// import cycle is exactly what `epg-mapping.schema.ts` avoids too. +export const vodSourcePins = sqliteTable( + 'vod_source_pins', + { + id: integer('id').primaryKey({ autoIncrement: true }), + matchKey: text('match_key').notNull().unique(), + playlistId: text('playlist_id').notNull(), + // Provider-side id within that playlist (Xtream `stream_id`). + contentId: integer('content_id').notNull(), + portalType: text('portal_type', { + enum: ['xtream', 'stalker', 'm3u'], + }).notNull(), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + playlistIdx: index('idx_vod_source_pins_playlist').on(table.playlistId), + }) +); + +export type VodSourcePinRow = typeof vodSourcePins.$inferSelect; +export type NewVodSourcePinRow = typeof vodSourcePins.$inferInsert; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index b8592bf20..290bea8ca 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -31,6 +31,7 @@ export * from './lib/playlist-refresh.interface'; export * from './lib/playlist.interface'; export * from './lib/portal-activity-item.interface'; export * from './lib/portal-debug.interface'; +export * from './lib/playlist-display-label.util'; export * from './lib/portal-playback.interface'; export * from './lib/random-id.util'; export * from './lib/security-policy-error.utils'; @@ -43,6 +44,8 @@ export * from './lib/theme.enum'; export * from './lib/season-marker.util'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; +export * from './lib/vod-source.interface'; +export * from './lib/vod-source-match-key.util'; export * from './lib/xtream-account-info-dialog-data.interface'; export * from './lib/xtream-category.interface'; export * from './lib/xtream-code-actions'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index d67b3cad5..e8905a06e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -32,6 +32,11 @@ import { } from './portal-playback.interface'; import { PortalDebugEvent } from './portal-debug.interface'; import { CatalogTitleMatch } from './catalog-title-match.interface'; +import { + StreamProbeHeaders, + VodSourceCandidateRow, + VodSourcePin, +} from './vod-source.interface'; import { Settings } from './settings.interface'; import { TmdbCacheEntry, @@ -266,8 +271,17 @@ export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult { } export interface ElectronBridgeXtreamProbeResult { + /** + * HTTP status, or 0 when no response was obtained. + * + * 0 covers a timeout and a URL rejected by the redirect-safety policy as + * well as a dead host, so it means "could not check" — never "offline". + */ status: number; url: string; + /** Round-trip time; present whenever the request completed either way. */ + latencyMs?: number; + error?: string; } export interface ElectronBridgeEpgFetchResult extends ElectronBridgeResult { @@ -699,6 +713,13 @@ export interface ElectronBridgeApi { url: string, method?: 'GET' | 'HEAD' ) => Promise; + /** Generic stream reachability probe (VOD multi-source availability) */ + probeStreamUrl: ( + url: string, + method?: 'GET' | 'HEAD', + /** Playback headers the owning playlist requires, when it has any. */ + headers?: StreamProbeHeaders + ) => Promise; refreshPlaylist: ( payload: PlaylistRefreshPayload ) => Promise; @@ -876,6 +897,40 @@ export interface ElectronBridgeApi { >; /** Cross-playlist title matching (actor page "All portals" scope) */ dbMatchTitles: (titles: string[]) => Promise; + /** VOD multi-source: the same movie in the user's other Xtream playlists */ + dbFindTitleSources: (request: { + title: string; + year?: number | null; + excludePlaylistId?: string | null; + /** A stream id inside the excluded playlist to keep anyway (a pin). */ + keepContentId?: number | null; + }) => Promise; + /** Per-movie pinned source; keys are passed most-trusted first */ + dbGetVodSourcePin: (matchKeys: string[]) => Promise; + /** Every pin pointing at this playlist — used by playlist backup. */ + dbListVodSourcePins: (playlistId: string) => Promise; + /** Bulk clear: not keyed, so no `MAX_KEYS_PER_LOOKUP` truncation. */ + dbClearVodSourcePinsForPlaylist: ( + playlistId: string + ) => Promise; + /** + * `aliasKeys` receive the same pin, and `retireKeys` are removed, all in + * the SAME transaction as the write. Aliases keep a pin readable under the + * poorer key forms the movie had before enrichment. + */ + dbSetVodSourcePin: ( + pin: VodSourcePin, + retireKeys?: string[], + aliasKeys?: string[] + ) => Promise; + /** Makes the playlist's pins exactly `pins`, in one transaction. */ + dbReplaceVodSourcePins: ( + playlistId: string, + pins: VodSourcePin[] + ) => Promise; + dbClearVodSourcePin: ( + matchKeys: string[] + ) => Promise; onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void ) => () => void; diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts index 2ff9300db..a22017fd8 100644 --- a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -58,11 +58,25 @@ export interface XtreamBackupRecentlyViewedItem { viewedAt: string; } +/** + * A per-movie preferred source, carried under the playlist it points AT. + * + * `matchKey` identifies the film, not the portal, so it survives the restore + * untouched — only the playlist id has to be remapped to the imported copy. + */ +export interface XtreamBackupSourcePin { + matchKey: string; + contentId: number; + updatedAt?: string; +} + export interface XtreamBackupUserState { hiddenCategories: XtreamBackupHiddenCategory[]; favorites: XtreamBackupFavoriteItem[]; recentlyViewed: XtreamBackupRecentlyViewedItem[]; playbackPositions: PlaybackPositionData[]; + /** Optional: absent in archives written before multi-source existed. */ + sourcePins?: XtreamBackupSourcePin[]; } export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry { diff --git a/libs/shared/interfaces/src/lib/playlist-display-label.util.spec.ts b/libs/shared/interfaces/src/lib/playlist-display-label.util.spec.ts new file mode 100644 index 000000000..2833c2cae --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-display-label.util.spec.ts @@ -0,0 +1,84 @@ +import { + playlistDisplayLabel, + playlistNameHasCredentials, +} from './playlist-display-label.util'; + +describe('playlistDisplayLabel', () => { + it('collapses a credential-bearing URL to its host', () => { + expect( + playlistDisplayLabel( + 'http://vipmax.site:8080/get.php?username=00%3A1A%3A79&password=5fd87ba4&type=m3u_plus' + ) + ).toBe('vipmax.site:8080'); + }); + + it('strips credentials typed outside the URL', () => { + // The default :80 is dropped by URL normalization, which reads better + // anyway; a non-default port is kept (see the case above). + expect( + playlistDisplayLabel('http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2') + ).toBe('marveltv.info'); + }); + + it('keeps a name the user actually chose', () => { + expect(playlistDisplayLabel('DE movies/kinder')).toBe( + 'DE movies/kinder' + ); + expect(playlistDisplayLabel('cord-cutter')).toBe('cord-cutter'); + }); + + it('removes hand-typed credentials from a non-URL name', () => { + expect( + playlistDisplayLabel('MyPortal Username: abc Password: s3cret') + ).toBe('MyPortal'); + }); + + it('never renders a password fragment', () => { + const labels = [ + 'http://x.tv:80/get.php?username=joe&password=hunter2', + 'Portal user==joe pass==hunter2', + 'http://62.182.83.108 USERNAME : 99558627 PASSWORD : uUhQQlcHjD', + ].map((n) => playlistDisplayLabel(n)); + + for (const label of labels) { + expect(label).not.toMatch(/hunter2|uUhQQlcHjD|99558627/); + } + }); + + it('truncates an over-long name', () => { + const label = playlistDisplayLabel('a'.repeat(120)); + expect(label.length).toBeLessThanOrEqual(42); + expect(label.endsWith('…')).toBe(true); + }); + + it('falls back when there is nothing to show', () => { + expect(playlistDisplayLabel('', 'playlist-1')).toBe('playlist-1'); + expect(playlistDisplayLabel(null, 'playlist-1')).toBe('playlist-1'); + expect(playlistDisplayLabel(undefined)).toBe(''); + }); + + it('does not return an empty label for a credentials-only name', () => { + expect(playlistDisplayLabel('username=joe password=x')).toBe( + 'Playlist' + ); + }); +}); + +describe('playlistNameHasCredentials', () => { + it('detects query-string credentials', () => { + expect( + playlistNameHasCredentials('http://x.tv/get.php?username=a&password=b') + ).toBe(true); + }); + + it('detects hand-typed credentials', () => { + expect(playlistNameHasCredentials('Portal usr== abc pas== def')).toBe( + true + ); + }); + + it('is false for an ordinary name', () => { + expect(playlistNameHasCredentials('cord-cutter')).toBe(false); + expect(playlistNameHasCredentials('http://plain.tv:8080')).toBe(false); + }); +}); diff --git a/libs/shared/interfaces/src/lib/playlist-display-label.util.ts b/libs/shared/interfaces/src/lib/playlist-display-label.util.ts new file mode 100644 index 000000000..6bab72a31 --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-display-label.util.ts @@ -0,0 +1,82 @@ +/** + * A short, safe label for a playlist. + * + * Users routinely name a playlist after the URL they pasted, and those URLs + * carry `username=` / `password=` query parameters — or even + * `usr== X pas== Y` typed into the name by hand. Rendering that verbatim puts + * live credentials on screen in a list that exists to be looked at, screenshotted + * and screen-shared. + * + * So anything URL-shaped collapses to its host, and any credential-looking + * fragment is dropped. A name the user actually chose is left alone. + */ + +/** Query keys whose presence means the string is a credential-bearing URL. */ +const CREDENTIAL_KEYS = /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i; + +/** Hand-typed credential fragments, e.g. "usr== Tl24uy0xGi pas== abc". */ +const INLINE_CREDENTIALS = + /\b(?:usr|user|username|pas|pass|password|login|token)\s*[:=]+\s*\S+/gi; + +const MAX_LABEL_LENGTH = 42; + +/** + * Turn a stored playlist name into something short enough to scan and safe + * enough to show. Never returns an empty string when given any input, so a row + * can always be identified by something. + */ +export function playlistDisplayLabel( + rawName: string | null | undefined, + fallback = '' +): string { + const name = (rawName ?? '').trim(); + if (!name) { + return fallback; + } + + const host = extractHost(name); + if (host) { + return host; + } + + // Not a URL, but may still have credentials typed into the name. + const cleaned = name + .replace(INLINE_CREDENTIALS, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); + + const safe = cleaned || fallback || 'Playlist'; + return safe.length > MAX_LABEL_LENGTH + ? `${safe.slice(0, MAX_LABEL_LENGTH - 1).replace(/\s+$/, '')}…` + : safe; +} + +/** True when the stored name would have leaked credentials as-is. */ +export function playlistNameHasCredentials( + rawName: string | null | undefined +): boolean { + const name = rawName ?? ''; + return CREDENTIAL_KEYS.test(name) || INLINE_CREDENTIALS.test(name); +} + +/** + * The host of the first URL in the string, port included. + * + * Handles both a clean URL and the common "http://host:8080 user== x pas== y" + * shape, where the credentials sit outside the URL entirely. + */ +function extractHost(value: string): string | null { + const match = value.match(/https?:\/\/[^\s/?#]+/i); + if (!match) { + return null; + } + + try { + const url = new URL(match[0]); + return url.host || null; + } catch { + // Malformed but clearly URL-shaped — strip the scheme by hand rather + // than fall through and render the credential-bearing remainder. + return match[0].replace(/^https?:\/\//i, '') || null; + } +} diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 83daa7116..1091391ea 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -15,6 +15,25 @@ export enum VideoPlayer { ArtPlayer = 'artplayer', } +/** + * Whether a failed stream on this player reaches the app at all. + * + * Only the built-in web players raise a playback diagnostic: Embedded MPV has + * its diagnostics suppressed, and MPV/VLC play outside the app entirely. On + * those, anything keyed off a playback failure — VOD auto-failover above all — + * can never fire, so offering the control there promises a feature that does + * nothing. + */ +export function reportsPlaybackFailures( + player: VideoPlayer | null | undefined +): boolean { + return ( + player === VideoPlayer.VideoJs || + player === VideoPlayer.Html5Player || + player === VideoPlayer.ArtPlayer + ); +} + export enum StartupBehavior { FirstView = 'first-view', RestoreLastView = 'restore-last-view', @@ -96,6 +115,16 @@ export interface Settings { * missing values mean enabled. Only affects the built-in web players. */ playerUpNextRail?: boolean; + /** + * When a movie fails to play and the same film exists in another imported + * playlist, switch to it automatically instead of showing the error. + * + * Off by default and deliberately opt-in: another source can carry a + * different dub or cut, so switching is never silent — the toast always + * announces it and offers an undo. Each source is tried at most once per + * session, so this cannot loop. + */ + vodAutoFailover?: boolean; epgUrl: string[]; streamFormat: StreamFormat; openStreamOnDoubleClick: boolean; diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.ts b/libs/shared/interfaces/src/lib/title-normalization.util.ts index e2963c83b..5e95dc2e2 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.ts @@ -293,3 +293,36 @@ export function extractYear( const fromTitle = rawTitle?.match(YEAR_PATTERN)?.[0]; return fromTitle ? Number(fromTitle) : null; } + +/** Bracketed release tag: "Dune (2021)", "Dune [2021]". */ +const BRACKETED_YEAR_PATTERN = /[([{]\s*(19\d{2}|20\d{2})\s*[)\]}]/; + +/** + * The year a provider title states as a release TAG, never one that belongs to + * the film's name. + * + * `extractYear` reads any year anywhere in the title, which is the right answer + * where a year is only a search hint that scoring will confirm. It is the wrong + * answer wherever the year becomes part of an IDENTITY: "2001: A Space Odyssey" + * is not a 2001 film, and calling it one makes every genuine 1968 copy fail the + * year gate — the movie then has no alternative sources at all — while its pin + * key moves the moment enrichment supplies the real year. + * + * Only bracketed and trailing forms count. The trailing form stays ambiguous on + * purpose ("Blade Runner 2049" is a title, not a tag); that is what the + * `exact`/`base` two-tier match is for, and nothing here can settle it. + */ +export function releaseTagYear( + rawTitle: string | null | undefined +): number | null { + if (!rawTitle) { + return null; + } + + // Before `normalizeTitleKeys`, which strips bracketed segments wholesale + // and would take the tag with them. + const bracketed = rawTitle.match(BRACKETED_YEAR_PATTERN); + return bracketed + ? Number(bracketed[1]) + : normalizeTitleKeys(rawTitle).trailingYear; +} diff --git a/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts b/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts new file mode 100644 index 000000000..ff7da2d11 --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts @@ -0,0 +1,91 @@ +import { + buildVodSourceMatchKey, + buildVodSourceMatchKeyCandidates, + isTmdbMatchKey, + buildVodSourceMatchKeyWriteKeys, +} 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([]); + }); +}); + +describe('buildVodSourceMatchKeyWriteKeys — the ambiguous alias', () => { + it('is not written beside a precise key', () => { + // `title:dune:` names every remake. Retiring it while storing the + // tmdb pin could delete another film's pre-enrichment preference. + expect( + buildVodSourceMatchKeyWriteKeys({ + tmdbId: 603, + title: 'Dune', + year: null, + }) + ).toEqual(['tmdb:603']); + }); + + it('is still used when it is the only key there is', () => { + // Refusing to pin at all would be worse than an imprecise pin. + expect( + buildVodSourceMatchKeyWriteKeys({ title: 'Dune', year: null }) + ).toEqual(['title:dune:']); + }); + + it('is unnecessary once a year disambiguates the title', () => { + expect( + buildVodSourceMatchKeyWriteKeys({ + tmdbId: 603, + title: 'Dune', + year: 2021, + }) + ).toEqual(['tmdb:603', 'title:dune:2021']); + }); +}); 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 new file mode 100644 index 000000000..637a2d4b0 --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts @@ -0,0 +1,138 @@ +/** + * Portal-agnostic identity for a movie, used to key multi-source pins. + * + * Provider ids cannot be used: the same film carries a different `stream_id` + * in every portal, which is precisely the problem this feature solves. A TMDB + * id is the strongest identity when both sides have one, but Stalker rarely + * does and Xtream's arrives asynchronously after enrichment — so the title + * form has to work standalone and stay stable when a TMDB id shows up later. + */ + +import { normalizeTitleKeys } from './title-normalization.util'; + +const TMDB_PREFIX = 'tmdb:'; +const TITLE_PREFIX = 'title:'; + +/** + * Build the canonical key for a movie. + * + * Prefers `tmdb:{id}`; falls back to `title:{normalizedBase}:{year}`. The + * year-stripped `base` form is used deliberately: the trailing year in a + * provider title is usually a release tag, and portals disagree about whether + * to include it. + * + * Returns `null` when there is nothing identifying to key on, so callers are + * forced to handle "cannot pin this" rather than silently writing a junk row. + */ +export function buildVodSourceMatchKey(input: { + tmdbId?: number | string | null; + title?: string | null; + year?: number | null; +}): string | null { + const tmdbId = normalizeTmdbId(input.tmdbId); + if (tmdbId !== null) { + return `${TMDB_PREFIX}${tmdbId}`; + } + + const { base } = normalizeTitleKeys(input.title); + if (!base) { + return null; + } + + return `${TITLE_PREFIX}${base}:${input.year ?? ''}`; +} + +/** + * Every key a movie may legitimately be stored under, most-trusted first. + * + * 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; + title?: string | null; + 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) { + push(`${TMDB_PREFIX}${tmdbId}`); + } + + 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; +} + +/** + * 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 }); + // The yearless form (`title:dune:`) names every remake at once, so once a + // precise key exists it must not be written OR retired alongside it — the + // row may hold a different film's pre-enrichment preference. When it is + // the ONLY key we have, it is still better than refusing to pin at all, + // and `retirablePinKeys` re-adds it whenever this session actually read + // it. + const isAmbiguous = titleKey?.endsWith(':') === true; + const hasPreciseKey = keys.length > 0; + if ( + titleKey && + !keys.includes(titleKey) && + !(isAmbiguous && hasPreciseKey) + ) { + keys.push(titleKey); + } + + return keys; +} + +export function isTmdbMatchKey(key: string): boolean { + return key.startsWith(TMDB_PREFIX); +} + +/** + * Coerce a provider-supplied TMDB id to a positive integer. + * + * Xtream sends this field as a string, as `0`, and as `""` depending on the + * panel build; none of those are usable ids. + */ +function normalizeTmdbId(raw: number | string | null | undefined): number | null { + if (raw === null || raw === undefined || raw === '') { + return null; + } + + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} diff --git a/libs/shared/interfaces/src/lib/vod-source.interface.ts b/libs/shared/interfaces/src/lib/vod-source.interface.ts new file mode 100644 index 000000000..46745e19c --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-source.interface.ts @@ -0,0 +1,164 @@ +/** + * VOD multi-source: the same movie found across several imported playlists. + * + * The central rule of this feature is that the UI must never present a guess + * as a fact. Every metadata field therefore carries WHERE its value came from, + * and consumers branch on that provenance instead of on the value alone. + */ + +/** + * Where a metadata value came from, in descending order of trust. + * + * - `api` the provider stated it (Xtream `get_vod_info`) — a fact + * - `parsed` inferred from the title/filename by regex — a guess + * - `probe` observed by contacting the stream — a fact + * + * v1 only ever produces `probe` for reachability and latency: reading real + * codecs would need ffprobe, which this app does not ship. + */ +export type VodSourceProvenance = 'api' | 'parsed' | 'probe'; + +/** + * A metadata value tagged with its origin. Absent (`undefined`) means "we do + * not know" — which the UI renders as no tag at all, never as a blank or a + * placeholder value. + */ +export interface VodSourceField { + value: T; + provenance: VodSourceProvenance; +} + +/** How confidently this source was matched to the movie being viewed. */ +export type VodSourceMatchConfidence = 'exact' | 'fuzzy'; + +/** How the whole source list was matched — drives the popover header text. */ +export type VodSourceMatchKind = 'tmdb' | 'title-year'; + +export type VodSourcePortalType = 'xtream' | 'stalker' | 'm3u'; + +/** Outcome of an on-demand availability probe. */ +export type VodSourceProbeStatus = + | 'idle' + | 'probing' + /** Reachable: HEAD (or the Range-GET fallback) returned a success status */ + | 'ok' + /** Contacted and refused: a definite non-success HTTP status */ + | 'fail' + /** + * Could not be checked — request blocked by policy, timed out, or the + * runtime has no probe capability. Deliberately distinct from `fail`: an + * unchecked source must never be displayed as offline. + */ + | 'unknown'; + +/** + * Playback headers a playlist requires, carried into the probe. + * + * A panel configured to demand its own User-Agent, Referer or Origin answers + * 401/403 without them, and calling a stream that plays fine "unavailable" is + * exactly the confident lie this feature refuses to tell. + */ +export interface StreamProbeHeaders { + userAgent?: string | null; + referer?: string | null; + origin?: string | null; +} + +export interface VodSourceProbeResult { + status: VodSourceProbeStatus; + /** HTTP status when one was actually received; 0 when none was. */ + httpStatus?: number; + /** Round-trip time in milliseconds, when the request completed. */ + latencyMs?: number; + /** ISO timestamp of the check, for the "checked yesterday" tag. */ + probedAt?: string; +} + +/** + * A raw discovery hit, exactly as the database returns it. + * + * Deliberately narrow: it is what the `content` table can prove, and nothing + * more. Notably it carries no container, codec or audio, because those columns + * do not exist — learning them costs a live request to the foreign portal. + */ +export interface VodSourceCandidateRow { + playlistId: string; + playlistName: string; + categoryId: number; + xtreamId: number; + title: string; + posterUrl: string | null; + matchConfidence: VodSourceMatchConfidence; + year: number | null; +} + +/** + * A candidate source as it comes out of discovery: enough to render a row and + * to resolve a stream URL later, but NOT yet playable. Resolving the URL costs + * a live request against the foreign playlist, so it is deferred until the + * user actually asks for this source. + */ +export interface VodSourceCandidate { + /** Stable within a session: `${playlistId}:${portalType}:${contentId}` */ + id: string; + playlistId: string; + playlistName: string; + portalType: VodSourcePortalType; + /** Provider-side id of the item (Xtream `stream_id`). */ + contentId: number; + /** The provider's own title, unnormalized — shown for fuzzy matches. */ + rawTitle: string; + matchConfidence: VodSourceMatchConfidence; + /** Release year when known, for disambiguating fuzzy matches. */ + year?: number | null; + posterUrl?: string | null; + + quality?: VodSourceField; + codec?: VodSourceField; + container?: VodSourceField; + /** + * The audio track as a LABEL, whatever kind the source could supply: a + * dub marker guessed from the title, or the codec the provider stated. + * Display only — the two are not comparable with each other. + */ + audio?: VodSourceField; + /** + * The spoken language, when the provider names one. + * + * Kept apart from `audio` because that field holds a codec whenever the + * fact came from the API, and a codec says nothing about which dub this + * is: AAC and AC3 can carry the same language, and two AC3 tracks can + * carry different ones. This is the only field allowed to answer "would + * the dub change". + */ + audioLanguage?: VodSourceField; + + /** ISO timestamp of the last failed playback attempt, if any. */ + lastFailedAt?: string; +} + +/** + * A candidate plus the live session state the UI needs to render it. + */ +export interface VodSourceDescriptor extends VodSourceCandidate { + isActive: boolean; + isPinned: boolean; + /** Already attempted this session — failover must not retry it. */ + isTried: boolean; + probe: VodSourceProbeResult; +} + +/** + * A user's per-movie choice of preferred source. + * + * Keyed by `matchKey` (see `vod-source-match-key.util.ts`) rather than by a + * provider id, because the whole point is that this movie exists under + * different ids in different portals. + */ +export interface VodSourcePin { + matchKey: string; + playlistId: string; + contentId: number; + portalType: VodSourcePortalType; + updatedAt?: string; +} diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts index 45a4226c0..e7e13040a 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts @@ -1,6 +1,8 @@ import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util'; describe('normalizeXtreamPendingRestoreState', () => { + // No `sourcePins`: absent has to stay absent, or an older archive would + // read as "there are no pins" and restore would clear the user's. const emptyState = { hiddenCategories: [], favorites: [], @@ -17,6 +19,60 @@ describe('normalizeXtreamPendingRestoreState', () => { } ); + describe('source pins', () => { + it('leaves the field absent when the input has none', () => { + expect( + normalizeXtreamPendingRestoreState({ favorites: [] }) + ).not.toHaveProperty('sourcePins'); + }); + + it('keeps a present-but-empty collection, which is an answer', () => { + expect( + normalizeXtreamPendingRestoreState({ sourcePins: [] }) + ).toHaveProperty('sourcePins', []); + }); + + it('keeps usable pins and drops the rest', () => { + const state = normalizeXtreamPendingRestoreState({ + sourcePins: [ + { matchKey: 'tmdb:603', contentId: 501 }, + // Nothing addressable: writing either would occupy the + // unique key of a film it does not describe. + { matchKey: '', contentId: 7 }, + { matchKey: 'title:dune:1984' }, + { contentId: 9 }, + 'not an object', + ], + }); + + expect(state.sourcePins).toEqual([ + { matchKey: 'tmdb:603', contentId: 501 }, + ]); + }); + + it('keeps a string updatedAt and drops anything else', () => { + const state = normalizeXtreamPendingRestoreState({ + sourcePins: [ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + { matchKey: 'tmdb:604', contentId: 502, updatedAt: 17 }, + ], + }); + + expect(state.sourcePins).toEqual([ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + { matchKey: 'tmdb:604', contentId: 502 }, + ]); + }); + }); + it('falls back to empty arrays for missing or non-array fields', () => { expect( normalizeXtreamPendingRestoreState({ diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts index 00a9a8093..6f17b039b 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts @@ -3,6 +3,7 @@ import { XtreamBackupFavoriteItem, XtreamBackupHiddenCategory, XtreamBackupRecentlyViewedItem, + XtreamBackupSourcePin, } from './playlist-backup.interface'; import { PlaybackPositionData } from './playback-position.interface'; @@ -11,6 +12,11 @@ export interface XtreamPendingRestoreState { favorites: XtreamBackupFavoriteItem[]; recentlyViewed: XtreamBackupRecentlyViewedItem[]; playbackPositions: PlaybackPositionData[]; + /** + * Optional: absent from archives and persisted entries written before + * multi-source existed. The normalizer always fills it. + */ + sourcePins?: XtreamBackupSourcePin[]; } export function getXtreamPendingRestoreStorageKey(playlistId: string): string { @@ -28,6 +34,7 @@ interface RestoreStateCandidate { favorites?: unknown; recentlyViewed?: unknown; playbackPositions?: unknown; + sourcePins?: unknown; } interface RestoreEntryCandidate { @@ -136,5 +143,43 @@ export function normalizeXtreamPendingRestoreState( playbackPositions: toArray(candidate.playbackPositions).filter( (item): item is PlaybackPositionData => isRecord(item) ), + // Absent stays ABSENT. Materializing `[]` here would make an older + // archive — which says nothing about pins — look like one that says + // "there are none", and restore would clear the user's pins. + ...(candidate.sourcePins === undefined + ? {} + : { sourcePins: normalizeSourcePins(candidate.sourcePins) }), }; } + +/** + * A pin without a usable match key or content id cannot address anything, and + * writing it would occupy the unique key of a film it does not describe. + */ +function normalizeSourcePins(value: unknown): XtreamBackupSourcePin[] { + const pins: XtreamBackupSourcePin[] = []; + + for (const item of toArray(value)) { + if (!isRecord(item)) { + continue; + } + + const matchKey = (item as { matchKey?: unknown }).matchKey; + const contentId = normalizeXtreamBackupId( + (item as { contentId?: unknown }).contentId + ); + const updatedAt = (item as { updatedAt?: unknown }).updatedAt; + + if (typeof matchKey !== 'string' || !matchKey || contentId === null) { + continue; + } + + pins.push({ + matchKey, + contentId, + ...(typeof updatedAt === 'string' ? { updatedAt } : {}), + }); + } + + return pins; +} diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 09fca1a52..1b92a2a2b 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -16,5 +16,9 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component'; export * from './lib/progress-capsule/progress-capsule.component'; export * from './lib/resizable/resizable.directive'; export * from './lib/season-container/season-container.component'; +export * from './lib/vod-sources/vod-source-row.component'; +export * from './lib/vod-sources/vod-source-tags'; +export * from './lib/vod-sources/vod-sources-chip.component'; +export * from './lib/vod-sources/vod-sources-menu.component'; export * from './lib/watched-badge/watched-badge.component'; export * from './lib/window-controls/window-controls.component'; diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 97f85afd4..a44db8110 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -446,7 +446,11 @@ } ::ng-deep .favorite-btn, - ::ng-deep .download-btn { + ::ng-deep .download-btn, + // The sources chip is a peer secondary action, so it wears the same + // surface, height and radius. Without this it reads as a link floating + // between three solid buttons. + ::ng-deep app-vod-sources-chip .sources-chip { @include detail-actions.secondary-action-button; } diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-groups.util.ts b/libs/ui/components/src/lib/vod-sources/vod-source-groups.util.ts new file mode 100644 index 000000000..6b748f22d --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-groups.util.ts @@ -0,0 +1,75 @@ +import { + playlistDisplayLabel, + type VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; + +/** + * Collapses sources that come from the same playlist. + * + * One playlist frequently carries the same film several times under different + * stream ids — different rips, dubs or quality ladders. Flat, those rows are + * indistinguishable: same label, same monogram, same tags. Grouping turns "five + * identical rows" into "one playlist, 5 copies" and moves the difference into + * the expanded variants, where it can actually be read. + */ + +export interface VodSourceGroup { + /** Stable across renders: the playlist is the group. */ + key: string; + label: string; + /** The row shown when collapsed. */ + primary: VodSourceDescriptor; + /** Every source in the group, primary included, in discovery order. */ + sources: VodSourceDescriptor[]; + /** Collapsed groups still need to advertise how much they hide. */ + count: number; + hasActive: boolean; +} + +export function groupVodSources( + sources: readonly VodSourceDescriptor[] +): VodSourceGroup[] { + const byPlaylist = new Map(); + + for (const source of sources) { + const existing = byPlaylist.get(source.playlistId); + if (existing) { + existing.push(source); + } else { + byPlaylist.set(source.playlistId, [source]); + } + } + + return [...byPlaylist.entries()].map(([key, groupSources]) => ({ + key, + label: playlistDisplayLabel( + groupSources[0].playlistName, + groupSources[0].playlistId + ), + // The playing copy must be the face of the group, or the user sees + // "5 copies" with no indication that one of them is on screen. + primary: groupSources.find((s) => s.isActive) ?? groupSources[0], + sources: groupSources, + count: groupSources.length, + hasActive: groupSources.some((s) => s.isActive), + })); +} + +/** + * What tells two copies from the same playlist apart. + * + * Their tags are usually identical (both parsed from near-identical titles), so + * the provider's own raw title is the only honest discriminator left. Falls + * back to the stream id when even the titles match. + */ +export function variantLabel( + source: VodSourceDescriptor, + siblings: readonly VodSourceDescriptor[] +): string { + const titles = new Set(siblings.map((s) => s.rawTitle)); + if (titles.size > 1 && source.rawTitle) { + return source.rawTitle; + } + + return `#${source.contentId}`; +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html new file mode 100644 index 000000000..95e1b0ccb --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.html @@ -0,0 +1,97 @@ +
+ + +
+
+ {{ + displayLabel() + }} + @if (source().isActive) { + {{ + (playbackLive() + ? 'PORTALS.MULTI_SOURCE.PLAYING' + : 'PORTALS.MULTI_SOURCE.CURRENT' + ) | translate + }} + } +
+ +
+ @for (tag of tags(); track tag.id) { + + @if (tag.spinner) { + + } @else if (tag.icon) { + {{ + tag.icon + }} + } + @if (tag.prefix) { + {{ tag.prefix }} + } + @if (tag.labelKey) { + {{ tag.labelKey | translate }} + } @else { + {{ tag.text }} + } + @if (tag.suffix) { + {{ tag.suffix }} + } + + } + @if (showCheckChip()) { + + } +
+
+ +
+ @if (showPin()) { + + } + +
+
diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.scss b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.scss new file mode 100644 index 000000000..223666e74 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.scss @@ -0,0 +1,277 @@ +$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); + +:host { + display: block; + + // Feature-specific status colours. Light theme is the default (no + // .dark-theme class on body); the dark overrides follow, mirroring the + // pattern in libs/ui/styles/_detail-view.scss. The handoff's dark values + // are far too light on a white surface, so light theme uses darker + // equivalents that clear ~4.5:1 against the tinted tag background. + --vod-source-ok-fg: #15803d; + --vod-source-ok-bg: rgba(21, 128, 61, 0.1); + --vod-source-warn-fg: #b45309; + --vod-source-warn-bg: rgba(180, 83, 9, 0.1); + --vod-source-bad-fg: #b91c1c; + --vod-source-bad-bg: rgba(185, 28, 28, 0.1); +} + +:host-context(.dark-theme) { + --vod-source-ok-fg: #4ade80; + --vod-source-ok-bg: rgba(74, 222, 128, 0.1); + --vod-source-warn-fg: #ffcf99; + --vod-source-warn-bg: rgba(255, 207, 153, 0.1); + --vod-source-bad-fg: #ff9aa6; + --vod-source-bad-bg: rgba(255, 91, 110, 0.1); +} + +.source-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: 10px; + box-sizing: border-box; + transition: + background 0.15s ease, + outline-color 0.15s ease; + + &:hover { + background: var(--app-card-hover-bg); + } + + &--active, + &--active:hover { + background: var(--app-selection-surface); + outline: 1px solid var(--app-selection-color); + outline-offset: -1px; + } +} + +.source-row__monogram { + flex: none; + width: 28px; + height: 28px; + // Concentric with the row: outer 10px radius minus 3px inset. + border-radius: 7px; + display: grid; + place-items: center; + background: var(--app-card-hover-bg); + color: var(--app-eyebrow-color); + font-weight: 700; + font-size: 13px; + letter-spacing: 0.02em; +} + +.source-row__info { + flex: 1; + min-width: 0; + // One line, not three: the name takes what it needs, the tags sit on the + // horizontal space that was previously empty. Roughly halves row height, + // which doubles how many sources fit without touching the panel size. + display: flex; + align-items: center; + gap: 10px; +} + +.source-row__name-line { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + // Shrinks before the tags do — the status tags are the deciding + // information, the label only has to stay recognizable. + flex: 1 1 auto; +} + +.source-row__name { + font-size: 13px; + font-weight: 600; + line-height: 1.25; + color: var(--app-on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-row__badge { + flex: none; + font-family: $font-mono; + font-weight: 600; + font-size: 9px; + line-height: 1.3; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--app-selection-color); + background: color-mix(in srgb, var(--app-selection-color) 12%, transparent); + border-radius: 4px; + padding: 2px 6px; +} + +.source-row__tags { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex: 0 1 auto; + min-width: 0; + // Never wrap to a second line; overflowing tags are clipped from the left + // so the trailing status tag (the one that matters) always stays visible. + flex-wrap: nowrap; + overflow: hidden; +} + +.source-tag { + display: inline-flex; + align-items: center; + gap: 4px; + box-sizing: border-box; + font-family: $font-mono; + font-weight: 500; + font-size: 10.5px; + line-height: 1.5; + border-radius: 4px; + padding: 2px 6px; + border: 1px solid transparent; + color: var(--app-eyebrow-color); + background: var(--app-card-hover-bg); + white-space: nowrap; + + &--ok { + color: var(--vod-source-ok-fg); + background: var(--vod-source-ok-bg); + } + + &--warn { + color: var(--vod-source-warn-fg); + background: var(--vod-source-warn-bg); + } + + &--bad { + color: var(--vod-source-bad-fg); + background: var(--vod-source-bad-bg); + } + + // The one tag the user can act on: no fill, dashed edge, so an unknown + // never reads as a value. + &--action { + border: 1px dashed var(--app-widget-border); + background: transparent; + color: var(--app-eyebrow-color); + cursor: pointer; + transition: + color 0.15s ease, + border-color 0.15s ease, + background 0.15s ease; + + &:hover { + color: var(--app-on-surface); + border-color: var(--app-selection-color); + background: color-mix( + in srgb, + var(--app-selection-color) 8%, + transparent + ); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: 2px; + } + } +} + +.source-tag__icon { + font-size: 12px; + width: 12px; + height: 12px; + line-height: 12px; +} + +.source-tag__spinner { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid color-mix(in srgb, currentColor 30%, transparent); + border-top-color: currentColor; + animation: vod-source-spin 0.7s linear infinite; +} + +@keyframes vod-source-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .source-tag__spinner { + animation-duration: 2.4s; + } +} + +.source-row__actions { + flex: none; + display: flex; + align-items: center; + gap: 6px; +} + +.source-row__action { + width: 30px; + height: 30px; + padding: 0; + box-sizing: border-box; + border-radius: 8px; + display: grid; + place-items: center; + border: 1px solid var(--app-widget-border); + background: transparent; + color: var(--app-eyebrow-color); + cursor: pointer; + transition: + color 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + } + + &:hover { + color: var(--app-on-surface); + background: var(--app-card-hover-bg); + } + + &.is-active { + color: var(--app-selection-color); + border-color: var(--app-selection-color); + background: color-mix( + in srgb, + var(--app-selection-color) 12%, + transparent + ); + } + + &--play { + border-color: transparent; + background: var(--app-selection-color); + color: var(--mat-sys-on-primary, #fff); + + &:hover { + background: color-mix( + in srgb, + var(--app-selection-color) 86%, + var(--app-on-surface) + ); + color: var(--mat-sys-on-primary, #fff); + } + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: 2px; + } +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.ts b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.ts new file mode 100644 index 000000000..cd15e3229 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.ts @@ -0,0 +1,85 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; +import { + buildVodSourceTags, + sourceMonogram, + sourceNeedsCheck, +} from './vod-source-tags'; +import { playlistDisplayLabel } from '@iptvnator/shared/interfaces'; + +/** + * One alternative source for the movie being viewed. + * + * Shared by the sources popover (details + player) and the playback error + * screen. It renders only what the descriptor can prove: unknown metadata + * produces no tag, just the actionable check chip. + */ +@Component({ + selector: 'app-vod-source-row', + templateUrl: './vod-source-row.component.html', + styleUrls: ['./vod-source-row.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIcon, MatTooltip, TranslatePipe], +}) +export class VodSourceRowComponent { + readonly source = input.required(); + /** The error screen lists alternatives without offering the pin action. */ + readonly showPin = input(true); + /** + * Whether a stream is on screen right now. The active row is the one a + * switch or Play would use, which is true from the moment the page opens — + * so without this the badge claims "Playing" before anything has started + * and again after the player is closed. + */ + readonly playbackLive = input(false); + + readonly playRequested = output(); + readonly pinRequested = output(); + readonly checkRequested = output(); + + /** + * Short, credential-safe label. Playlist names are routinely the pasted + * URL, which carries `username=`/`password=` — never render that raw. + */ + readonly displayLabel = computed(() => + playlistDisplayLabel( + this.source().playlistName, + this.source().playlistId + ) + ); + + // From the display label, not the raw name: every URL-named playlist would + // otherwise monogram to "HT" (from "http://") and be indistinguishable. + readonly monogram = computed(() => sourceMonogram(this.displayLabel())); + readonly tags = computed(() => buildVodSourceTags(this.source())); + readonly showCheckChip = computed(() => sourceNeedsCheck(this.source())); + readonly pinLabelKey = computed(() => + this.source().isPinned + ? 'PORTALS.MULTI_SOURCE.UNPIN_SOURCE' + : 'PORTALS.MULTI_SOURCE.PIN_SOURCE' + ); + + onPlay(event: Event): void { + event.stopPropagation(); + this.playRequested.emit(this.source().id); + } + + onPin(event: Event): void { + event.stopPropagation(); + this.pinRequested.emit(this.source().id); + } + + onCheck(event: Event): void { + event.stopPropagation(); + this.checkRequested.emit(this.source().id); + } +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-tags.ts b/libs/ui/components/src/lib/vod-sources/vod-source-tags.ts new file mode 100644 index 000000000..75b3c847b --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-source-tags.ts @@ -0,0 +1,200 @@ +import { + VodSourceDescriptor, + VodSourceField, + VodSourcePortalType, +} from '@iptvnator/shared/interfaces'; + +/** + * Presentation helpers for the VOD multi-source row. + * + * The whole feature rests on one rule: never present a guess as a fact. These + * helpers are the single place that turns a `VodSourceDescriptor` into tags, so + * the honesty rules cannot drift between the three surfaces that render rows + * (details popover, in-player popover, playback error screen). + */ + +export type VodSourceTagTone = 'neutral' | 'ok' | 'warn' | 'bad'; + +export interface VodSourceRowTag { + /** Stable identity for `@for` tracking; also the tag's semantic slot. */ + id: string; + tone: VodSourceTagTone; + /** A provider value, rendered verbatim — never translated. */ + text?: string; + /** Translation key, used for app copy such as probe outcomes. */ + labelKey?: string; + /** `~` marks a value that was guessed from the title rather than stated. */ + prefix?: string; + /** Trailing detail such as `· 0.4s`. */ + suffix?: string; + /** Material Symbol rendered before the label. */ + icon?: string; + /** Renders an inline spinner instead of an icon. */ + spinner?: boolean; +} + +const PORTAL_TYPE_LABELS: Record = { + xtream: 'Xtream', + stalker: 'Stalker', + m3u: 'M3U', +}; + +export const VOD_SOURCE_TAG_KEYS = { + probeOk: 'PORTALS.MULTI_SOURCE.PROBE_OK', + probeFail: 'PORTALS.MULTI_SOURCE.PROBE_FAIL', + probeChecking: 'PORTALS.MULTI_SOURCE.PROBE_CHECKING', + matchByTitle: 'PORTALS.MULTI_SOURCE.MATCH_BY_TITLE', +} as const; + +/** + * `400` becomes `0.4s`. Returns `undefined` for anything that is not a usable + * measurement, so the caller renders no latency rather than `NaNs`. + */ +export function formatProbeLatency(latencyMs?: number): string | undefined { + if ( + typeof latencyMs !== 'number' || + !Number.isFinite(latencyMs) || + latencyMs < 0 + ) { + return undefined; + } + + return `${(latencyMs / 1000).toFixed(1)}s`; +} + +/** + * `api` and `probe` values are facts and render as plain tags. A `parsed` value + * is a regex guess, so it is prefixed with `~` and carries the warn colour. An + * absent field renders nothing at all — the row offers the check chip instead. + */ +function fieldTag( + id: string, + field: VodSourceField | undefined +): VodSourceRowTag | null { + if (!field) { + return null; + } + + const isGuess = field.provenance === 'parsed'; + + return { + id, + tone: isGuess ? 'warn' : 'neutral', + text: field.value, + prefix: isGuess ? '~' : undefined, + }; +} + +/** + * `idle` and `unknown` both mean "not checked". Neither may ever be shown as + * offline, so both produce no probe tag and let the check chip take the slot. + */ +function probeTag(source: VodSourceDescriptor): VodSourceRowTag | null { + const { status, latencyMs } = source.probe; + + if (status === 'probing') { + return { + id: 'probe', + tone: 'neutral', + labelKey: VOD_SOURCE_TAG_KEYS.probeChecking, + spinner: true, + }; + } + + if (status === 'ok') { + const latency = formatProbeLatency(latencyMs); + return { + id: 'probe', + tone: 'ok', + icon: 'check', + labelKey: VOD_SOURCE_TAG_KEYS.probeOk, + suffix: latency ? `· ${latency}` : undefined, + }; + } + + if (status === 'fail') { + return { + id: 'probe', + tone: 'bad', + icon: 'close', + labelKey: VOD_SOURCE_TAG_KEYS.probeFail, + }; + } + + return null; +} + +/** + * A fuzzy match is a guess about *which movie this is*, so it is flagged with + * the same `~` language as guessed metadata, and shows the year when known + * because that is what lets the user disambiguate. + */ +function matchTag(source: VodSourceDescriptor): VodSourceRowTag | null { + if (source.matchConfidence !== 'fuzzy') { + return null; + } + + return { + id: 'match', + tone: 'warn', + prefix: '~', + labelKey: VOD_SOURCE_TAG_KEYS.matchByTitle, + suffix: source.year ? `· ${source.year}` : undefined, + }; +} + +/** + * Fixed order: portal type → quality → codec → container → audio → probe. + * The fuzzy-match flag sits just before the probe status so the trailing slot + * stays the availability one across every row. + */ +export function buildVodSourceTags( + source: VodSourceDescriptor +): VodSourceRowTag[] { + const tags: (VodSourceRowTag | null)[] = [ + { + id: 'portal', + tone: 'neutral', + text: PORTAL_TYPE_LABELS[source.portalType] ?? source.portalType, + }, + fieldTag('quality', source.quality), + fieldTag('codec', source.codec), + fieldTag('container', source.container), + fieldTag('audio', source.audio), + matchTag(source), + probeTag(source), + ]; + + return tags.filter((tag): tag is VodSourceRowTag => tag !== null); +} + +/** + * The check chip is offered whenever something is unknown — either the stream + * was never probed, or a metadata field is missing. It is deliberately a single + * chip rather than one per gap: the user asks once, and the check fills in what + * it can. While a probe is in flight the chip stands down for the spinner tag. + */ +export function sourceNeedsCheck(source: VodSourceDescriptor): boolean { + const status = source.probe.status; + + if (status === 'probing') { + return false; + } + + const isUnchecked = status === 'idle' || status === 'unknown'; + const hasMissingMetadata = + !source.quality || !source.codec || !source.container || !source.audio; + + return isUnchecked || hasMissingMetadata; +} + +/** Portals have no logos, so the row falls back to a two-letter monogram. */ +export function sourceMonogram(playlistName: string): string { + const trimmed = (playlistName ?? '').trim(); + + if (!trimmed) { + return '?'; + } + + return [...trimmed].slice(0, 2).join('').toUpperCase(); +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html new file mode 100644 index 000000000..eadddefe1 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html @@ -0,0 +1,51 @@ + + + + + + diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.scss b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.scss new file mode 100644 index 000000000..7b383ed9b --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.scss @@ -0,0 +1,78 @@ +$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); + + +:host { + display: inline-block; +} + +.sources-chip { + // Layout plus a full reset of the UA button appearance. The surface itself + // is supplied by whichever bar hosts the chip, because "looks like its + // neighbours" means something different in the hero than in the player. + // Without the reset, a host that sets no background gets Chromium's grey + // `buttonface` default instead of nothing. + appearance: none; + background: transparent; + border: 0; + color: inherit; + padding: 0; + display: inline-flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + cursor: pointer; + font: inherit; + + // Tactile press, matching the rest of the action row. + &:active { + transform: scale(0.985); + } +} + +.sources-chip__icon { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + color: var(--app-eyebrow-color); +} + +.sources-chip__count { + font-family: $font-mono; + font-weight: 700; + font-size: 11px; + line-height: 1.3; + border-radius: 999px; + padding: 2px 8px; + color: var(--app-selection-color); + background: color-mix(in srgb, var(--app-selection-color) 12%, transparent); +} + +.sources-chip__caret { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + color: var(--app-eyebrow-color); + transition: transform 0.14s ease-out; + + &.is-open { + transform: rotate(180deg); + } +} + + + +// The popover surface. Width lives here, on an element we fully own, instead +// of being fought out with Material's 280px menu cap. +.sources-chip__panel { + display: block; + width: 620px; + max-width: calc(100vw - 32px); + box-shadow: 0 16px 60px rgba(0, 0, 0, 0.35); + border-radius: 14px; + // Opaque here as well as on the inner surface: a CDK overlay floats over + // arbitrary page content, so a missing background is not a cosmetic bug — + // it makes the whole panel unreadable. + background: var(--app-widget-bg, #161a22); +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts new file mode 100644 index 000000000..9991d7df5 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts @@ -0,0 +1,129 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + CdkOverlayOrigin, + CdkConnectedOverlay, + type ConnectedPosition, +} from '@angular/cdk/overlay'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + VodSourceDescriptor, + VodSourceMatchKind, +} from '@iptvnator/shared/interfaces'; +import { VodSourcesMenuComponent } from './vod-sources-menu.component'; + +/** + * The "Sources N" chip on the VOD details action row, together with its + * anchored popover. Hosts render it only when at least one alternative source + * was found. + */ +@Component({ + selector: 'app-vod-sources-chip', + templateUrl: './vod-sources-chip.component.html', + styleUrls: ['./vod-sources-chip.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CdkConnectedOverlay, + CdkOverlayOrigin, + MatIcon, + TranslatePipe, + VodSourcesMenuComponent, + ], +}) +export class VodSourcesChipComponent { + readonly sources = input.required(); + readonly title = input(''); + readonly matchKind = input('title-year'); + readonly autoFailoverEnabled = input(false); + /** See `VodSourcesMenuComponent.autoFailoverSupported`. */ + readonly autoFailoverSupported = input(true); + /** See `VodSourceRowComponent.playbackLive`. */ + readonly playbackLive = input(false); + readonly resumeLabel = input(null); + readonly showPin = input(true); + /** Falls back to the number of sources when the host does not override it. */ + readonly count = input(null); + + readonly playRequested = output(); + readonly pinRequested = output(); + readonly checkRequested = output(); + readonly autoFailoverToggled = output(); + + readonly isOpen = signal(false); + readonly displayCount = computed( + () => this.count() ?? this.sources().length + ); + + /** + * Below the chip by default, flipping above when the action row sits near + * the bottom of the window. `viewportMargin` keeps it off the edges. + */ + readonly overlayPositions: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetY: -8, + }, + ]; + + toggle(): void { + this.isOpen.update((open) => !open); + } + + close(): void { + this.isOpen.set(false); + } + + onOverlayKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + this.close(); + } + } + + /** Playing from a row hands the screen back to the player. */ + onPlay(sourceId: string): void { + this.close(); + this.playRequested.emit(sourceId); + } + + /** Pinning is a decision, not a browse step — the popover steps aside. */ + onPin(sourceId: string): void { + this.close(); + this.pinRequested.emit(sourceId); + } + + /** Checking stays open: the spinner and its outcome belong on the row. */ + onCheck(sourceId: string): void { + this.checkRequested.emit(sourceId); + } +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html new file mode 100644 index 000000000..860647ccf --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html @@ -0,0 +1,141 @@ +
+
+

+ @if (title()) { + {{ + 'PORTALS.MULTI_SOURCE.SOURCES_FOR' + | translate: { title: title() } + }} + } @else { + {{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }} + } +

+ + @if (resumeLabel(); as resume) { + {{ resume }} + } @else { + {{ matchKindLabelKey() | translate }} + } + +
+ +
+ @if (showFilter()) { +
+ search + +
+ } + @if (uncheckedCount() > 0) { + + } +
+ +
+ @for (group of groups(); track group.key) { +
+ + + @if (group.count > 1) { + + + @if (isExpanded(group.key)) { +
+ @for ( + variant of group.sources; + track variant.id; + let i = $index + ) { + @if (variant.id !== group.primary.id) { +
+ {{ variantLabelFor(group, i) }} + +
+ } + } +
+ } + } +
+ } + @if (visibleSources().length === 0) { +

+ {{ 'PORTALS.MULTI_SOURCE.NO_MATCHES' | translate }} +

+ } +
+ + @if (autoFailoverSupported()) { + + } +
diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss new file mode 100644 index 000000000..1fd37be64 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss @@ -0,0 +1,220 @@ +$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); + +:host { + display: block; +} + +// Self-sufficient surface: the popover body is also dropped straight into the +// playback error screen, where there is no menu panel to supply a background. +.sources-menu { + box-sizing: border-box; + padding: 8px; + border-radius: 14px; + background: var(--app-widget-bg, #161a22); + outline: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.08)); + color: var(--app-on-surface); +} + +.sources-menu__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 12px 14px 10px; +} + +.sources-menu__title { + margin: 0; + min-width: 0; + // Reads as the panel's heading. Previously it sat at the same weight and + // size as the hint and the action beside it, so all three scanned as + // column headers over the list. + font-size: 15px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--app-on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sources-menu__hint { + flex: none; + font-size: 11px; + line-height: 1.35; + color: var(--app-eyebrow-color); +} + +.sources-menu__list { + display: flex; + flex-direction: column; + gap: 2px; + // Compact rows mean this now shows ~9 sources instead of ~3. Viewport-aware + // so a short window still gets a usable list rather than a clipped one. + max-height: min(56vh, 460px); + overflow-y: auto; +} + +.sources-menu__footer { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px 8px; + border-top: 1px solid var(--app-widget-border); + margin-top: 4px; + font-size: 11.5px; + color: var(--app-eyebrow-color); +} + +.sources-menu__footer-label { + flex: 1; + min-width: 0; + line-height: 1.35; +} + +.sources-menu__toggle { + flex: none; + margin-left: auto; +} + +.sources-menu__check-all { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid var(--app-widget-border); + border-radius: 999px; + background: none; + color: var(--app-eyebrow-color); + font: inherit; + font-size: 11px; + cursor: pointer; + + &:hover { + background: var(--app-card-hover-bg); + color: var(--app-on-surface); + } + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; + } +} + +.sources-menu__filter { + display: flex; + align-items: center; + gap: 8px; + margin: 0 12px 6px; + padding: 6px 10px; + border: 1px solid var(--app-widget-border); + // Concentric with the row radius one level in. + border-radius: 8px; + background: var(--app-card-hover-bg); + + &:focus-within { + border-color: var(--app-selection-color); + } +} + +.sources-menu__filter-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + color: var(--app-eyebrow-color); +} + +.sources-menu__filter-input { + flex: 1; + min-width: 0; + border: 0; + background: none; + outline: none; + color: var(--app-on-surface); + font: inherit; + font-size: 12.5px; + + &::placeholder { + color: var(--app-eyebrow-color); + } +} + +.sources-menu__empty { + margin: 0; + padding: 18px 12px; + text-align: center; + color: var(--app-eyebrow-color); + font-size: 12.5px; +} + +// Controls live on their own line, below the heading, so nothing in the header +// can be mistaken for a column label. +.sources-menu__toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px 10px; +} + +.sources-menu__toolbar .sources-menu__filter { + flex: 1; + min-width: 0; + margin: 0; +} + +.sources-menu__toolbar .sources-menu__check-all { + flex: none; + margin-left: auto; +} + +.sources-menu__variants-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + // Aligned under the row's info block, past the monogram, so it reads as + // belonging to the row above rather than as a list item of its own. + margin: 0 0 2px 48px; + padding: 2px 8px; + border: 0; + border-radius: 999px; + background: none; + color: var(--app-eyebrow-color); + font: inherit; + font-size: 11px; + cursor: pointer; + + &:hover { + background: var(--app-card-hover-bg); + color: var(--app-on-surface); + } + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; + } +} + +.sources-menu__variants { + // A left rail is the cheapest way to show these belong to the row above + // without nesting another card inside the panel. + margin: 0 0 4px 20px; + padding-left: 12px; + border-left: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.08)); +} + +.sources-menu__variant-label { + display: block; + padding: 2px 10px 0; + color: var(--app-eyebrow-color); + font-size: 10.5px; + font-family: $font-mono; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts new file mode 100644 index 000000000..d7a083253 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts @@ -0,0 +1,301 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { Observable, of } from 'rxjs'; +import { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; +import { VodSourcesMenuComponent } from './vod-sources-menu.component'; + +class FakeTranslateLoader implements TranslateLoader { + getTranslation(): Observable> { + return of({ + PORTALS: { + MULTI_SOURCE: { + SOURCE: 'Source', + SOURCES_FOR: 'Sources for “{{title}}”', + MATCHED_BY_TMDB: 'matched by TMDB', + MATCHED_BY_TITLE: 'matched by title', + MATCH_BY_TITLE: 'by title', + AUTO_FAILOVER: 'Auto-switch on error', + PLAYING: 'Playing', + CURRENT: 'Current', + PLAY_FROM_SOURCE: 'Play from this source', + PIN_SOURCE: 'Make this the main source', + UNPIN_SOURCE: 'Unpin this source', + CHECK: 'check', + CHECK_TOOLTIP: 'Check availability', + PROBE_OK: 'available', + PROBE_FAIL: 'unavailable', + PROBE_CHECKING: 'checking', + OPEN_SOURCES: 'Show alternative sources', + }, + }, + }); + } +} + +function createSource( + overrides: Partial = {} +): VodSourceDescriptor { + return { + id: 'playlist-1:xtream:42', + playlistId: 'playlist-1', + playlistName: 'SharkTV', + portalType: 'xtream', + contentId: 42, + rawTitle: 'Dune Part Two 2160p', + matchConfidence: 'exact', + year: 2024, + isActive: false, + isPinned: false, + isTried: false, + probe: { status: 'idle' }, + ...overrides, + }; +} + +describe('VodSourcesMenuComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + VodSourcesMenuComponent, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: FakeTranslateLoader, + }, + }), + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setDefaultLang('en'); + translate.use('en'); + + fixture = TestBed.createComponent(VodSourcesMenuComponent); + }); + + function render(sources: VodSourceDescriptor[]): void { + fixture.componentRef.setInput('sources', sources); + fixture.detectChanges(); + } + + /** Metadata tags only — the actionable check chip is not a value. */ + function tagTexts(): string[] { + return fixture.debugElement + .queryAll(By.css('.source-tag:not(.source-tag--action)')) + .map((tag) => (tag.nativeElement.textContent ?? '').trim()); + } + + function checkChip() { + return fixture.debugElement.query(By.css('.source-tag--action')); + } + + it('renders a stated value as a plain tag, without the guess marker', () => { + render([ + createSource({ + quality: { value: '1080p', provenance: 'api' }, + }), + ]); + + const qualityTag = tagTexts().find((text) => text.includes('1080p')); + expect(qualityTag).toBeDefined(); + expect(qualityTag).not.toContain('~'); + expect( + fixture.debugElement.queryAll(By.css('.source-tag--warn')).length + ).toBe(0); + }); + + it('marks a parsed value as a guess with ~ and the warn tone', () => { + render([ + createSource({ + quality: { value: '2160p', provenance: 'parsed' }, + }), + ]); + + const warnTags = fixture.debugElement.queryAll( + By.css('.source-tag--warn') + ); + expect(warnTags.length).toBe(1); + + const text = (warnTags[0].nativeElement.textContent ?? '').trim(); + expect(text).toContain('~'); + expect(text).toContain('2160p'); + }); + + it('renders no tag for an unknown field and offers the check chip instead', () => { + render([ + createSource({ + quality: undefined, + codec: undefined, + container: { value: 'mkv', provenance: 'api' }, + audio: undefined, + probe: { status: 'ok', latencyMs: 120 }, + }), + ]); + + const texts = tagTexts().join(' | '); + expect(texts).not.toContain('unknown'); + expect(texts).not.toContain('—'); + // Portal type, the one known field, and the probe outcome — nothing else. + expect(tagTexts().length).toBe(3); + expect(checkChip()).not.toBeNull(); + }); + + it('never renders an unchecked probe as offline and keeps offering the check', () => { + render([ + createSource({ + quality: { value: '1080p', provenance: 'api' }, + codec: { value: 'h264', provenance: 'api' }, + container: { value: 'mkv', provenance: 'api' }, + audio: { value: 'rus', provenance: 'api' }, + probe: { status: 'unknown' }, + }), + ]); + + expect( + fixture.debugElement.queryAll(By.css('.source-tag--bad')).length + ).toBe(0); + expect(tagTexts().join(' | ')).not.toContain('unavailable'); + expect(checkChip()).not.toBeNull(); + }); + + it('renders a successful probe with its latency in seconds', () => { + render([ + createSource({ + quality: { value: '1080p', provenance: 'api' }, + codec: { value: 'h264', provenance: 'api' }, + container: { value: 'mkv', provenance: 'api' }, + audio: { value: 'rus', provenance: 'api' }, + probe: { status: 'ok', latencyMs: 400 }, + }), + ]); + + const okTag = fixture.debugElement.query(By.css('.source-tag--ok')); + expect(okTag).not.toBeNull(); + + const text = (okTag.nativeElement.textContent ?? '').trim(); + expect(text).toContain('available'); + expect(text).toContain('0.4s'); + // Nothing left unknown, so the check chip stands down. + expect(checkChip()).toBeNull(); + }); + + it('orders tags portal type → quality → codec → container → audio → probe', () => { + render([ + createSource({ + quality: { value: '2160p', provenance: 'api' }, + codec: { value: 'hevc', provenance: 'api' }, + container: { value: 'mp4', provenance: 'api' }, + audio: { value: 'dub', provenance: 'api' }, + probe: { status: 'ok', latencyMs: 400 }, + }), + ]); + + const texts = tagTexts(); + expect(texts[0]).toContain('Xtream'); + expect(texts[1]).toContain('2160p'); + expect(texts[2]).toContain('hevc'); + expect(texts[3]).toContain('mp4'); + expect(texts[4]).toContain('dub'); + expect(texts[5]).toContain('available'); + expect(texts.length).toBe(6); + }); + + it('flags a fuzzy match with ~ and the year that disambiguates it', () => { + render([ + createSource({ + matchConfidence: 'fuzzy', + year: 2024, + quality: { value: '720p', provenance: 'api' }, + }), + ]); + + const warnTag = fixture.debugElement.query(By.css('.source-tag--warn')); + expect(warnTag).not.toBeNull(); + + const text = (warnTag.nativeElement.textContent ?? '').trim(); + expect(text).toContain('~'); + expect(text).toContain('by title'); + expect(text).toContain('2024'); + }); + + it('emits the toggled state when the footer switch is flipped', () => { + render([createSource()]); + + const emitted: boolean[] = []; + fixture.componentInstance.autoFailoverToggled.subscribe( + (value: boolean) => emitted.push(value) + ); + + const toggle = fixture.debugElement.query( + By.css('.sources-menu__toggle button[role="switch"]') + ); + expect(toggle).not.toBeNull(); + + toggle.nativeElement.click(); + fixture.detectChanges(); + + expect(emitted).toEqual([true]); + }); + + it('drops the footer switch on a player that cannot report failures', () => { + // MPV, VLC and Embedded MPV never raise the playback diagnostic that + // drives failover, so the switch there promises something that can + // never happen. + render([createSource()]); + fixture.componentRef.setInput('autoFailoverSupported', false); + fixture.detectChanges(); + + expect( + fixture.debugElement.query(By.css('.sources-menu__toggle')) + ).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain( + 'Auto-switch on error' + ); + }); + + it('badges the active row as Playing only while a player is running', () => { + // Discovery marks a source active the moment the page opens, and the + // row keeps that state after the player is closed — so an + // unconditional "Playing" badge is a claim about nothing. + render([createSource({ isActive: true })]); + + const badge = () => + ( + fixture.debugElement.query(By.css('.source-row__badge')) + ?.nativeElement.textContent ?? '' + ).trim(); + + expect(badge()).toBe('Current'); + + fixture.componentRef.setInput('playbackLive', true); + fixture.detectChanges(); + + expect(badge()).toBe('Playing'); + }); + + it('shows the match kind in the header, or the resume label when given', () => { + fixture.componentRef.setInput('sources', [createSource()]); + fixture.componentRef.setInput('matchKind', 'tmdb'); + fixture.detectChanges(); + + const hint = fixture.debugElement.query(By.css('.sources-menu__hint')); + expect(hint.nativeElement.textContent.trim()).toBe('matched by TMDB'); + + fixture.componentRef.setInput( + 'resumeLabel', + 'timecode will be kept · 0:42:18' + ); + fixture.detectChanges(); + + expect(hint.nativeElement.textContent.trim()).toBe( + 'timecode will be kept · 0:42:18' + ); + }); +}); diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts new file mode 100644 index 000000000..81d373134 --- /dev/null +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts @@ -0,0 +1,163 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { + MatSlideToggle, + MatSlideToggleChange, +} from '@angular/material/slide-toggle'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + playlistDisplayLabel, + VodSourceDescriptor, + VodSourceMatchKind, +} from '@iptvnator/shared/interfaces'; +import { VodSourceRowComponent } from './vod-source-row.component'; +import { + groupVodSources, + variantLabel, + type VodSourceGroup, +} from './vod-source-groups.util'; + +/** Below this many sources, a filter box costs more than it saves. */ +const FILTER_FROM = 8; + +/** + * Body of the "sources" popover: header, the source rows, and the auto-failover + * footer. Rendered inside the chip's anchored menu on the details page and + * reused as-is by the in-player popover and the playback error screen, so it + * owns no anchoring of its own. + */ +@Component({ + selector: 'app-vod-sources-menu', + templateUrl: './vod-sources-menu.component.html', + styleUrls: ['./vod-sources-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + MatIcon, + MatSlideToggle, + TranslatePipe, + VodSourceRowComponent, + ], +}) +export class VodSourcesMenuComponent { + readonly sources = input.required(); + /** + * Movie title for the header. Empty renders the bare "Source" heading used + * by the in-player variant, where the title is already on screen. + */ + readonly title = input(''); + readonly matchKind = input('title-year'); + readonly autoFailoverEnabled = input(false); + /** + * False on players that never report a playback failure (MPV, VLC, + * Embedded MPV). The toggle would then promise a switch that can never + * happen, so it is not offered at all. + */ + readonly autoFailoverSupported = input(true); + /** + * In-player variant: replaces the match kind with e.g. + * "timecode will be kept · 0:42:18". Already formatted by the host. + */ + readonly resumeLabel = input(null); + readonly showPin = input(true); + /** See `VodSourceRowComponent.playbackLive`. */ + readonly playbackLive = input(false); + + readonly playRequested = output(); + readonly pinRequested = output(); + readonly checkRequested = output(); + readonly autoFailoverToggled = output(); + + /** Filter text; only reachable once the list is long enough to need it. */ + readonly filter = signal(''); + + /** + * Progressive disclosure: a search box for four sources is chrome nobody + * asked for, but scanning eleven by eye is worse. + */ + readonly showFilter = computed(() => this.sources().length > FILTER_FROM); + + readonly visibleSources = computed(() => { + const query = this.filter().trim().toLowerCase(); + const sources = this.sources(); + if (!query || !this.showFilter()) { + return sources; + } + + return sources.filter((source) => + playlistDisplayLabel(source.playlistName, source.playlistId) + .toLowerCase() + .includes(query) + ); + }); + + /** + * Sources collapsed per playlist. One playlist often lists the same film + * several times; flat they are indistinguishable rows. + */ + readonly groups = computed(() => groupVodSources(this.visibleSources())); + + private readonly expanded = signal>(new Set()); + + isExpanded(key: string): boolean { + return this.expanded().has(key); + } + + toggleGroup(key: string): void { + this.expanded.update((keys) => { + const next = new Set(keys); + if (!next.delete(key)) { + next.add(key); + } + return next; + }); + } + + /** Distinguishes copies within a group — usually the provider's own title. */ + variantLabelFor(group: VodSourceGroup, index: number): string { + return variantLabel(group.sources[index], group.sources); + } + + /** Anything still worth probing — drives the "check all" affordance. */ + readonly uncheckedCount = computed( + () => + this.visibleSources().filter( + (source) => + source.probe.status === 'idle' || + source.probe.status === 'unknown' + ).length + ); + + readonly matchKindLabelKey = computed(() => + this.matchKind() === 'tmdb' + ? 'PORTALS.MULTI_SOURCE.MATCHED_BY_TMDB' + : 'PORTALS.MULTI_SOURCE.MATCHED_BY_TITLE' + ); + + onAutoFailoverChange(event: MatSlideToggleChange): void { + this.autoFailoverToggled.emit(event.checked); + } + + /** + * Checking eleven sources one row at a time is busywork the app can do + * itself; the probe service already dedupes and caches. + */ + checkAllVisible(): void { + for (const source of this.visibleSources()) { + if ( + source.probe.status === 'idle' || + source.probe.status === 'unknown' + ) { + this.checkRequested.emit(source.id); + } + } + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts new file mode 100644 index 000000000..01f1e87c7 --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -0,0 +1,153 @@ +import { Component, input, output, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { SettingsStore } from '@iptvnator/services'; +import { VodSourcesChipComponent } from '@iptvnator/ui/components'; +import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; +import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component'; + +jest.unstable_mockModule('video.js', () => ({ + default: jest.fn(), +})); + +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +@Component({ + selector: 'app-web-player-view', + standalone: true, + template: '
', +}) +class StubWebPlayerViewComponent { + readonly streamUrl = input.required(); + readonly title = input(''); + readonly mediaTitle = input(null); + readonly playback = input(null); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly alternativeSources = input([]); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly externalFallbackRequested = output(); + readonly alternativeSourceRequested = output(); + readonly playbackFailed = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +function descriptor(id: string, isActive: boolean): VodSourceDescriptor { + return { + id, + playlistId: id, + playlistName: `Portal ${id}`, + portalType: 'xtream', + contentId: 1, + rawTitle: 'The Matrix', + matchConfidence: 'exact', + year: 1999, + isActive, + isPinned: false, + isTried: isActive, + probe: { status: 'idle' }, + }; +} + +/** + * The picker inside the player is the SAME picker as the detail page's, so the + * settings it shows and changes have to be the same ones. + */ +describe('PortalInlinePlayerComponent sources picker', () => { + let PortalInlinePlayerComponent: typeof import('./portal-inline-player.component').PortalInlinePlayerComponent; + let WebPlayerViewComponent: typeof import('../web-player-view/web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture; + let component: PortalInlinePlayerComponentInstance; + + beforeAll(async () => { + ({ PortalInlinePlayerComponent } = await import( + './portal-inline-player.component' + )); + ({ WebPlayerViewComponent } = await import( + '../web-player-view/web-player-view.component' + )); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [PortalInlinePlayerComponent, TranslateModule.forRoot()], + providers: [ + { + provide: SettingsStore, + useValue: { + player: signal('html5'), + playerAmbientMode: signal(false), + playerUpNextRail: signal(false), + stripCountryPrefix: signal(false), + }, + }, + ], + }) + .overrideComponent(PortalInlinePlayerComponent, { + remove: { imports: [WebPlayerViewComponent] }, + add: { imports: [StubWebPlayerViewComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PortalInlinePlayerComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mkv', + title: 'The Matrix', + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 1, + contentType: 'vod', + }, + }); + fixture.componentRef.setInput('allSources', [ + descriptor('playlist-1', true), + descriptor('playlist-2', false), + ]); + fixture.componentRef.setInput('alternativeSources', [ + descriptor('playlist-2', false), + ]); + }); + + function chip(): VodSourcesChipComponent { + return fixture.debugElement.query(By.directive(VodSourcesChipComponent)) + .componentInstance; + } + + it('shows auto-failover as it actually is, not as off by default', () => { + fixture.componentRef.setInput('sourcesAutoFailoverEnabled', true); + fixture.detectChanges(); + + expect(chip().autoFailoverEnabled()).toBe(true); + }); + + it('relays a toggle to the host that owns the setting', () => { + const toggled = jest.fn(); + component.sourcesAutoFailoverToggled.subscribe(toggled); + fixture.detectChanges(); + + chip().autoFailoverToggled.emit(false); + + expect(toggled).toHaveBeenCalledWith(false); + }); + + it('states the match kind the host reports', () => { + fixture.componentRef.setInput('sourcesMatchKind', 'title'); + fixture.detectChanges(); + + // The picker prints this as a claim about how the sources were found, + // so a default it was never told is a guess presented as a fact. + expect(chip().matchKind()).toBe('title'); + }); +}); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index b002bc430..dc6bcd277 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -24,8 +24,11 @@ class StubWebPlayerViewComponent { readonly playback = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); + readonly alternativeSources = input([]); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly externalFallbackRequested = output(); + readonly alternativeSourceRequested = output(); + readonly playbackFailed = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); 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 2cdd6ff4a..43d54185d 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 @@ -27,6 +27,28 @@
+ @if (allSources().length > 1) { + + }
} + @if (alternativeSources().length > 0) { +
+

+ {{ + 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' + | translate + }} +

+ @for (source of visibleAlternatives(); track source.id) { + + } + @if (hiddenAlternativeCount() > 0) { +

+ {{ + 'PORTALS.MULTI_SOURCE.MORE_SOURCES' + | translate + : { count: hiddenAlternativeCount() } + }} +

+ } +
+ } + @if (canShowExternalFallbackActions()) {
{ 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 e1c8a0c34..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 @@ -23,9 +23,14 @@ import { Settings, STORE_KEY, VideoPlayer, + type VodSourceDescriptor, } from '@iptvnator/shared/interfaces'; import type { ExternalPlayerName } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { VodSourceRowComponent } from '@iptvnator/ui/components'; + +/** How many recovery options the error screen shows before it stops helping. */ +const ERROR_SCREEN_ALTERNATIVES = 5; import { ArtPlayerComponent } from '../art-player/art-player.component'; import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; @@ -36,6 +41,7 @@ import { } from '../player-controls'; import { type PlaybackDiagnostic, + type PlaybackDiagnosticCode, type PlaybackFallbackRequest, getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; @@ -73,6 +79,7 @@ function resolveWebPlayerSharedControls(): boolean { MatTooltipModule, TranslatePipe, VjsPlayerComponent, + VodSourceRowComponent, ], providers: [ { @@ -102,6 +109,33 @@ export class WebPlayerViewComponent { duration: number; }>(); readonly externalFallbackRequested = output(); + /** + * Alternative sources offered on the error screen, turning a dead end into + * a recovery point. Empty (the default) keeps the overlay exactly as it + * was for every non-multi-source host. + */ + 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 + * the host does nothing, the user still sees the honest error. + */ + readonly playbackFailed = output(); + + /** + * An error screen is a recovery point, not a catalogue. Offering fifty + * options here is worse than offering the best few — the full list stays + * one click away behind the player's own sources button. + */ + readonly visibleAlternatives = computed(() => + this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES) + ); + readonly hiddenAlternativeCount = computed(() => + Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES) + ); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -264,6 +298,9 @@ export class WebPlayerViewComponent { } this.playbackDiagnostic.set(issue); + if (issue) { + this.playbackFailed.emit(issue.code); + } } requestExternalFallback(player: ExternalPlayerName): void {