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 },
};
}
}
}