mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
Five findings from the round-5 review. An external player launched for an alternative carries that playlist's ids, so the page disowned its own session: the primary button never became Stop, stopping found nothing to stop, and another click opened a second player. Multi-source now tells playback which source is actually active, and the matcher accepts either that or the route's own stream. Stop also has to beat the pin. The primary action consults the pin first — that is what makes a pin decide where playback starts — but while a session is running the same button reads Stop, and consulting the pin there made the control do the opposite of its label. A pinned source started from the Resume button resolved at zero, because nothing reports a live position until the first timeupdate. The controller is now seeded from the persisted position, one-way: a live value always wins, since the stored one lags it and applying it would rewind. A pin whose write failed was still shown as pinned, promising a preference that reopening the movie would not have. Portal failures in this path logged raw errors. An Xtream error message carries the stream URL, and that URL is built out of the username and password, so they now go through the redacting logger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
import { Injectable } from '@angular/core';
|
|
import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces';
|
|
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
|
|
|
/**
|
|
* 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 {
|
|
let response = await window.electron.probeStreamUrl(url, method);
|
|
|
|
// Plenty of stream servers reject HEAD outright yet serve the media
|
|
// happily over GET. Reporting those as unavailable would be a
|
|
// confident lie, so retry once with the ranged GET the main process
|
|
// already supports.
|
|
if (method === 'HEAD' && refusesHeadRequests(response.status)) {
|
|
response = await window.electron.probeStreamUrl(url, 'GET');
|
|
}
|
|
|
|
result = {
|
|
status: toProbeStatus(response.status),
|
|
httpStatus: response.status,
|
|
latencyMs: response.latencyMs,
|
|
probedAt: new Date().toISOString(),
|
|
};
|
|
} catch (error) {
|
|
// The probed URL is a stream URL, and Xtream builds those out
|
|
// of the username and password — never log one raw.
|
|
console.warn('Stream probe failed:', redactSensitiveData(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.
|
|
*/
|
|
/** Statuses that mean "not this method", not "not this resource". */
|
|
function refusesHeadRequests(httpStatus: number): boolean {
|
|
return httpStatus === 405 || httpStatus === 501;
|
|
}
|
|
|
|
function toProbeStatus(httpStatus: number): VodSourceProbeResult['status'] {
|
|
if (httpStatus === 0) {
|
|
return 'unknown';
|
|
}
|
|
if (httpStatus >= 200 && httpStatus < 400) {
|
|
return 'ok';
|
|
}
|
|
return 'fail';
|
|
}
|