mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user