Files
SubMiner/src/anime-bridge/media-url.ts
T
sudacode ff25e5cafa fix(anime): strip disguised HLS segments and report real playback errors
Some hosts prepend a fake image header (a real 1x1 PNG) to every HLS
segment; ffmpeg probes the segment as a picture and mpv drops back to
idle with no window while the browser claims the episode is playing.

- Route bridge-served m3u8 streams through a local strip proxy that
  scans each segment for the first genuine MPEG-TS packet run and drops
  the junk in front of it; playlists get absolute origins rewritten so
  segment requests come back through the proxy. Non-TS bodies pass
  through untouched.
- Only report ok from playEpisode once mpv configures a video output;
  an end-file with reason error surfaces mpv's own message (e.g. "no
  audio or video data played") in the browser status bar instead of
  "Playing". New end-file event plumbed through the mpv IPC client.
2026-08-01 00:05:31 -07:00

99 lines
2.9 KiB
TypeScript

/**
* The bridge returns cover art and video URLs pointing at its own loopback
* media proxy, but the origin it embeds is not always the port we actually
* started it on. Rebase those onto the live bridge origin, and leave any
* genuinely remote URL untouched.
*/
const PROXY_ROUTES = new Set(['image', 'video']);
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
function isLoopbackProxyUrl(candidate: URL): boolean {
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') return false;
const host = candidate.hostname.toLowerCase();
if (!LOOPBACK_HOSTS.has(host)) return false;
const route = candidate.pathname.split('/').filter(Boolean)[0];
return route !== undefined && PROXY_ROUTES.has(route);
}
/**
* Rewrite a bridge media URL onto `bridgeBaseUrl`, preserving path and query.
* Returns the input unchanged when it is not a loopback proxy URL, or when
* either URL cannot be parsed.
*/
export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string): string {
let media: URL;
try {
media = new URL(mediaUrl);
} catch {
return mediaUrl;
}
if (!isLoopbackProxyUrl(media)) return mediaUrl;
const normalizedBase = bridgeBaseUrl.includes('://') ? bridgeBaseUrl : `http://${bridgeBaseUrl}`;
let base: URL;
try {
base = new URL(normalizedBase);
} catch {
return mediaUrl;
}
if (base.protocol !== 'http:' && base.protocol !== 'https:') return mediaUrl;
if (base.hostname.length === 0) return mediaUrl;
const rebased = new URL(base.origin);
rebased.pathname = media.pathname;
rebased.search = media.search;
rebased.hash = media.hash;
return rebased.toString();
}
/**
* Send a bridge-served HLS stream through the local strip proxy instead. Only
* `.m3u8` URLs on the bridge origin qualify: direct files need no fixing, and
* an external URL would not resolve through a proxy that forwards to the
* bridge. Anything unparseable comes back unchanged.
*/
export function routeHlsThroughProxy(
streamUrl: string,
bridgeBaseUrl: string,
proxyOrigin: string,
): string {
let stream: URL;
let bridge: URL;
try {
stream = new URL(streamUrl);
bridge = new URL(bridgeBaseUrl);
} catch {
return streamUrl;
}
if (stream.origin !== bridge.origin) return streamUrl;
if (!stream.pathname.endsWith('.m3u8')) return streamUrl;
return `${proxyOrigin}${stream.pathname}${stream.search}`;
}
/** Aniyomi's SAnime status constants. */
export type AnimeStatus =
| 'unknown'
| 'ongoing'
| 'completed'
| 'publishing-finished'
| 'cancelled'
| 'on-hiatus';
export function parseAnimeStatus(status: number | undefined): AnimeStatus {
switch (status) {
case 1:
return 'ongoing';
case 2:
return 'completed';
case 4:
return 'publishing-finished';
case 5:
return 'cancelled';
case 6:
return 'on-hiatus';
default:
return 'unknown';
}
}