mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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>
122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
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';
|
|
}
|