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 558e00cb44
commit 722222c651
25 changed files with 1719 additions and 69 deletions
@@ -4313,3 +4313,116 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async
cleanupDbPath(dbPath);
}
});
test('anime browser streams group by series instead of by the proxy file extension', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
// Two different shows, each behind the strip proxy. Without the metadata
// recorded up front the only readable part of either URL is ".m3u8", which
// is what used to collapse them into one series.
tracker.recordStreamPlaybackMetadata({
mediaPath: 'http://127.0.0.1:41234/video/9f2c1b7a.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
seasonNumber: 3,
episodeNumber: 4,
});
tracker.handleMediaChange(
'http://127.0.0.1:41234/video/9f2c1b7a.m3u8',
'Mushoku Tensei: Jobless Reincarnation S03E04',
);
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange(null, null);
tracker.recordStreamPlaybackMetadata({
mediaPath: 'http://127.0.0.1:41234/video/33ab90ff.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fsnafu/%2Fwatch%2Fep-10',
displayTitle: 'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
seriesTitle: 'My Teen Romantic Comedy SNAFU Climax!',
seasonNumber: null,
episodeNumber: 10,
});
tracker.handleMediaChange(
'http://127.0.0.1:41234/video/33ab90ff.m3u8',
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
);
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange(null, null);
// The same episode again: a fresh proxy port and token, which must not mint
// a second video row.
tracker.recordStreamPlaybackMetadata({
mediaPath: 'http://127.0.0.1:55555/video/ffff0000.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
seasonNumber: 3,
episodeNumber: 4,
});
tracker.handleMediaChange(
'http://127.0.0.1:55555/video/ffff0000.m3u8',
'Mushoku Tensei: Jobless Reincarnation S03E04',
);
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as { db: DatabaseSync };
const rows = privateApi.db
.prepare(
`
SELECT
v.source_url,
v.canonical_title AS video_title,
v.parsed_title,
v.parsed_season,
v.parsed_episode,
v.parser_source,
a.canonical_title AS anime_title
FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
ORDER BY v.video_id
`,
)
.all() as Array<{
source_url: string | null;
video_title: string;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
parser_source: string | null;
anime_title: string;
}>;
// Two episodes, three playbacks: the rewatch reused its row.
assert.equal(rows.length, 2);
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 2);
assert.equal(
rows.some((row) => row.anime_title.toLowerCase().includes('m3u8')),
false,
);
const mushoku = rows.find((row) => row.parsed_title?.startsWith('Mushoku'));
assert.ok(mushoku);
assert.equal(mushoku.source_url, 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4');
assert.equal(mushoku.video_title, 'Mushoku Tensei: Jobless Reincarnation S03E04');
assert.equal(mushoku.parsed_title, 'Mushoku Tensei: Jobless Reincarnation');
assert.equal(mushoku.parsed_season, 3);
assert.equal(mushoku.parsed_episode, 4);
assert.equal(mushoku.parser_source, 'anime-browser');
assert.equal(mushoku.anime_title, 'Mushoku Tensei: Jobless Reincarnation Season 3');
const snafu = rows.find((row) => row.parsed_title?.startsWith('My Teen'));
assert.ok(snafu);
assert.equal(snafu.parsed_episode, 10);
assert.equal(snafu.parsed_season, null);
assert.equal(snafu.anime_title, 'My Teen Romantic Comedy SNAFU Climax!');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});