fix(anime): thread episode metadata through stream playback and fix seas

- Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8"
- Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess
- Add a Season field to the TsukiHime modal and include later seasons in its search query
- Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT
- Update CHANGELOG and troubleshooting docs
This commit is contained in:
2026-08-01 23:34:23 -07:00
parent 590794b34e
commit 41c23b131b
25 changed files with 1721 additions and 69 deletions
+79
View File
@@ -903,6 +903,85 @@ test('runSubsyncManual keeps a retimed secondary track in the secondary slot', a
);
});
test('runSubsyncManual converts VTT stream tracks to SRT before running alass', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-vtt-'));
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const videoPath = 'https://example.com/stream.m3u8';
const primaryPath = path.join(tmpDir, 'track-1.vtt');
const sourcePath = path.join(tmpDir, 'track-0.vtt');
fs.writeFileSync(primaryPath, 'WEBVTT\n\n00:00:05.000 --> 00:00:06.000\nしっかし\n');
fs.writeFileSync(sourcePath, 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nWell then\n');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
alassPath,
`#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\ncp "$2" "$3"\nexit 0\n`,
);
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': primaryPath,
},
{
id: 2,
type: 'sub',
selected: false,
external: true,
'external-filename': sourcePath,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
assert.equal(result.ok, true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs.length, 3);
for (const arg of alassArgs) {
assert.equal(path.extname(arg), '.srt');
}
// The originals stay put; only the alass copies are rewritten.
assert.equal(fs.existsSync(primaryPath), true);
assert.equal(fs.existsSync(sourcePath), true);
const loadedPath = sentCommands[0]?.[1];
assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.equal(typeof loadedPath, 'string');
assert.match(fs.readFileSync(fromShellPath(String(loadedPath)), 'utf8'), /しっかし/);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
const alassPath = path.join(tmpDir, 'alass.sh');