feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 08:34:56 +02:00
1 parent e2300bea11
commit 004cb65fe6
101 files changed
+7183 -192

No files matched your search

@@ -1 +1,2 @@
export * from './lib/collection';
export * from './lib/multi-source';
@@ -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';
@@ -0,0 +1,236 @@
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceController } from './vod-multi-source.controller';
function source(
id: string,
overrides: Partial<VodSourceCandidate> = {}
): 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.setActiveSource('a');
const first = controller.pickFailoverTarget();
expect(first).not.toBeNull();
controller.setActiveSource(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.setActiveSource('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.setActiveSource(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.setActiveSource('a');
controller.setActiveSource('b');
controller.setActiveSource('a');
// Returning by hand is allowed; automatic retrying is not.
expect(controller.pickFailoverTarget()).toBeNull();
});
});
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);
});
});
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' });
});
});
});
@@ -0,0 +1,183 @@
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<VodSourceCandidate[]>([]);
private readonly _activeSourceId = signal<string | null>(null);
private readonly _pinnedSourceId = signal<string | null>(null);
private readonly _matchKind = signal<VodSourceMatchKind>('title-year');
private readonly _probes = signal<Record<string, VodSourceProbeResult>>({});
/**
* 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<string>();
/** 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<VodSourceDescriptor[]>(() => {
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
)
);
}
setActiveSource(sourceId: string | null) {
this._activeSourceId.set(sourceId);
if (sourceId) {
this.tried.add(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;
}
}
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. probed reachable
* 3. not known to have failed recently
* 4. richer FACTUAL metadata
* 5. 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;
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;
}
@@ -0,0 +1,93 @@
import { Injectable } from '@angular/core';
import type {
VodSourceCandidate,
VodSourceCandidateRow,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
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;
}
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 {
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.dbFindTitleSources === 'function'
);
}
async discover(
request: VodSourceDiscoveryRequest
): Promise<VodSourceDiscoveryResult> {
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,
});
return {
sources: (rows ?? []).map(toCandidate),
matchKind: 'title-year',
};
} catch (error) {
console.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,
};
}
@@ -0,0 +1,224 @@
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import {
applyApiMetadata,
audioDiffersFactually,
factualOnly,
parseTitleMetadata,
} from './vod-source-metadata.util';
function candidate(
overrides: Partial<VodSourceCandidate> = {}
): 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('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({
audio: { value: 'Дубляж', provenance: 'api' },
});
const to = candidate({ audio: { value: 'Eng', provenance: 'api' } });
expect(audioDiffersFactually(from, to)).toBe(true);
});
it('stays silent when either side is only a guess', () => {
const from = candidate({
audio: { value: 'Дубляж', provenance: 'api' },
});
const guessed = candidate({
audio: { value: 'MVO', provenance: 'parsed' },
});
expect(audioDiffersFactually(from, guessed)).toBe(false);
});
it('stays silent when either side is unknown', () => {
const known = candidate({
audio: { value: 'Дубляж', provenance: 'api' },
});
expect(audioDiffersFactually(known, candidate())).toBe(false);
expect(audioDiffersFactually(candidate(), known)).toBe(false);
expect(audioDiffersFactually(null, known)).toBe(false);
});
it('stays silent when both sides state the same track', () => {
const from = candidate({
audio: { value: 'Дубляж', provenance: 'api' },
});
const to = candidate({ audio: { value: 'Дубляж', provenance: 'api' } });
expect(audioDiffersFactually(from, to)).toBe(false);
});
});
@@ -0,0 +1,272 @@
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(`(?<![\\p{L}\\p{N}])(?:${source})(?![\\p{L}\\p{N}])`, 'iu');
}
/** Release-quality tags, most specific first so "4K" wins over "1080p". */
const QUALITY_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[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;
/** 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 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<VodSourceCandidate, 'quality' | 'codec' | 'container' | 'audio'> {
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 state an audio track AS FACT and those facts differ.
*
* Deliberately conservative: two guesses, or a guess against a fact, are not
* enough to claim the dub changed. The warning must be trustworthy, so it
* fires only when both sides are known — and stays silent when we simply do
* not know, which is the common case.
*/
export function audioDiffersFactually(
from: VodSourceCandidate | null | undefined,
to: VodSourceCandidate | null | undefined
): boolean {
const a = from && factualOnly(from).audio?.value;
const b = to && factualOnly(to).audio?.value;
return Boolean(a && b && a !== b);
}
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.
*/
function qualityFromDimensions(
width: number | null | undefined,
height: number | null | undefined
): string | null {
if (isPositiveNumber(width)) {
if (width >= 3400) {
return '2160p';
}
if (width >= 2400) {
return '1440p';
}
if (width >= 1700) {
return '1080p';
}
if (width >= 1200) {
return '720p';
}
if (width >= 900) {
return '576p';
}
return '480p';
}
if (!isPositiveNumber(height)) {
return null;
}
const standard = STANDARD_HEIGHTS.find(
([reference]) => Math.abs(height - reference) / reference <= 0.05
);
return standard ? standard[1] : null;
}
@@ -0,0 +1,219 @@
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 { applyApiMetadata } 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 {
/**
* 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<string, string>();
async resolve(
candidate: VodSourceCandidate,
options: { startTime?: number } = {}
): Promise<ResolvedVodSource | null> {
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) {
console.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 info = Array.isArray(vodDetails?.info)
? undefined
: vodDetails?.info;
const video = info?.video as
| { codec_name?: string; width?: number; height?: number }
| undefined;
const audio = info?.audio as { codec_name?: string } | undefined;
const container = vodDetails?.movie_data?.container_extension;
if (container) {
this.containerCache.set(cacheKey, container);
}
return {
vodDetails,
metadata: {
containerExtension: container ?? null,
videoCodec: video?.codec_name ?? null,
audioCodec: audio?.codec_name ?? null,
width: video?.width ?? null,
height: video?.height ?? null,
},
};
} catch (error) {
console.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<XtreamUrlService['constructVodUrl']>[1],
metadata: { containerExtension: cachedContainer },
};
}
}
}
@@ -53,6 +53,8 @@ export class VodDetailsPlaybackService {
null
);
private lastSaveTime = 0;
/** Cleared on every start; latches once playback reaches startTime. */
private resumeSettled = false;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
@@ -274,6 +276,7 @@ export class VodDetailsPlaybackService {
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
this.resumeSettled = false;
}
handleInlineTimeUpdate(event: {
@@ -283,6 +286,19 @@ export class VodDetailsPlaybackService {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
// A resuming engine can emit timeupdates at ~0 before it finishes
// seeking to startTime. Writing one would overwrite the very position
// we are resuming from — which a source switch would then inherit.
//
// 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 (!this.resumeSettled) {
const startTime = playback.startTime ?? 0;
if (startTime > 0 && event.currentTime < startTime - 5) return;
this.resumeSettled = true;
}
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
@@ -324,8 +340,22 @@ 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`.
*/
startResolvedPlayback(playback: ResolvedPortalPlayback): void {
this.startPlayback(playback);
}
private startPlayback(playback: ResolvedPortalPlayback): void {
this.lastSaveTime = 0;
this.resumeSettled = false;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
@@ -190,6 +190,19 @@
</span>
</button>
}
@if (multiSource.hasAlternatives()) {
<app-vod-sources-chip
[sources]="multiSource.sources()"
[title]="multiSourceTitle()"
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
(autoFailoverToggled)="setAutoFailover($event)"
/>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
@@ -236,12 +249,44 @@
</button>
}
}
@if (activeSourceCaption(); as caption) {
<p class="source-caption">
<span class="source-caption__label">
{{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
</span>
<span class="source-caption__source">{{
caption.source
}}</span>
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE'
| translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: { count: caption.alternativeCount }
}}
}
</span>
}
</p>
}
</ng-template>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
(alternativeSourceRequested)="playFromSource($event)"
(sourcePinRequested)="pinSource($event)"
(sourceCheckRequested)="checkSource($event)"
(playbackFailed)="onPlaybackFailed()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
@@ -4,6 +4,27 @@
height: 100%;
min-height: 0;
// "Playing from: SharkTV · 1080p · mkv — also found in 2 playlists".
// Projected into the hero's action row, which is a flex row, so it claims
// a full-width line of its own below the buttons.
::ng-deep .source-caption {
flex: 0 0 100%;
margin: 10px 0 0;
font-size: 0.75rem;
line-height: 1.4;
color: var(--text-muted);
.source-caption__source {
margin-left: 6px;
font-weight: 600;
color: var(--text-secondary);
}
.source-caption__extra {
margin-left: 6px;
}
}
::ng-deep .details__tag--status {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--accent-color) 28%, transparent);
@@ -20,6 +20,7 @@ import {
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
PortalDetailShellComponent,
VodSourcesChipComponent,
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
@@ -36,6 +37,7 @@ import {
} from '@iptvnator/services';
import {
getXtreamVodInfo,
playlistDisplayLabel,
normalizeTitleKeys,
TmdbEnrichedCastMember,
XtreamCategory,
@@ -53,6 +55,8 @@ import {
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
@Component({
templateUrl: './vod-details-route.component.html',
@@ -61,7 +65,7 @@ import { VodDetailsPlaybackService } from './vod-details-playback.service';
'./vod-details-route.component.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [VodDetailsPlaybackService],
providers: [VodDetailsPlaybackService, VodMultiSourceHostService],
imports: [
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -72,6 +76,7 @@ import { VodDetailsPlaybackService } from './vod-details-playback.service';
SlicePipe,
TranslateModule,
PortalInlinePlayerComponent,
VodSourcesChipComponent,
],
})
export class VodDetailsRouteComponent implements OnInit, OnDestroy {
@@ -85,6 +90,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
/** Alternative sources for this movie in the user's other playlists */
readonly multiSource = inject(VodMultiSourceHostService);
private readonly logger = createLogger('VodDetailsRoute');
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
@@ -172,6 +179,21 @@ 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(),
})
);
readonly selectedVodInfo = computed(() => {
const item = this.selectedItem();
return item && hasUsableXtreamVodMetadata(item)
@@ -288,6 +310,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
vodId: this.selectedVodId,
vodInfo: this.selectedVodInfo,
});
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) =>
this.playback.startResolvedPlayback(playback),
movie: this.multiSourceMovie,
});
// Initializes on first render and RE-initializes when the route
// params change while the component is reused (Similar rail).
@@ -418,10 +447,101 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.closeInlinePlayer();
}
/** Title shown in the sources popover header. */
readonly multiSourceTitle = computed(
() => this.multiSourceMovie()?.title ?? ''
);
/** The ".srcbar" caption under the action row: where this is playing from. */
readonly activeSourceCaption = computed(() => {
const active = this.multiSource.sources().find((s) => s.isActive);
if (!active) {
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(' · '),
alternativeCount: this.multiSource.alternativeCount(),
};
});
playFromSource(sourceId: string): void {
void this.multiSource.play(sourceId);
}
pinSource(sourceId: string): void {
void this.multiSource.togglePin(sourceId);
}
checkSource(sourceId: string): void {
void this.multiSource.check(sourceId);
}
setAutoFailover(enabled: boolean): void {
this.settingsStore.updateSettings({ vodAutoFailover: enabled });
}
/**
* 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<void> {
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 {
// Ahead of the service's 15s persistence throttle, so a source switch
// resumes from where playback actually is rather than up to 15s back.
this.multiSource.reportPosition(event.currentTime);
this.playback.handleInlineTimeUpdate(event);
}
@@ -0,0 +1,395 @@
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 {
ResolvedPortalPlayback,
VodSourceCandidate,
VodSourceField,
} from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
const MOVIE_A: VodMultiSourceMovie = {
playlistId: 'playlist-1',
playlistName: 'Playlist One',
contentId: 101,
title: 'The Matrix',
year: 1999,
};
const MOVIE_B: VodMultiSourceMovie = {
...MOVIE_A,
contentId: 202,
title: 'Blade Runner',
year: 1982,
}; // a second movie, to prove load() fully resets the session
const CURRENT_A_ID = 'playlist-1:xtream:101';
const API_AC3: VodSourceField = { value: 'ac3', provenance: 'api' };
const API_AAC: VodSourceField = { value: 'aac', provenance: 'api' };
const PARSED_DUB: VodSourceField = { value: 'Дубляж', provenance: 'parsed' };
const PROBE_OK = { status: 'ok', httpStatus: 200, latencyMs: 42 };
type DiscoveryResult = { sources: VodSourceCandidate[]; matchKind: string };
type AudioPicker = (c: VodSourceCandidate) => VodSourceField | undefined;
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,
};
}
const [ALT_TWO, ALT_THREE] = [alternative(2), alternative(3)];
function resolvedFor(
candidate: VodSourceCandidate,
startTime: number | undefined,
audio?: VodSourceField
) {
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. */
function resolveWith(audioFor?: AudioPicker) {
return async (
candidate: VodSourceCandidate,
options?: { startTime?: number }
) => resolvedFor(candidate, options?.startTime, audioFor?.(candidate));
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe('VodMultiSourceHostService', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
/** Runs pending root effects, then drains the work they started. */
async function flushEffects(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
TestBed.tick();
await Promise.resolve();
}
}
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
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(
firstAudio: VodSourceField | undefined,
secondAudio: VodSourceField
) {
await loadMovie([ALT_TWO, ALT_THREE]);
resolver.resolve.mockImplementation(
resolveWith((candidate) =>
candidate.id === ALT_TWO.id ? firstAudio : secondAudio
)
);
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 })
);
});
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',
});
movie.set({ ...MOVIE_A });
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
movie.set(MOVIE_B);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
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('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('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, VodSourceField | undefined, VodSourceField, boolean]>([
['fires when both audio values are facts', API_AC3, API_AAC, true],
['is silent for a guessed audio', PARSED_DUB, API_AAC, false],
['is silent without both audio values', undefined, API_AAC, 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);
expect(service.isExhausted()).toBe(true);
});
it('does not retry a source it could not resolve', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
resolver.resolve.mockResolvedValueOnce(null);
await expect(service.failover()).resolves.toBeNull();
const failedId = resolvedIds()[0];
expect(rowFor(failedId)?.isActive).toBe(false);
expect(rowFor(failedId)?.isTried).toBe(true);
expect(startPlayback).not.toHaveBeenCalled();
await expect(service.failover()).resolves.not.toBeNull();
expect(resolvedIds()[1]).not.toBe(failedId);
expect(rowFor(resolvedIds()[1])?.isActive).toBe(true);
});
it('round-trips a pin under the movie match key', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
expect(matchKeys.length).toBeGreaterThan(0);
const pin = {
matchKey: matchKeys[0],
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
};
await service.togglePin(ALT_TWO.id);
expect(pins.set).toHaveBeenCalledWith(pin);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
await service.togglePin(ALT_TWO.id);
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
expect(pins.set).toHaveBeenCalledTimes(1);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
pins.get.mockResolvedValue(pin);
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isPinned).toBe(false);
});
it('reports unknown, never fail, for a source it cannot check', async () => {
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);
expect(probes.probe).toHaveBeenCalledWith(
`http://${ALT_TWO.playlistId}/${ALT_TWO.contentId}.mkv`
);
expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK);
});
it('discards a discovery that resolves after the movie changed', async () => {
const first = createDeferred<DiscoveryResult>();
const second = createDeferred<DiscoveryResult>();
discovery.discover
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const loadA = service.load(MOVIE_A);
const loadB = service.load(MOVIE_B);
first.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadA;
expect(service.sources()).toEqual([]);
// Abandoned on arrival: it never even looks the stale pin up.
expect(pins.get).not.toHaveBeenCalled();
second.resolve({ sources: [ALT_THREE], matchKind: 'title-year' });
await loadB;
expect(pins.get).toHaveBeenCalledTimes(1);
expect(service.sources().map((source) => source.id)).toEqual([
'playlist-1:xtream:202',
ALT_THREE.id,
]);
});
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();
});
});
@@ -0,0 +1,350 @@
import {
computed,
effect,
inject,
Injectable,
signal,
type Signal,
} from '@angular/core';
import {
VodMultiSourceController,
VodSourceDiscoveryService,
VodSourceResolverService,
audioDiffersFactually,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import {
vodMultiSourceMovieKey,
type VodMultiSourceMovie,
} from './vod-multi-source-identity';
import {
buildVodSourceMatchKey,
buildVodSourceMatchKeyCandidates,
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, or a later film would
* inherit a poisoned failover history.
*
* The host component supplies the movie identity and the two playback seams
* (start a playback, read the live position) through `bind()`, so this service
* never reaches into the route component.
*/
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<VodMultiSourceMovie | null>;
}
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;
}
@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 matchKeys: string[] = [];
/** Guards against a stale discovery resolving after the user moved on. */
private discoveryToken = 0;
private lastMovieKey: string | null = null;
/** The source that was playing before the most recent switch. */
private readonly _previousSourceId = signal<string | null>(null);
private readonly _sources = signal<VodSourceDescriptor[]>([]);
private readonly _busySourceId = signal<string | null>(null);
/** Emitted after a switch so the host can toast it — never silent. */
private readonly _lastSwitch = signal<VodMultiSourceSwitchNotice | null>(
null
);
readonly sources = this._sources.asReadonly();
readonly busySourceId = this._busySourceId.asReadonly();
readonly lastSwitch = this._lastSwitch.asReadonly();
readonly previousSourceId = this._previousSourceId.asReadonly();
readonly alternatives = computed(() =>
this._sources().filter((source) => !source.isActive)
);
/** The chip appears only when there is genuinely somewhere else to go. */
readonly hasAlternatives = computed(() => this.alternatives().length > 0);
readonly alternativeCount = computed(() => this.alternatives().length);
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, not a snapshot: `isAvailable` is itself a getter over
* the Electron bridge, so copying it once could disagree with it. */
get isAvailable(): boolean {
return this.discovery.isAvailable;
}
/**
* Wire the host's playback seam and start watching the movie on screen.
*
* The effect lives here rather than in the route component because it is
* this service's own lifecycle: the router REUSES the detail component for
* detail-to-detail navigation, so a new movie must fully reset the session
* — above all the tried-source set that makes failover terminate.
*/
bind(bindings: VodMultiSourceBindings): void {
this.bindings = bindings;
effect(() => {
const movie = bindings.movie();
if (!movie) {
return;
}
const key = vodMultiSourceMovieKey(movie);
if (this.lastMovieKey === key) {
return;
}
this.lastMovieKey = key;
void this.load(movie);
});
}
/**
* Start a fresh session for a movie. The router REUSES this component for
* detail→detail navigation, so this must fully reset — including the
* tried-source set.
*/
async load(movie: VodMultiSourceMovie): Promise<void> {
const token = ++this.discoveryToken;
this.controller = new VodMultiSourceController();
this._sources.set([]);
this._lastSwitch.set(null);
this._busySourceId.set(null);
this._previousSourceId.set(null);
this.matchKeys = buildVodSourceMatchKeyCandidates(movie);
if (!this.discovery.isAvailable) {
return;
}
const result = await this.discovery.discover({
title: movie.title,
year: movie.year,
currentPlaylistId: movie.playlistId,
});
if (token !== this.discoveryToken) {
return;
}
// The playing source is part of the list, so the popover can show it
// as current rather than pretending the movie came from nowhere.
const current: 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,
};
this.controller.setSources(
[current, ...result.sources],
result.matchKind
);
this.controller.setActiveSource(current.id);
const pin = await this.pins.get(this.matchKeys);
if (token !== this.discoveryToken) {
return;
}
if (pin) {
this.controller.setPinnedSource(
`${pin.playlistId}:${pin.portalType}:${pin.contentId}`
);
}
this.publish();
}
/** Play from a specific source once; does not change the pin. */
async play(sourceId: string): Promise<boolean> {
const candidate = this.controller.findSource(sourceId);
if (!candidate || !this.bindings) {
return false;
}
this._busySourceId.set(sourceId);
try {
return await this.switchTo(candidate);
} finally {
this._busySourceId.set(null);
}
}
/** Pin or unpin this source as the movie's preferred one. */
async togglePin(sourceId: string): Promise<void> {
if (this.matchKeys.length === 0) {
return;
}
const alreadyPinned = this._sources().some(
(source) => source.id === sourceId && source.isPinned
);
if (alreadyPinned) {
await this.pins.clear(this.matchKeys);
this.controller.setPinnedSource(null);
this.publish();
return;
}
const candidate = this.controller.findSource(sourceId);
if (!candidate) {
return;
}
// Written under the most-trusted key; lookups pass every alias, so a
// TMDB id arriving later does not orphan this row.
const matchKey = this.matchKeys[0] ?? buildVodSourceMatchKey(candidate);
if (!matchKey) {
return;
}
await this.pins.set({
matchKey,
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: candidate.portalType,
});
this.controller.setPinnedSource(sourceId);
this.publish();
}
/** User-triggered availability check for one row. */
async check(sourceId: string): Promise<void> {
const candidate = this.controller.findSource(sourceId);
if (!candidate) {
return;
}
this.controller.setProbe(sourceId, { status: 'probing' });
this.publish();
const resolved = await this.resolver.resolve(candidate);
if (!resolved) {
// We could not even build a URL — that is "cannot check", not
// "this source is dead".
this.controller.setProbe(sourceId, { status: 'unknown' });
this.publish();
return;
}
this.controller.updateSource(resolved.candidate);
const result = await this.probes.probe(resolved.playback.streamUrl);
this.controller.setProbe(sourceId, result);
this.publish();
}
/**
* Automatically move to the best untried source after a playback failure.
*
* Returns the notice to announce, or null when auto-failover is off or
* every source has been tried — in which case the caller shows the honest
* error screen instead.
*/
async failover(): Promise<VodMultiSourceSwitchNotice | null> {
if (!this.autoFailoverEnabled()) {
return null;
}
const target = this.controller.pickFailoverTarget();
if (!target) {
return null;
}
const switched = await this.switchTo(target);
return switched ? this._lastSwitch() : null;
}
/** True once every alternative has been attempted this session. */
isExhausted(): boolean {
return this.controller.isExhausted();
}
/** Feeds the live player position; called ahead of the persist throttle. */
reportPosition(seconds: number): void {
this.controller.setResumeSeconds(seconds);
}
private async switchTo(candidate: VodSourceCandidate): Promise<boolean> {
if (!this.bindings) {
return false;
}
// Read the LIVE position, not the persisted one: the DB value lags by
// up to 15 seconds and switching would visibly rewind.
const resumeSeconds = Math.floor(this.controller.getResumeSeconds());
const previous = this.controller.findSource(
this.controller.activeSourceId() ?? ''
);
const resolved = await this.resolver.resolve(candidate, {
startTime: resumeSeconds,
});
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.
this.controller.markTried(candidate.id);
this.publish();
return false;
}
this.controller.updateSource(resolved.candidate);
this._previousSourceId.set(previous?.id ?? null);
this.controller.setActiveSource(candidate.id);
this.controller.setResumeSeconds(resumeSeconds);
this.bindings.startPlayback(resolved.playback);
this._lastSwitch.set({
playlistName: candidate.playlistName,
resumeSeconds,
audioMayDiffer: audioDiffersFactually(previous, resolved.candidate),
quality: resolved.candidate.quality?.value,
container: resolved.candidate.container?.value,
});
this.publish();
return true;
}
private publish(): void {
this._sources.set(this.controller.sources());
}
}
@@ -0,0 +1,74 @@
import {
extractYear,
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;
}
type CatalogItem =
| (Partial<XtreamVodStream> & { 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;
}): 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,
year: extractYear(vodInfo?.releasedate, title),
tmdbId: vodInfo?.tmdb_id,
};
}
/**
* Identity key for "this is still the same movie".
*
* Includes the title because it can arrive after the ids do — the first pass
* may run against the catalog title and a better one may follow.
*/
export function vodMultiSourceMovieKey(movie: VodMultiSourceMovie): string {
return `${movie.playlistId}:${movie.contentId}:${movie.title}`;
}
+2
View File
@@ -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';
@@ -29,6 +29,7 @@ const DEFAULT_SETTINGS: Settings = {
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
@@ -196,6 +197,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(),
@@ -0,0 +1,121 @@
import { Injectable } from '@angular/core';
import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces';
/**
* 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<string, CacheEntry>();
/** Deduplicates concurrent probes of the same URL into one request. */
private readonly inFlight = new Map<string, Promise<VodSourceProbeResult>>();
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.probeStreamUrl === 'function'
);
}
/** A cached result for this URL, if one is still fresh. */
peek(url: string): VodSourceProbeResult | null {
const entry = this.cache.get(url);
if (!entry || Date.now() - entry.storedAt > CACHE_TTL_MS) {
return null;
}
return entry.result;
}
async probe(
url: string,
method: 'GET' | 'HEAD' = 'HEAD'
): Promise<VodSourceProbeResult> {
if (!this.isAvailable || !url) {
// Not "offline" — we are simply unable to find out.
return { status: 'unknown' };
}
const cached = this.peek(url);
if (cached) {
return cached;
}
const pending = this.inFlight.get(url);
if (pending) {
return pending;
}
const request = this.runProbe(url, method).finally(() => {
this.inFlight.delete(url);
});
this.inFlight.set(url, 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'
): Promise<VodSourceProbeResult> {
let result: VodSourceProbeResult;
try {
const response = await window.electron.probeStreamUrl(url, method);
result = {
status: toProbeStatus(response.status),
httpStatus: response.status,
latencyMs: response.latencyMs,
probedAt: new Date().toISOString(),
};
} catch (error) {
console.warn('Stream probe failed:', 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(url, { 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.
*/
function toProbeStatus(httpStatus: number): VodSourceProbeResult['status'] {
if (httpStatus === 0) {
return 'unknown';
}
if (httpStatus >= 200 && httpStatus < 400) {
return 'ok';
}
return 'fail';
}
@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
/**
* 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<VodSourcePin | null> {
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:', error);
return null;
}
}
async set(pin: VodSourcePin): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
try {
const result = await window.electron.dbSetVodSourcePin(pin);
return result?.success === true;
} catch (error) {
console.warn('Pinning the VOD source failed:', 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<boolean> {
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:', error);
return false;
}
}
}
@@ -301,6 +301,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,
+1
View File
@@ -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', {
@@ -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;
+3
View File
@@ -30,6 +30,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';
@@ -42,6 +43,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';
@@ -32,6 +32,10 @@ import {
} from './portal-playback.interface';
import { PortalDebugEvent } from './portal-debug.interface';
import { CatalogTitleMatch } from './catalog-title-match.interface';
import {
VodSourceCandidateRow,
VodSourcePin,
} from './vod-source.interface';
import { Settings } from './settings.interface';
import {
TmdbCacheEntry,
@@ -254,8 +258,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 {
@@ -672,6 +685,11 @@ export interface ElectronBridgeApi {
url: string,
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
/** Generic stream reachability probe (VOD multi-source availability) */
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist | PlaylistRefreshCancelledResult>;
@@ -849,6 +867,18 @@ export interface ElectronBridgeApi {
>;
/** Cross-playlist title matching (actor page "All portals" scope) */
dbMatchTitles: (titles: string[]) => Promise<CatalogTitleMatch[]>;
/** VOD multi-source: the same movie in the user's other Xtream playlists */
dbFindTitleSources: (request: {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
}) => Promise<VodSourceCandidateRow[]>;
/** Per-movie pinned source; keys are passed most-trusted first */
dbGetVodSourcePin: (matchKeys: string[]) => Promise<VodSourcePin | null>;
dbSetVodSourcePin: (pin: VodSourcePin) => Promise<ElectronBridgeResult>;
dbClearVodSourcePin: (
matchKeys: string[]
) => Promise<ElectronBridgeResult>;
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => () => void;
@@ -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);
});
});
@@ -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;
}
}
@@ -96,6 +96,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;
@@ -0,0 +1,89 @@
/**
* 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.
*
* A pin written before TMDB enrichment lands is keyed by title; after
* enrichment the same movie prefers a `tmdb:` key. Looking up both means the
* earlier pin is not orphaned by the id arriving.
*/
export function buildVodSourceMatchKeyCandidates(input: {
tmdbId?: number | string | null;
title?: string | null;
year?: number | null;
}): string[] {
const keys: string[] = [];
const tmdbId = normalizeTmdbId(input.tmdbId);
if (tmdbId !== null) {
keys.push(`${TMDB_PREFIX}${tmdbId}`);
}
const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null });
if (titleKey && !keys.includes(titleKey)) {
keys.push(titleKey);
}
return keys;
}
export function isTmdbMatchKey(key: string): boolean {
return key.startsWith(TMDB_PREFIX);
}
/**
* 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;
}
@@ -0,0 +1,136 @@
/**
* 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<T = string> {
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';
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;
audio?: 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;
}
+4
View File
@@ -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';
@@ -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;
}
@@ -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<string, VodSourceDescriptor[]>();
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}`;
}
@@ -0,0 +1,94 @@
<div class="source-row" [class.source-row--active]="source().isActive">
<span class="source-row__monogram" aria-hidden="true">{{
monogram()
}}</span>
<div class="source-row__info">
<div class="source-row__name-line">
<span class="source-row__name" [title]="displayLabel()">{{
displayLabel()
}}</span>
@if (source().isActive) {
<span class="source-row__badge">{{
'PORTALS.MULTI_SOURCE.PLAYING' | translate
}}</span>
}
</div>
<div class="source-row__tags">
@for (tag of tags(); track tag.id) {
<span
class="source-tag"
[class.source-tag--ok]="tag.tone === 'ok'"
[class.source-tag--warn]="tag.tone === 'warn'"
[class.source-tag--bad]="tag.tone === 'bad'"
>
@if (tag.spinner) {
<span
class="source-tag__spinner"
aria-hidden="true"
></span>
} @else if (tag.icon) {
<mat-icon class="source-tag__icon">{{
tag.icon
}}</mat-icon>
}
@if (tag.prefix) {
<span class="source-tag__prefix">{{ tag.prefix }}</span>
}
@if (tag.labelKey) {
<span>{{ tag.labelKey | translate }}</span>
} @else {
<span>{{ tag.text }}</span>
}
@if (tag.suffix) {
<span class="source-tag__suffix">{{ tag.suffix }}</span>
}
</span>
}
@if (showCheckChip()) {
<button
type="button"
class="source-tag source-tag--action"
(click)="onCheck($event)"
[matTooltip]="
'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate
"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate
"
>
<mat-icon class="source-tag__icon">refresh</mat-icon>
<span>{{ 'PORTALS.MULTI_SOURCE.CHECK' | translate }}</span>
</button>
}
</div>
</div>
<div class="source-row__actions">
@if (showPin()) {
<button
type="button"
class="source-row__action"
[class.is-active]="source().isPinned"
[attr.aria-pressed]="source().isPinned"
(click)="onPin($event)"
[matTooltip]="pinLabelKey() | translate"
[attr.aria-label]="pinLabelKey() | translate"
>
<mat-icon>push_pin</mat-icon>
</button>
}
<button
type="button"
class="source-row__action source-row__action--play"
(click)="onPlay($event)"
[matTooltip]="'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
</div>
</div>
@@ -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;
}
}
@@ -0,0 +1,78 @@
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<VodSourceDescriptor>();
/** The error screen lists alternatives without offering the pin action. */
readonly showPin = input(true);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
/**
* 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);
}
}
@@ -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<VodSourcePortalType, string> = {
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();
}
@@ -0,0 +1,49 @@
<button
type="button"
class="sources-chip"
[class.sources-chip--open]="isOpen()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
(click)="toggle()"
[attr.aria-expanded]="isOpen()"
[attr.aria-label]="'PORTALS.MULTI_SOURCE.OPEN_SOURCES' | translate"
>
<mat-icon class="sources-chip__icon">video_library</mat-icon>
<span class="sources-chip__label">{{
'PORTALS.MULTI_SOURCE.SOURCES' | translate
}}</span>
<span class="sources-chip__count">{{ displayCount() }}</span>
<mat-icon class="sources-chip__caret" [class.is-open]="isOpen()"
>expand_more</mat-icon
>
</button>
<!-- CDK Overlay rather than MatMenu: this is a rich panel, not a list of menu
items, and Material's menu caps its width at 280px in a way that fought
every override. Here the width is simply ours. -->
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayViewportMargin]="16"
(backdropClick)="close()"
(detach)="close()"
(overlayKeydown)="onOverlayKeydown($event)"
>
<app-vod-sources-menu
class="sources-chip__panel"
[sources]="sources()"
[title]="title()"
[matchKind]="matchKind()"
[autoFailoverEnabled]="autoFailoverEnabled()"
[resumeLabel]="resumeLabel()"
[showPin]="showPin()"
(playRequested)="onPlay($event)"
(pinRequested)="onPin($event)"
(checkRequested)="onCheck($event)"
(autoFailoverToggled)="autoFailoverToggled.emit($event)"
/>
</ng-template>
@@ -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);
}
@@ -0,0 +1,125 @@
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<VodSourceDescriptor[]>();
readonly title = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
readonly resumeLabel = input<string | null>(null);
readonly showPin = input(true);
/** Falls back to the number of sources when the host does not override it. */
readonly count = input<number | null>(null);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
readonly autoFailoverToggled = output<boolean>();
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);
}
}
@@ -0,0 +1,135 @@
<div class="sources-menu">
<div class="sources-menu__header">
<h3 class="sources-menu__title">
@if (title()) {
{{
'PORTALS.MULTI_SOURCE.SOURCES_FOR'
| translate: { title: title() }
}}
} @else {
{{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }}
}
</h3>
<span class="sources-menu__hint">
@if (resumeLabel(); as resume) {
{{ resume }}
} @else {
{{ matchKindLabelKey() | translate }}
}
</span>
</div>
<div class="sources-menu__toolbar">
@if (showFilter()) {
<div class="sources-menu__filter">
<mat-icon class="sources-menu__filter-icon">search</mat-icon>
<input
type="text"
class="sources-menu__filter-input"
[ngModel]="filter()"
(ngModelChange)="filter.set($event)"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.FILTER_PLACEHOLDER' | translate
"
[placeholder]="
'PORTALS.MULTI_SOURCE.FILTER_PLACEHOLDER' | translate
"
/>
</div>
}
@if (uncheckedCount() > 0) {
<button
type="button"
class="sources-menu__check-all"
(click)="checkAllVisible()"
>
<mat-icon>refresh</mat-icon>
<span>{{ 'PORTALS.MULTI_SOURCE.CHECK_ALL' | translate }}</span>
</button>
}
</div>
<div class="sources-menu__list">
@for (group of groups(); track group.key) {
<div class="sources-menu__group">
<app-vod-source-row
[source]="group.primary"
[showPin]="showPin()"
(playRequested)="playRequested.emit($event)"
(pinRequested)="pinRequested.emit($event)"
(checkRequested)="checkRequested.emit($event)"
/>
@if (group.count > 1) {
<button
type="button"
class="sources-menu__variants-toggle"
[attr.aria-expanded]="isExpanded(group.key)"
(click)="toggleGroup(group.key)"
>
<mat-icon>{{
isExpanded(group.key)
? 'expand_less'
: 'expand_more'
}}</mat-icon>
<span>{{
'PORTALS.MULTI_SOURCE.COPIES'
| translate: { count: group.count }
}}</span>
</button>
@if (isExpanded(group.key)) {
<div class="sources-menu__variants">
@for (
variant of group.sources;
track variant.id;
let i = $index
) {
@if (variant.id !== group.primary.id) {
<div class="sources-menu__variant">
<span
class="sources-menu__variant-label"
[title]="variantLabelFor(group, i)"
>{{ variantLabelFor(group, i) }}</span
>
<app-vod-source-row
[source]="variant"
[showPin]="showPin()"
(playRequested)="
playRequested.emit($event)
"
(pinRequested)="
pinRequested.emit($event)
"
(checkRequested)="
checkRequested.emit($event)
"
/>
</div>
}
}
</div>
}
}
</div>
}
@if (visibleSources().length === 0) {
<p class="sources-menu__empty">
{{ 'PORTALS.MULTI_SOURCE.NO_MATCHES' | translate }}
</p>
}
</div>
<div class="sources-menu__footer">
<span class="sources-menu__footer-label">{{
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
}}</span>
<mat-slide-toggle
class="sources-menu__toggle"
[checked]="autoFailoverEnabled()"
(change)="onAutoFailoverChange($event)"
(click)="$event.stopPropagation()"
[attr.aria-label]="'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate"
/>
</div>
</div>
@@ -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;
}
@@ -0,0 +1,264 @@
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<Record<string, unknown>> {
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',
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> = {}
): 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<VodSourcesMenuComponent>;
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('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'
);
});
});
@@ -0,0 +1,155 @@
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<VodSourceDescriptor[]>();
/**
* 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<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
/**
* 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<string | null>(null);
readonly showPin = input(true);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
readonly autoFailoverToggled = output<boolean>();
/** 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<ReadonlySet<string>>(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);
}
}
}
}
@@ -24,8 +24,11 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -27,6 +27,19 @@
</h3>
</div>
<div class="player-shell__actions">
@if (allSources().length > 1) {
<app-vod-sources-chip
class="player-shell__sources"
[sources]="allSources()"
[count]="alternativeSources().length"
[resumeLabel]="sourcesResumeLabel()"
(playRequested)="
alternativeSourceRequested.emit($event)
"
(pinRequested)="sourcePinRequested.emit($event)"
(checkRequested)="sourceCheckRequested.emit($event)"
/>
}
<button
mat-button
type="button"
@@ -68,6 +81,11 @@
[playback]="playback()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
[alternativeSources]="alternativeSources()"
(alternativeSourceRequested)="
alternativeSourceRequested.emit($event)
"
(playbackFailed)="playbackFailed.emit($event)"
(timeUpdate)="onTimeUpdate($event)"
(playbackEnded)="onPlaybackEnded()"
(previousEpisodeRequested)="onPreviousEpisodeRequested()"
@@ -75,6 +75,30 @@
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
align-items: center;
}
// The sources chip sits between a plain text button (Copy Stream URL) and an
// outlined one (Close player). It is a peer of the latter, so it borrows that
// shape — outlined pill, transparent fill, Material's 40px button height —
// rather than carrying the filled surface it wears in the detail hero.
.player-shell__actions ::ng-deep app-vod-sources-chip .sources-chip {
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--mat-sys-outline, rgb(255 255 255 / 24%));
border-radius: 20px;
background: transparent;
color: inherit;
font-size: 0.875rem;
font-weight: 500;
transition:
background-color 140ms ease,
border-color 140ms ease;
&:hover {
background: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 38%);
}
}
// The viewport is the "theater stage": it spans the full content width and,
@@ -25,8 +25,11 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -19,8 +19,10 @@ import {
PlayerContentInfo,
ResolvedPortalPlayback,
VideoPlayer,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
@@ -29,6 +31,7 @@ import type {
SeriesEpisodeMetadata,
SeriesPlaybackNavigation,
} from './series-playback-navigation';
import { VodSourcesChipComponent } from '@iptvnator/ui/components';
import { UpNextRailComponent } from './up-next-rail.component';
import type { UpNextRailItem } from './up-next-rail.util';
@@ -49,6 +52,7 @@ const RAIL_STAGE_GAP = 18;
MatTooltipModule,
TranslateModule,
UpNextRailComponent,
VodSourcesChipComponent,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -201,6 +205,17 @@ export class PortalInlinePlayerComponent {
}>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/** Alternatives offered on the playback-error screen. */
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Every source incl. the playing one — powers the in-player picker. */
readonly allSources = input<VodSourceDescriptor[]>([]);
/** Formatted resume timecode shown in the picker header. */
readonly sourcesResumeLabel = input<string | null>(null);
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly alternativeSourceRequested = output<string>();
/** Relayed playback failure, so hosts can offer/auto-pick a source. */
readonly playbackFailed = output<PlaybackDiagnosticCode>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -127,6 +127,36 @@
</div>
}
@if (alternativeSources().length > 0) {
<div
class="web-player-diagnostic__alternatives"
data-test-id="playback-alternative-sources"
>
<h3 class="web-player-diagnostic__alternatives-title">
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</h3>
@for (source of visibleAlternatives(); track source.id) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="alternativeSourceRequested.emit($event)"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: { count: hiddenAlternativeCount() }
}}
</p>
}
</div>
}
@if (canShowExternalFallbackActions()) {
<div
class="web-player-diagnostic__player-actions"
@@ -118,6 +118,41 @@ app-html-video-player {
overflow-wrap: anywhere;
}
// "Try another source" — the alternatives block that turns the error screen
// into a recovery point. The overlay is always dark, so these are literals
// rather than theme tokens, matching the rest of this diagnostic surface.
.web-player-diagnostic__alternatives {
width: min(420px, 100%);
margin: 20px auto 0;
padding: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(22 27 36 / 90%);
text-align: left;
// Scrolls itself rather than growing the whole overlay: otherwise a long
// list pushes the error headline and message off the top of the screen,
// which is precisely the information the user needs first.
max-height: min(46vh, 320px);
overflow-y: auto;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.web-player-diagnostic__player-actions {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
@@ -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,31 @@ export class WebPlayerViewComponent {
duration: number;
}>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/**
* 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<VodSourceDescriptor[]>([]);
readonly alternativeSourceRequested = output<string>();
/**
* 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<PlaybackDiagnosticCode>();
/**
* 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<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -264,6 +296,9 @@ export class WebPlayerViewComponent {
}
this.playbackDiagnostic.set(issue);
if (issue) {
this.playbackFailed.emit(issue.code);
}
}
requestExternalFallback(player: ExternalPlayerName): void {