mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user