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