mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
fix(anime): alias disguised HLS segments for ffmpeg
- Expose local `.ts` aliases for `.image` MPEG-TS segments - Cover proxy rewriting and upstream alias removal with tests
This commit is contained in:
@@ -13,7 +13,7 @@ area: anime
|
|||||||
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
||||||
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
||||||
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
||||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments, so streams ffmpeg would otherwise probe as "a PNG" and abandon now play in mpv.
|
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives `.image` segment URLs a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases.
|
||||||
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
|
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
|
||||||
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
||||||
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
|
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ probes the segment as a picture and playback dies with "no audio or video data
|
|||||||
played". The proxy scans each segment for the first genuine MPEG-TS packet run
|
played". The proxy scans each segment for the first genuine MPEG-TS packet run
|
||||||
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
|
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
|
||||||
subtitles, encryption keys) pass through untouched, and direct-file streams
|
subtitles, encryption keys) pass through untouched, and direct-file streams
|
||||||
skip the proxy entirely.
|
skip the proxy entirely. Disguised `.image` segment URLs are exposed locally
|
||||||
|
with a `.ts` suffix so current ffmpeg releases accept them when Anki extracts
|
||||||
|
audio, screenshots, or animated images from the playing stream.
|
||||||
|
|
||||||
"Playing" in the status bar means playing: after handing mpv the stream,
|
"Playing" in the status bar means playing: after handing mpv the stream,
|
||||||
SubMiner waits until mpv actually configures a video output before reporting
|
SubMiner waits until mpv actually configures a video output before reporting
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
findTsSyncOffset,
|
findTsSyncOffset,
|
||||||
rewritePlaylistOrigins,
|
rewritePlaylistOrigins,
|
||||||
startStreamStripProxy,
|
startStreamStripProxy,
|
||||||
|
TS_SEGMENT_ALIAS_SUFFIX,
|
||||||
TS_PACKET_LENGTH,
|
TS_PACKET_LENGTH,
|
||||||
} from './stream-strip-proxy';
|
} from './stream-strip-proxy';
|
||||||
|
|
||||||
@@ -60,6 +61,25 @@ test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lin
|
|||||||
assert.ok(!rewritten.includes('41569'));
|
assert.ok(!rewritten.includes('41569'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suffix', () => {
|
||||||
|
const body = [
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXTINF:6.006,',
|
||||||
|
'/video/relative.image?token=one',
|
||||||
|
'#EXTINF:4.463,',
|
||||||
|
'http://127.0.0.1:41569/video/absolute.image',
|
||||||
|
'#EXTINF:3,',
|
||||||
|
'https://cdn.example/video/external.image',
|
||||||
|
].join('\n');
|
||||||
|
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
|
||||||
|
|
||||||
|
assert.ok(rewritten.includes(`/video/relative.image${TS_SEGMENT_ALIAS_SUFFIX}?token=one`));
|
||||||
|
assert.ok(
|
||||||
|
rewritten.includes(`http://127.0.0.1:9999/video/absolute.image${TS_SEGMENT_ALIAS_SUFFIX}`),
|
||||||
|
);
|
||||||
|
assert.ok(rewritten.includes('https://cdn.example/video/external.image'));
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------- proxy end-to-end ---------- */
|
/* ---------- proxy end-to-end ---------- */
|
||||||
|
|
||||||
type Route = { status: number; contentType: string; body: Buffer };
|
type Route = { status: number; contentType: string; body: Buffer };
|
||||||
@@ -158,6 +178,32 @@ test('proxy rewrites absolute upstream playlist entries to its own origin', asyn
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('proxy aliases image segment URLs and removes the alias before fetching upstream', async () => {
|
||||||
|
const ts = makeTsPackets(8);
|
||||||
|
const disguised = Buffer.concat([PNG_HEADER, ts]);
|
||||||
|
await withProxy(
|
||||||
|
{
|
||||||
|
'/video/list.m3u8': {
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/vnd.apple.mpegurl',
|
||||||
|
body: Buffer.from('#EXTM3U\n#EXTINF:6,\n/video/seg.image\n'),
|
||||||
|
},
|
||||||
|
'/video/seg.image': { status: 200, contentType: 'image/png', body: disguised },
|
||||||
|
},
|
||||||
|
async (proxyOrigin) => {
|
||||||
|
const playlist = await fetch(`${proxyOrigin}/video/list.m3u8`).then((response) =>
|
||||||
|
response.text(),
|
||||||
|
);
|
||||||
|
const segmentPath = playlist.split('\n').find((line) => line.startsWith('/video/'));
|
||||||
|
assert.equal(segmentPath, `/video/seg.image${TS_SEGMENT_ALIAS_SUFFIX}`);
|
||||||
|
|
||||||
|
const { status, body } = await fetchBytes(`${proxyOrigin}${segmentPath}`);
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.deepEqual(body, ts);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('proxy strips even when the client asks for a byte range', async () => {
|
test('proxy strips even when the client asks for a byte range', async () => {
|
||||||
// ffmpeg opens every HLS segment with `Range: bytes=0-`. The proxy drops the
|
// ffmpeg opens every HLS segment with `Range: bytes=0-`. The proxy drops the
|
||||||
// header, so the upstream answers 200 with the whole body and the strip
|
// header, so the upstream answers 200 with the whole body and the strip
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const TS_SYNC_BYTE = 0x47;
|
|||||||
* row at 188-byte strides do not.
|
* row at 188-byte strides do not.
|
||||||
*/
|
*/
|
||||||
const SYNC_RUN = 5;
|
const SYNC_RUN = 5;
|
||||||
|
/**
|
||||||
|
* FFmpeg 8.1 rejects HLS media whose URL suffix is not in its segment allowlist.
|
||||||
|
* The disguised MPEG-TS segments seen in the wild use `.image`, so the local
|
||||||
|
* playlist gives them this safe alias and removes it again before forwarding.
|
||||||
|
*/
|
||||||
|
export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts';
|
||||||
/** A disguise prefix is small; give up scanning after this much. */
|
/** A disguise prefix is small; give up scanning after this much. */
|
||||||
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
|
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
|
||||||
/** Bytes needed to either find a run within the limit or rule one out. */
|
/** Bytes needed to either find a run within the limit or rule one out. */
|
||||||
@@ -61,7 +67,36 @@ export function rewritePlaylistOrigins(
|
|||||||
upstreamOrigin: string,
|
upstreamOrigin: string,
|
||||||
proxyOrigin: string,
|
proxyOrigin: string,
|
||||||
): string {
|
): string {
|
||||||
return body.split(upstreamOrigin).join(proxyOrigin);
|
const rebased = body.split(upstreamOrigin).join(proxyOrigin);
|
||||||
|
return rebased
|
||||||
|
.split(/(\r?\n)/)
|
||||||
|
.map((line) => {
|
||||||
|
const uri = line.trim();
|
||||||
|
if (!uri || uri.startsWith('#')) return line;
|
||||||
|
|
||||||
|
let resolved: URL;
|
||||||
|
try {
|
||||||
|
resolved = new URL(uri, proxyOrigin);
|
||||||
|
} catch {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (resolved.origin !== proxyOrigin || !resolved.pathname.toLowerCase().endsWith('.image')) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryIndex = uri.search(/[?#]/);
|
||||||
|
const aliasIndex = queryIndex === -1 ? uri.length : queryIndex;
|
||||||
|
const leadingWhitespace = line.slice(0, line.indexOf(uri));
|
||||||
|
const trailingWhitespace = line.slice(leadingWhitespace.length + uri.length);
|
||||||
|
return `${leadingWhitespace}${uri.slice(0, aliasIndex)}${TS_SEGMENT_ALIAS_SUFFIX}${uri.slice(aliasIndex)}${trailingWhitespace}`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTsSegmentAlias(url: URL): void {
|
||||||
|
if (url.pathname.endsWith(TS_SEGMENT_ALIAS_SUFFIX)) {
|
||||||
|
url.pathname = url.pathname.slice(0, -TS_SEGMENT_ALIAS_SUFFIX.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamStripProxyOptions {
|
export interface StreamStripProxyOptions {
|
||||||
@@ -118,6 +153,7 @@ export function startStreamStripProxy(
|
|||||||
let upstreamUrl: URL;
|
let upstreamUrl: URL;
|
||||||
try {
|
try {
|
||||||
upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin());
|
upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin());
|
||||||
|
removeTsSegmentAlias(upstreamUrl);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(502).end();
|
res.writeHead(502).end();
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user