mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
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';
|
||||
+236
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user