diff --git a/CLAUDE.md b/CLAUDE.md index df835cb08..fff7c037b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -824,8 +824,8 @@ engine` (restart required) or - Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing. - **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height. - Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** (its own duplicate rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials. -- 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. -- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. A pin is not decoration: the primary Play action starts from the pinned source, and it outranks everything else in failover ranking. 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. +- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password. +- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` lets `matchedExternalPlayback` still recognise it as this page's session. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them. - Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist, offers Undo, and warns "dub may differ" only when both sides state an audio track as fact. - HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup). No ffprobe — the binary is not bundled. - See `docs/architecture/vod-multi-source.md` diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index b6a60d367..8c002b9ae 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -125,6 +125,11 @@ landed is stored under its title key and prefers a `tmdb:` key afterwards; reading both means the id arriving later does not orphan the pin, and unpinning clears every alias so a stale row cannot resurrect it. +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 @@ -214,6 +219,13 @@ Three details make the position survive: 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. `handleInlineTimeUpdate` @@ -259,6 +271,22 @@ 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`). + +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 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 index cc6ba854c..1a11673d5 100644 --- 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 @@ -197,6 +197,26 @@ describe('VodMultiSourceController', () => { 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', () => { 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 index 1633fb91e..64e9e8c6d 100644 --- 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 @@ -118,6 +118,22 @@ export class VodMultiSourceController { } } + /** + * 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; } 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 index f03a1e3b5..cc5523a0c 100644 --- 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 @@ -4,6 +4,7 @@ import type { VodSourceCandidateRow, VodSourceMatchKind, } from '@iptvnator/shared/interfaces'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { parseTitleMetadata } from './vod-source-metadata.util'; /** @@ -34,6 +35,8 @@ export interface VodSourceDiscoveryResult { @Injectable({ providedIn: 'root' }) export class VodSourceDiscoveryService { + private readonly logger = createLogger('VodSourceDiscovery'); + get isAvailable(): boolean { return ( typeof window !== 'undefined' && @@ -65,7 +68,7 @@ export class VodSourceDiscoveryService { matchKind: 'title-year', }; } catch (error) { - console.warn('VOD source discovery failed:', error); + this.logger.warn('VOD source discovery failed:', error); return empty; } } 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 index 1d4122f30..4385e09f7 100644 --- 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 @@ -10,6 +10,7 @@ import type { ResolvedPortalPlayback, VodSourceCandidate, } from '@iptvnator/shared/interfaces'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { applyApiMetadata } from './vod-source-metadata.util'; /** @@ -33,6 +34,10 @@ export interface ResolvedVodSource { @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. * @@ -153,7 +158,7 @@ export class VodSourceResolverService { } return playlist; } catch (error) { - console.warn('Loading the alternative playlist failed:', error); + this.logger.warn('Loading the alternative playlist failed:', error); return null; } } @@ -196,7 +201,7 @@ export class VodSourceResolverService { }, }; } catch (error) { - console.warn('Reading alternative VOD details failed:', 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 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..87d1d5759 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -0,0 +1,132 @@ +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 { 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; + const activeSession = signal(null); + 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); + + TestBed.configureTestingModule({ + providers: [ + VodDetailsPlaybackService, + { + provide: XtreamStore, + useValue: { + currentPlaylist: signal({ id: ROUTE_PLAYLIST }), + addRecentItem: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession, closeSession: jest.fn() }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn(), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback: jest.fn(), + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: jest + .fn() + .mockReturnValue(() => 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(); + }); +}); 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 b80a00484..27c832759 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 @@ -30,6 +30,13 @@ export interface VodDetailsPlaybackBindings { vodId: Signal; /** Usable metadata of the selected VOD, if any */ vodInfo: Signal; + /** + * The source actually playing when it is NOT the route's own — supplied by + * multi-source. An external player launched for an alternative carries + * that playlist's ids, so without this the session belongs to no page and + * its Stop button never appears. + */ + activeSource?: Signal; } /** @@ -74,15 +81,23 @@ export class VodDetailsPlaybackService { } const contentInfo = session.contentInfo; - if ( - contentInfo.playlistId !== playlistId || - contentInfo.contentType !== 'vod' || - contentInfo.contentXtreamId !== vodId - ) { + if (contentInfo.contentType !== 'vod') { return null; } - return session; + // This page owns the session when it launched the route's own stream — + // or the alternative it switched to, whose ids belong to the other + // playlist entirely. + const active = this.bindings()?.activeSource?.(); + const isRouteStream = + contentInfo.playlistId === playlistId && + contentInfo.contentXtreamId === vodId; + const isActiveSource = + !!active && + contentInfo.playlistId === active.playlistId && + contentInfo.contentXtreamId === active.contentXtreamId; + + return isRouteStream || isActiveSource ? session : null; }); readonly externalPrimaryLabel = computed(() => { const session = this.matchedExternalPlayback(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index 4f2885e8a..fcb0f8905 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -48,6 +48,8 @@ describe('VodDetailsRouteComponent', () => { const addRecentItem = jest.fn(); const downloads = signal([]); const getPlaybackPosition = jest.fn().mockResolvedValue(null); + const activeSession = signal(null); + const closeSession = jest.fn(); beforeEach(async () => { const consoleDebug = console.debug.bind(console); @@ -91,6 +93,8 @@ describe('VodDetailsRouteComponent', () => { constructVodStreamUrl.mockClear(); addRecentItem.mockClear(); getPlaybackPosition.mockClear(); + activeSession.set(null); + closeSession.mockClear(); await TestBed.configureTestingModule({ imports: [VodDetailsRouteComponent], @@ -161,10 +165,7 @@ describe('VodDetailsRouteComponent', () => { }, { provide: PORTAL_EXTERNAL_PLAYBACK, - useValue: { - activeSession: signal(null), - closeSession: jest.fn(), - }, + useValue: { activeSession, closeSession }, }, { provide: PORTAL_PLAYBACK_POSITIONS, @@ -298,6 +299,33 @@ describe('VodDetailsRouteComponent', () => { expect(host.querySelector('button.play-btn')).not.toBeNull(); }); + 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('holds the resume point until the engine has seeked to it', () => { const component = fixture.componentInstance; const playback = fixture.debugElement.injector.get( diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 17771f813..21332fd0a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -305,10 +305,39 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { }); }); + /** + * The alternative the player is on, in playback's terms — null while the + * route's own source is playing, which the matcher already recognises. + */ + private readonly activeAlternativeSource = computed(() => { + const active = this.multiSource.sources().find((s) => s.isActive); + const routePlaylistId = this.xtreamStore.currentPlaylist()?.id; + if (!active || active.playlistId === routePlaylistId) { + return null; + } + + return { + playlistId: active.playlistId, + contentXtreamId: active.contentId, + contentType: 'vod' as const, + }; + }); + constructor() { this.playback.bind({ vodId: this.selectedVodId, vodInfo: this.selectedVodInfo, + activeSource: this.activeAlternativeSource, + }); + + // Nothing reports a live position until the player emits its first + // timeupdate, so a switch made straight off the Resume button would + // otherwise resolve at zero and restart the film. + effect(() => { + const position = this.playback.vodPlaybackPosition(); + if (position) { + this.multiSource.seedResumePosition(position.positionSeconds); + } }); this.multiSource.bind({ // Route every switch through the same inline-vs-external fork a @@ -409,6 +438,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } 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. 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 index 6a1e5defc..a47ae29a5 100644 --- 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 @@ -265,6 +265,55 @@ describe('VodMultiSourceHostService', () => { expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); }); + it('resumes the pinned source from the stored position', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + + // What the route knows after loading playback positions. Nothing has + // played yet, so no timeupdate has reported anything — and the button + // the user is about to press says "Resume". + service.seedResumePosition(2538); + + await expect(service.playPinnedSource()).resolves.toBe(true); + + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 2538 } + ); + }); + + it('does not show a pin the database refused to store', async () => { + await loadMovie([ALT_TWO]); + pins.set.mockResolvedValue(false); + + await service.togglePin(ALT_TWO.id); + + // The icon promises the preference survives reopening the movie. A + // write that failed makes that a lie, so the row must not change. + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + it('keeps the pin when clearing it fails', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + pins.clear.mockResolvedValue(false); + await service.togglePin(ALT_TWO.id); + + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + }); + it('leaves Play alone when nothing is pinned', async () => { await loadMovie([ALT_TWO]); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index b7d966f69..e704975e9 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -121,10 +121,9 @@ export class VodMultiSourceHostService { /** Alternative STREAMS — what the "Sources N" chip counts. */ readonly alternativeCount = computed(() => this.alternatives().length); /** - * Alternative PLAYLISTS. One playlist listing the film three times is one - * other place to watch it, and the popover already groups those three - * under that single playlist — so "also found in N other playlists" has to - * count portals, not copies, or it contradicts the list it opens. + * Alternative PLAYLISTS. The popover groups one playlist's three copies + * under that playlist, so "also found in N other playlists" must count + * portals, not copies, or it contradicts the list it opens. */ readonly alternativePlaylistCount = computed( () => new Set(this.alternatives().map((s) => s.playlistId)).size @@ -330,10 +329,14 @@ export class VodMultiSourceHostService { return this.controller.isExhausted(); } - /** Feeds the live player position; called ahead of the persist throttle. */ + /** 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 async switchTo( candidate: VodSourceCandidate diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index 8d404dc75..15e885929 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -41,31 +41,32 @@ export async function writePin( return false; } - await pins.set({ + // The write can fail — no bridge, or the DB refused it. Reporting success + // then would show a pin the next visit does not have. + return pins.set({ matchKey, playlistId: candidate.playlistId, contentId: candidate.contentId, portalType: candidate.portalType, }); - return true; } /** Clears every alias, so unpinning is not undone by a stale row. */ export async function erasePin( pins: Pick, matchKeys: readonly string[] -): Promise { - if (matchKeys.length > 0) { - await pins.clear([...matchKeys]); - } +): 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 failed — and the caller must leave the - * current pin exactly as it was rather than showing one that was not saved. + * 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, @@ -78,8 +79,7 @@ export async function togglePinnedSource( } if (isPinned) { - await erasePin(pins, matchKeys); - return null; + return (await erasePin(pins, matchKeys)) ? null : undefined; } if (!candidate) { diff --git a/libs/services/src/lib/stream-probe.service.ts b/libs/services/src/lib/stream-probe.service.ts index e12d6929d..df5cf617f 100644 --- a/libs/services/src/lib/stream-probe.service.ts +++ b/libs/services/src/lib/stream-probe.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; /** * On-demand stream reachability checks via the Electron main process. @@ -97,7 +98,9 @@ export class StreamProbeService { probedAt: new Date().toISOString(), }; } catch (error) { - console.warn('Stream probe failed:', 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' }; } diff --git a/libs/services/src/lib/vod-source-pin.service.ts b/libs/services/src/lib/vod-source-pin.service.ts index d932ae55b..1f350d311 100644 --- a/libs/services/src/lib/vod-source-pin.service.ts +++ b/libs/services/src/lib/vod-source-pin.service.ts @@ -1,5 +1,6 @@ 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"). @@ -32,7 +33,10 @@ export class VodSourcePinService { try { return await window.electron.dbGetVodSourcePin(matchKeys); } catch (error) { - console.warn('Reading the pinned VOD source failed:', error); + console.warn( + 'Reading the pinned VOD source failed:', + redactSensitiveData(error) + ); return null; } } @@ -46,7 +50,10 @@ export class VodSourcePinService { const result = await window.electron.dbSetVodSourcePin(pin); return result?.success === true; } catch (error) { - console.warn('Pinning the VOD source failed:', error); + console.warn( + 'Pinning the VOD source failed:', + redactSensitiveData(error) + ); return false; } } @@ -62,7 +69,10 @@ export class VodSourcePinService { const result = await window.electron.dbClearVodSourcePin(matchKeys); return result?.success === true; } catch (error) { - console.warn('Unpinning the VOD source failed:', error); + console.warn( + 'Unpinning the VOD source failed:', + redactSensitiveData(error) + ); return false; } }