fix(portals): keep external playback, the pin and the resume point honest

Five findings from the round-5 review.

An external player launched for an alternative carries that playlist's ids,
so the page disowned its own session: the primary button never became Stop,
stopping found nothing to stop, and another click opened a second player.
Multi-source now tells playback which source is actually active, and the
matcher accepts either that or the route's own stream.

Stop also has to beat the pin. The primary action consults the pin first —
that is what makes a pin decide where playback starts — but while a session
is running the same button reads Stop, and consulting the pin there made the
control do the opposite of its label.

A pinned source started from the Resume button resolved at zero, because
nothing reports a live position until the first timeupdate. The controller is
now seeded from the persisted position, one-way: a live value always wins,
since the stored one lags it and applying it would rewind.

A pin whose write failed was still shown as pinned, promising a preference
that reopening the movie would not have.

Portal failures in this path logged raw errors. An Xtream error message
carries the stream URL, and that URL is built out of the username and
password, so they now go through the redacting logger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 23:07:26 +02:00
1 parent d3e337261e
commit df30262f24
16 files changed
+453 -34

No files matched your search

+2 -2
View File
@@ -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`
+28
View File
@@ -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
@@ -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', () => {
@@ -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;
}
@@ -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');
}
});
@@ -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;
}
}
@@ -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
@@ -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<unknown>(null);
const activeSource = signal<PlayerContentInfo | null>(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();
});
});
@@ -30,6 +30,13 @@ export interface VodDetailsPlaybackBindings {
vodId: Signal<number>;
/** Usable metadata of the selected VOD, if any */
vodInfo: Signal<XtreamVodInfo | null>;
/**
* 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<PlayerContentInfo | null>;
}
/**
@@ -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();
@@ -48,6 +48,8 @@ describe('VodDetailsRouteComponent', () => {
const addRecentItem = jest.fn();
const downloads = signal([]);
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
const activeSession = signal<unknown>(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(
@@ -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<void> {
// 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.
@@ -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]);
@@ -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
@@ -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<VodSourcePinService, 'clear'>,
matchKeys: readonly string[]
): Promise<void> {
if (matchKeys.length > 0) {
await pins.clear([...matchKeys]);
}
): Promise<boolean> {
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<VodSourcePinService, 'set' | 'clear'>,
@@ -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) {
@@ -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' };
}
@@ -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;
}
}