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

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

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

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

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

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

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

No files matched your search

+3
View File
@@ -30,6 +30,7 @@ export * from './lib/playlist-refresh.interface';
export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
export * from './lib/playlist-display-label.util';
export * from './lib/portal-playback.interface';
export * from './lib/random-id.util';
export * from './lib/security-policy-error.utils';
@@ -42,6 +43,8 @@ export * from './lib/theme.enum';
export * from './lib/season-marker.util';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
export * from './lib/vod-source.interface';
export * from './lib/vod-source-match-key.util';
export * from './lib/xtream-account-info-dialog-data.interface';
export * from './lib/xtream-category.interface';
export * from './lib/xtream-code-actions';
@@ -32,6 +32,10 @@ import {
} from './portal-playback.interface';
import { PortalDebugEvent } from './portal-debug.interface';
import { CatalogTitleMatch } from './catalog-title-match.interface';
import {
VodSourceCandidateRow,
VodSourcePin,
} from './vod-source.interface';
import { Settings } from './settings.interface';
import {
TmdbCacheEntry,
@@ -254,8 +258,17 @@ export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult {
}
export interface ElectronBridgeXtreamProbeResult {
/**
* HTTP status, or 0 when no response was obtained.
*
* 0 covers a timeout and a URL rejected by the redirect-safety policy as
* well as a dead host, so it means "could not check" — never "offline".
*/
status: number;
url: string;
/** Round-trip time; present whenever the request completed either way. */
latencyMs?: number;
error?: string;
}
export interface ElectronBridgeEpgFetchResult extends ElectronBridgeResult {
@@ -672,6 +685,11 @@ export interface ElectronBridgeApi {
url: string,
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
/** Generic stream reachability probe (VOD multi-source availability) */
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist | PlaylistRefreshCancelledResult>;
@@ -849,6 +867,18 @@ export interface ElectronBridgeApi {
>;
/** Cross-playlist title matching (actor page "All portals" scope) */
dbMatchTitles: (titles: string[]) => Promise<CatalogTitleMatch[]>;
/** VOD multi-source: the same movie in the user's other Xtream playlists */
dbFindTitleSources: (request: {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
}) => Promise<VodSourceCandidateRow[]>;
/** Per-movie pinned source; keys are passed most-trusted first */
dbGetVodSourcePin: (matchKeys: string[]) => Promise<VodSourcePin | null>;
dbSetVodSourcePin: (pin: VodSourcePin) => Promise<ElectronBridgeResult>;
dbClearVodSourcePin: (
matchKeys: string[]
) => Promise<ElectronBridgeResult>;
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => () => void;
@@ -0,0 +1,84 @@
import {
playlistDisplayLabel,
playlistNameHasCredentials,
} from './playlist-display-label.util';
describe('playlistDisplayLabel', () => {
it('collapses a credential-bearing URL to its host', () => {
expect(
playlistDisplayLabel(
'http://vipmax.site:8080/get.php?username=00%3A1A%3A79&password=5fd87ba4&type=m3u_plus'
)
).toBe('vipmax.site:8080');
});
it('strips credentials typed outside the URL', () => {
// The default :80 is dropped by URL normalization, which reads better
// anyway; a non-default port is kept (see the case above).
expect(
playlistDisplayLabel('http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2')
).toBe('marveltv.info');
});
it('keeps a name the user actually chose', () => {
expect(playlistDisplayLabel('DE movies/kinder')).toBe(
'DE movies/kinder'
);
expect(playlistDisplayLabel('cord-cutter')).toBe('cord-cutter');
});
it('removes hand-typed credentials from a non-URL name', () => {
expect(
playlistDisplayLabel('MyPortal Username: abc Password: s3cret')
).toBe('MyPortal');
});
it('never renders a password fragment', () => {
const labels = [
'http://x.tv:80/get.php?username=joe&password=hunter2',
'Portal user==joe pass==hunter2',
'http://62.182.83.108 USERNAME : 99558627 PASSWORD : uUhQQlcHjD',
].map((n) => playlistDisplayLabel(n));
for (const label of labels) {
expect(label).not.toMatch(/hunter2|uUhQQlcHjD|99558627/);
}
});
it('truncates an over-long name', () => {
const label = playlistDisplayLabel('a'.repeat(120));
expect(label.length).toBeLessThanOrEqual(42);
expect(label.endsWith('…')).toBe(true);
});
it('falls back when there is nothing to show', () => {
expect(playlistDisplayLabel('', 'playlist-1')).toBe('playlist-1');
expect(playlistDisplayLabel(null, 'playlist-1')).toBe('playlist-1');
expect(playlistDisplayLabel(undefined)).toBe('');
});
it('does not return an empty label for a credentials-only name', () => {
expect(playlistDisplayLabel('username=joe password=x')).toBe(
'Playlist'
);
});
});
describe('playlistNameHasCredentials', () => {
it('detects query-string credentials', () => {
expect(
playlistNameHasCredentials('http://x.tv/get.php?username=a&password=b')
).toBe(true);
});
it('detects hand-typed credentials', () => {
expect(playlistNameHasCredentials('Portal usr== abc pas== def')).toBe(
true
);
});
it('is false for an ordinary name', () => {
expect(playlistNameHasCredentials('cord-cutter')).toBe(false);
expect(playlistNameHasCredentials('http://plain.tv:8080')).toBe(false);
});
});
@@ -0,0 +1,82 @@
/**
* A short, safe label for a playlist.
*
* Users routinely name a playlist after the URL they pasted, and those URLs
* carry `username=` / `password=` query parameters — or even
* `usr== X pas== Y` typed into the name by hand. Rendering that verbatim puts
* live credentials on screen in a list that exists to be looked at, screenshotted
* and screen-shared.
*
* So anything URL-shaped collapses to its host, and any credential-looking
* fragment is dropped. A name the user actually chose is left alone.
*/
/** Query keys whose presence means the string is a credential-bearing URL. */
const CREDENTIAL_KEYS = /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
/** Hand-typed credential fragments, e.g. "usr== Tl24uy0xGi pas== abc". */
const INLINE_CREDENTIALS =
/\b(?:usr|user|username|pas|pass|password|login|token)\s*[:=]+\s*\S+/gi;
const MAX_LABEL_LENGTH = 42;
/**
* Turn a stored playlist name into something short enough to scan and safe
* enough to show. Never returns an empty string when given any input, so a row
* can always be identified by something.
*/
export function playlistDisplayLabel(
rawName: string | null | undefined,
fallback = ''
): string {
const name = (rawName ?? '').trim();
if (!name) {
return fallback;
}
const host = extractHost(name);
if (host) {
return host;
}
// Not a URL, but may still have credentials typed into the name.
const cleaned = name
.replace(INLINE_CREDENTIALS, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
const safe = cleaned || fallback || 'Playlist';
return safe.length > MAX_LABEL_LENGTH
? `${safe.slice(0, MAX_LABEL_LENGTH - 1).replace(/\s+$/, '')}…`
: safe;
}
/** True when the stored name would have leaked credentials as-is. */
export function playlistNameHasCredentials(
rawName: string | null | undefined
): boolean {
const name = rawName ?? '';
return CREDENTIAL_KEYS.test(name) || INLINE_CREDENTIALS.test(name);
}
/**
* The host of the first URL in the string, port included.
*
* Handles both a clean URL and the common "http://host:8080 user== x pas== y"
* shape, where the credentials sit outside the URL entirely.
*/
function extractHost(value: string): string | null {
const match = value.match(/https?:\/\/[^\s/?#]+/i);
if (!match) {
return null;
}
try {
const url = new URL(match[0]);
return url.host || null;
} catch {
// Malformed but clearly URL-shaped — strip the scheme by hand rather
// than fall through and render the credential-bearing remainder.
return match[0].replace(/^https?:\/\//i, '') || null;
}
}
@@ -96,6 +96,16 @@ export interface Settings {
* missing values mean enabled. Only affects the built-in web players.
*/
playerUpNextRail?: boolean;
/**
* When a movie fails to play and the same film exists in another imported
* playlist, switch to it automatically instead of showing the error.
*
* Off by default and deliberately opt-in: another source can carry a
* different dub or cut, so switching is never silent — the toast always
* announces it and offers an undo. Each source is tried at most once per
* session, so this cannot loop.
*/
vodAutoFailover?: boolean;
epgUrl: string[];
streamFormat: StreamFormat;
openStreamOnDoubleClick: boolean;
@@ -0,0 +1,89 @@
/**
* Portal-agnostic identity for a movie, used to key multi-source pins.
*
* Provider ids cannot be used: the same film carries a different `stream_id`
* in every portal, which is precisely the problem this feature solves. A TMDB
* id is the strongest identity when both sides have one, but Stalker rarely
* does and Xtream's arrives asynchronously after enrichment — so the title
* form has to work standalone and stay stable when a TMDB id shows up later.
*/
import { normalizeTitleKeys } from './title-normalization.util';
const TMDB_PREFIX = 'tmdb:';
const TITLE_PREFIX = 'title:';
/**
* Build the canonical key for a movie.
*
* Prefers `tmdb:{id}`; falls back to `title:{normalizedBase}:{year}`. The
* year-stripped `base` form is used deliberately: the trailing year in a
* provider title is usually a release tag, and portals disagree about whether
* to include it.
*
* Returns `null` when there is nothing identifying to key on, so callers are
* forced to handle "cannot pin this" rather than silently writing a junk row.
*/
export function buildVodSourceMatchKey(input: {
tmdbId?: number | string | null;
title?: string | null;
year?: number | null;
}): string | null {
const tmdbId = normalizeTmdbId(input.tmdbId);
if (tmdbId !== null) {
return `${TMDB_PREFIX}${tmdbId}`;
}
const { base } = normalizeTitleKeys(input.title);
if (!base) {
return null;
}
return `${TITLE_PREFIX}${base}:${input.year ?? ''}`;
}
/**
* Every key a movie may legitimately be stored under, most-trusted first.
*
* A pin written before TMDB enrichment lands is keyed by title; after
* enrichment the same movie prefers a `tmdb:` key. Looking up both means the
* earlier pin is not orphaned by the id arriving.
*/
export function buildVodSourceMatchKeyCandidates(input: {
tmdbId?: number | string | null;
title?: string | null;
year?: number | null;
}): string[] {
const keys: string[] = [];
const tmdbId = normalizeTmdbId(input.tmdbId);
if (tmdbId !== null) {
keys.push(`${TMDB_PREFIX}${tmdbId}`);
}
const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null });
if (titleKey && !keys.includes(titleKey)) {
keys.push(titleKey);
}
return keys;
}
export function isTmdbMatchKey(key: string): boolean {
return key.startsWith(TMDB_PREFIX);
}
/**
* Coerce a provider-supplied TMDB id to a positive integer.
*
* Xtream sends this field as a string, as `0`, and as `""` depending on the
* panel build; none of those are usable ids.
*/
function normalizeTmdbId(raw: number | string | null | undefined): number | null {
if (raw === null || raw === undefined || raw === '') {
return null;
}
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
@@ -0,0 +1,136 @@
/**
* VOD multi-source: the same movie found across several imported playlists.
*
* The central rule of this feature is that the UI must never present a guess
* as a fact. Every metadata field therefore carries WHERE its value came from,
* and consumers branch on that provenance instead of on the value alone.
*/
/**
* Where a metadata value came from, in descending order of trust.
*
* - `api` the provider stated it (Xtream `get_vod_info`) — a fact
* - `parsed` inferred from the title/filename by regex — a guess
* - `probe` observed by contacting the stream — a fact
*
* v1 only ever produces `probe` for reachability and latency: reading real
* codecs would need ffprobe, which this app does not ship.
*/
export type VodSourceProvenance = 'api' | 'parsed' | 'probe';
/**
* A metadata value tagged with its origin. Absent (`undefined`) means "we do
* not know" — which the UI renders as no tag at all, never as a blank or a
* placeholder value.
*/
export interface VodSourceField<T = string> {
value: T;
provenance: VodSourceProvenance;
}
/** How confidently this source was matched to the movie being viewed. */
export type VodSourceMatchConfidence = 'exact' | 'fuzzy';
/** How the whole source list was matched — drives the popover header text. */
export type VodSourceMatchKind = 'tmdb' | 'title-year';
export type VodSourcePortalType = 'xtream' | 'stalker' | 'm3u';
/** Outcome of an on-demand availability probe. */
export type VodSourceProbeStatus =
| 'idle'
| 'probing'
/** Reachable: HEAD (or the Range-GET fallback) returned a success status */
| 'ok'
/** Contacted and refused: a definite non-success HTTP status */
| 'fail'
/**
* Could not be checked — request blocked by policy, timed out, or the
* runtime has no probe capability. Deliberately distinct from `fail`: an
* unchecked source must never be displayed as offline.
*/
| 'unknown';
export interface VodSourceProbeResult {
status: VodSourceProbeStatus;
/** HTTP status when one was actually received; 0 when none was. */
httpStatus?: number;
/** Round-trip time in milliseconds, when the request completed. */
latencyMs?: number;
/** ISO timestamp of the check, for the "checked yesterday" tag. */
probedAt?: string;
}
/**
* A raw discovery hit, exactly as the database returns it.
*
* Deliberately narrow: it is what the `content` table can prove, and nothing
* more. Notably it carries no container, codec or audio, because those columns
* do not exist — learning them costs a live request to the foreign portal.
*/
export interface VodSourceCandidateRow {
playlistId: string;
playlistName: string;
categoryId: number;
xtreamId: number;
title: string;
posterUrl: string | null;
matchConfidence: VodSourceMatchConfidence;
year: number | null;
}
/**
* A candidate source as it comes out of discovery: enough to render a row and
* to resolve a stream URL later, but NOT yet playable. Resolving the URL costs
* a live request against the foreign playlist, so it is deferred until the
* user actually asks for this source.
*/
export interface VodSourceCandidate {
/** Stable within a session: `${playlistId}:${portalType}:${contentId}` */
id: string;
playlistId: string;
playlistName: string;
portalType: VodSourcePortalType;
/** Provider-side id of the item (Xtream `stream_id`). */
contentId: number;
/** The provider's own title, unnormalized — shown for fuzzy matches. */
rawTitle: string;
matchConfidence: VodSourceMatchConfidence;
/** Release year when known, for disambiguating fuzzy matches. */
year?: number | null;
posterUrl?: string | null;
quality?: VodSourceField;
codec?: VodSourceField;
container?: VodSourceField;
audio?: VodSourceField;
/** ISO timestamp of the last failed playback attempt, if any. */
lastFailedAt?: string;
}
/**
* A candidate plus the live session state the UI needs to render it.
*/
export interface VodSourceDescriptor extends VodSourceCandidate {
isActive: boolean;
isPinned: boolean;
/** Already attempted this session — failover must not retry it. */
isTried: boolean;
probe: VodSourceProbeResult;
}
/**
* A user's per-movie choice of preferred source.
*
* Keyed by `matchKey` (see `vod-source-match-key.util.ts`) rather than by a
* provider id, because the whole point is that this movie exists under
* different ids in different portals.
*/
export interface VodSourcePin {
matchKey: string;
playlistId: string;
contentId: number;
portalType: VodSourcePortalType;
updatedAt?: string;
}