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
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildAnimeStreamMetadata,
buildAnimeStreamStatsPath,
buildStreamDisplayTitle,
splitEpisodeLabel,
splitSeasonFromTitle,
} from './episode-metadata';
test('splitSeasonFromTitle pulls a trailing season marker off the title', () => {
assert.deepEqual(splitSeasonFromTitle('Mushoku Tensei: Jobless Reincarnation Season 3'), {
title: 'Mushoku Tensei: Jobless Reincarnation',
season: 3,
});
assert.deepEqual(splitSeasonFromTitle('Spy x Family 2nd Season'), {
title: 'Spy x Family',
season: 2,
});
assert.deepEqual(splitSeasonFromTitle('Bocchi the Rock! S2'), {
title: 'Bocchi the Rock!',
season: 2,
});
assert.deepEqual(splitSeasonFromTitle('シャングリラ・フロンティア 第2期'), {
title: 'シャングリラ・フロンティア',
season: 2,
});
});
test('splitSeasonFromTitle leaves a title without a trailing marker alone', () => {
assert.deepEqual(splitSeasonFromTitle('My Teen Romantic Comedy SNAFU Climax!'), {
title: 'My Teen Romantic Comedy SNAFU Climax!',
season: null,
});
// "Season" inside the name is not a season marker.
assert.deepEqual(splitSeasonFromTitle('A Season of Snow and Ash'), {
title: 'A Season of Snow and Ash',
season: null,
});
// Nothing would be left of the title, so the marker is not a marker.
assert.deepEqual(splitSeasonFromTitle('Season 2'), { title: 'Season 2', season: null });
});
test('splitEpisodeLabel reads the number and the episode name', () => {
assert.deepEqual(splitEpisodeLabel('Episode 4'), { number: 4, title: null });
assert.deepEqual(splitEpisodeLabel('Episode 10: Gallantly, Shizuka Hiratsuka Moves Forward.'), {
number: 10,
title: 'Gallantly, Shizuka Hiratsuka Moves Forward.',
});
assert.deepEqual(splitEpisodeLabel('Ep. 7 - The Long Road'), {
number: 7,
title: 'The Long Road',
});
assert.deepEqual(splitEpisodeLabel('第12話 決戦'), { number: 12, title: '決戦' });
assert.deepEqual(splitEpisodeLabel('5. Homecoming'), { number: 5, title: 'Homecoming' });
assert.deepEqual(splitEpisodeLabel('13'), { number: 13, title: null });
assert.deepEqual(splitEpisodeLabel('Episode 6.5'), { number: 6.5, title: null });
});
test('splitEpisodeLabel keeps a label that carries no number as a name', () => {
assert.deepEqual(splitEpisodeLabel('Movie'), { number: null, title: 'Movie' });
assert.deepEqual(splitEpisodeLabel('OVA - Beach Episode'), {
number: null,
title: 'OVA - Beach Episode',
});
assert.deepEqual(splitEpisodeLabel(''), { number: null, title: null });
});
test('buildStreamDisplayTitle emits a form guessit and the jimaku parser both read', () => {
assert.equal(
buildStreamDisplayTitle('Mushoku Tensei: Jobless Reincarnation', 3, 4, null),
'Mushoku Tensei: Jobless Reincarnation S03E04',
);
assert.equal(
buildStreamDisplayTitle('My Teen Romantic Comedy SNAFU Climax!', null, 10, 'Gallantly'),
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
);
assert.equal(buildStreamDisplayTitle('Some Movie', null, null, null), 'Some Movie');
});
test('buildAnimeStreamStatsPath is stable across playbacks of the same episode', () => {
const first = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
const second = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
assert.equal(first, second);
assert.notEqual(first, buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-5'));
assert.match(first, /^animebrowser:\/\//);
});
test('buildAnimeStreamMetadata resolves the browser strings into fields', () => {
const metadata = buildAnimeStreamMetadata({
sourceId: '9001',
animeUrl: '/anime/mushoku',
animeTitle: 'Mushoku Tensei: Jobless Reincarnation Season 3',
episodeUrl: '/watch/ep-4',
episodeName: 'Episode 4',
episodeNumber: 4,
mediaPath: 'http://127.0.0.1:41234/video/abc123.m3u8',
});
assert.equal(metadata.seriesTitle, 'Mushoku Tensei: Jobless Reincarnation');
assert.equal(metadata.seasonNumber, 3);
assert.equal(metadata.episodeNumber, 4);
assert.equal(metadata.episodeTitle, null);
assert.equal(metadata.displayTitle, 'Mushoku Tensei: Jobless Reincarnation S03E04');
assert.equal(metadata.mediaPath, 'http://127.0.0.1:41234/video/abc123.m3u8');
assert.equal(
metadata.statsPath,
buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4'),
);
});
test('buildAnimeStreamMetadata prefers the extension episode number over the label', () => {
const metadata = buildAnimeStreamMetadata({
sourceId: '1',
animeUrl: '/a',
animeTitle: 'Show',
episodeUrl: '/e',
episodeName: 'Finale',
episodeNumber: 24,
mediaPath: 'http://host/x.m3u8',
});
assert.equal(metadata.episodeNumber, 24);
assert.equal(metadata.episodeTitle, 'Finale');
assert.equal(metadata.displayTitle, 'Show E24 - Finale');
});
test('buildAnimeStreamMetadata falls back to the label when the source reports no number', () => {
const metadata = buildAnimeStreamMetadata({
sourceId: '1',
animeUrl: '/a',
animeTitle: 'Show 2nd Season',
episodeUrl: '/e',
episodeName: 'Episode 3: Rain',
episodeNumber: null,
mediaPath: 'http://host/x.m3u8',
});
assert.equal(metadata.seasonNumber, 2);
assert.equal(metadata.episodeNumber, 3);
assert.equal(metadata.displayTitle, 'Show S02E03 - Rain');
});
+205
View File
@@ -0,0 +1,205 @@
/**
* Structured metadata for a streamed episode.
*
* Extensions hand us two free-form strings — an anime title that usually
* carries the season ("… Season 3") and an episode label that usually carries
* the number ("Episode 4: …"). Everything downstream (stats grouping, AniList,
* the subtitle modals) wants those as separate fields, so they are split once
* here rather than re-parsed out of the mpv title by each consumer.
*/
/** Where a stream came from, resolved into the fields consumers actually want. */
export interface AnimeStreamMetadata {
/** The URL handed to mpv. Matches what mpv reports as `path`. */
mediaPath: string;
/**
* Stable identity for this episode. The stream URL carries a per-playback
* proxy port and token, so it cannot be the key stats stores.
*/
statsPath: string;
/** Series name with the season suffix removed. */
seriesTitle: string;
seasonNumber: number | null;
episodeNumber: number | null;
/** The episode's own name, or null when the label was only a number. */
episodeTitle: string | null;
/** Shown by mpv, and the fallback every string parser sees. */
displayTitle: string;
}
export interface AnimeStreamMetadataInput {
sourceId: string;
animeUrl: string;
animeTitle: string;
episodeUrl: string;
episodeName: string;
/** Extension-reported number; trusted over anything parsed from the label. */
episodeNumber: number | null;
/** The URL playback actually uses, after proxy rewriting. */
mediaPath: string;
}
/**
* Season suffixes, anchored to the end of the title so a "Season" that is part
* of the name ("A Season of Snow") cannot be mistaken for one.
*/
const SEASON_SUFFIX_PATTERNS: RegExp[] = [
/[\s:_-]+season\s*(\d{1,2})\s*$/i,
/[\s:_-]+(\d{1,2})(?:st|nd|rd|th)\s+season\s*$/i,
/[\s:_-]+s(\d{1,2})\s*$/i,
/[\s:_-]*第\s*(\d{1,2})\s*期\s*$/,
/[\s:_-]+(\d{1,2})\s*期\s*$/,
];
/**
* Episode labels, most specific first. The trailing group is the episode's own
* name when the label carries one.
*/
const EPISODE_LABEL_PATTERNS: RegExp[] = [
/^\s*(?:episodio|épisode|episode|ep|e)\s*[.#]?\s*(\d{1,4}(?:\.\d+)?)\s*(?:[:\-–—.)]+\s*(.*))?$/i,
/^\s*第\s*(\d{1,4})\s*話\s*(?:[:\-–—]+\s*)?(.*)$/,
/^\s*(\d{1,4}(?:\.\d+)?)\s*[:\-–—.)]+\s*(.*)$/,
/^\s*(\d{1,4}(?:\.\d+)?)\s*$/,
];
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
/**
* Trims separators a split left dangling on either end. `.` is deliberately not
* one of them: an episode name often ends in a full stop that belongs to it.
*/
function trimSeparators(value: string): string {
return collapseWhitespace(value)
.replace(/^[\s:_\-–—]+/, '')
.replace(/[\s:_\-–—]+$/, '')
.trim();
}
function toEpisodeNumber(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
/**
* Split a trailing season marker off an anime title.
*
* "Mushoku Tensei: Jobless Reincarnation Season 3" becomes the series plus
* season 3, which is what both AniList and the stats grouping key want. A title
* with no marker is returned unchanged with a null season — season 1 is *not*
* assumed, because "unknown" and "one" behave differently when grouping.
*/
export function splitSeasonFromTitle(animeTitle: string): {
title: string;
season: number | null;
} {
const normalized = collapseWhitespace(animeTitle);
for (const pattern of SEASON_SUFFIX_PATTERNS) {
const match = normalized.match(pattern);
if (!match || match.index === undefined) continue;
const season = Number.parseInt(match[1]!, 10);
if (!Number.isInteger(season) || season <= 0) continue;
const title = trimSeparators(normalized.slice(0, match.index));
// A title that is *only* a season marker is not a title; keep the original.
if (!title) continue;
return { title, season };
}
return { title: normalized, season: null };
}
/**
* Split an episode label into its number and its own name.
*
* Sources are inconsistent here: "Episode 4", "4. Title", "第4話 タイトル" and a
* bare "4" all show up. A label that matches nothing is treated as a pure
* episode name, which is right for movies and specials.
*/
export function splitEpisodeLabel(episodeName: string): {
number: number | null;
title: string | null;
} {
const normalized = collapseWhitespace(episodeName);
if (!normalized) return { number: null, title: null };
for (const pattern of EPISODE_LABEL_PATTERNS) {
const match = normalized.match(pattern);
if (!match) continue;
const parsed = Number.parseFloat(match[1]!);
if (!Number.isFinite(parsed) || parsed <= 0) continue;
const title = trimSeparators(match[2] ?? '');
return { number: parsed, title: title || null };
}
return { number: null, title: normalized };
}
function formatEpisodePart(value: number): string {
return Number.isInteger(value) ? String(value).padStart(2, '0') : String(value);
}
/**
* The title mpv shows.
*
* `SxxEyy` is not just for looks: it is the one form both guessit and
* SubMiner's own filename parser read reliably, so any consumer that only ever
* sees the title string still lands on the right series and episode.
*/
export function buildStreamDisplayTitle(
seriesTitle: string,
season: number | null,
episode: number | null,
episodeTitle: string | null,
): string {
const parts: string[] = [seriesTitle];
if (episode !== null) {
parts.push(
season !== null
? `S${String(season).padStart(2, '0')}E${formatEpisodePart(episode)}`
: `E${formatEpisodePart(episode)}`,
);
} else if (season !== null) {
parts.push(`S${String(season).padStart(2, '0')}`);
}
const head = parts.join(' ');
return episodeTitle ? `${head} - ${episodeTitle}` : head;
}
/**
* A per-episode identity that survives across playbacks.
*
* The stream URL points at the strip proxy, whose port and token are minted per
* playback, so keying stats on it makes every rewatch a new video. The source's
* own episode url is stable, so that is what stats records instead — with the
* real URL kept as an alias so mpv's path change still finds the row.
*/
export function buildAnimeStreamStatsPath(
sourceId: string,
animeUrl: string,
episodeUrl: string,
): string {
const source = encodeURIComponent(sourceId || 'unknown');
const anime = encodeURIComponent(animeUrl || 'unknown');
const episode = encodeURIComponent(episodeUrl || 'unknown');
return `animebrowser://${source}/${anime}/${episode}`;
}
export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): AnimeStreamMetadata {
const { title: seriesTitle, season } = splitSeasonFromTitle(input.animeTitle);
const label = splitEpisodeLabel(input.episodeName);
const episodeNumber = toEpisodeNumber(input.episodeNumber) ?? label.number;
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
return {
mediaPath: input.mediaPath,
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
seriesTitle,
seasonNumber: season,
episodeNumber,
episodeTitle: label.title,
// A source that gave us neither a number nor a name leaves the series title
// alone rather than showing an empty suffix.
displayTitle: displayTitle || collapseWhitespace(input.animeTitle),
};
}
+1
View File
@@ -333,6 +333,7 @@ async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpiso
animeTitle: selectedAnime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
});
if (result.ok) {
+2 -1
View File
@@ -13,7 +13,8 @@ export interface AnilistMediaGuess {
year?: number;
season: number | null;
episode: number | null;
source: 'guessit' | 'fallback';
/** `stream` means the player was handed these fields, not that we parsed them. */
source: 'guessit' | 'fallback' | 'stream';
}
export interface AnilistPostWatchUpdateResult {
@@ -9,6 +9,7 @@ import {
import {
guessAnilistMediaInfo,
runGuessit,
type AnilistMediaGuess,
type GuessAnilistMediaInfoDeps,
} from './anilist-updater';
import {
@@ -32,7 +33,7 @@ interface Logger {
interface CoverArtCandidate {
title: string;
source: 'guessit' | 'fallback';
source: AnilistMediaGuess['source'];
season: number | null;
episode: number | null;
}
@@ -6,6 +6,7 @@ import {
ANILIST_SEASON_SEARCH_QUERY,
pickByAirOrder,
resolveAnilistSeasonMedia,
splitPartMarker,
stripSeasonSuffix,
type AnilistSeasonMedia,
} from './season-resolver';
@@ -305,3 +306,329 @@ test('air-order fallback declines when a franchise entry has no air year', async
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
});
/**
* Real AniList payloads for Mushoku Tensei, whose sequel chain interleaves
* split-cour continuations with real seasons:
* S1 -> "Cour 2" -> S2 -> "Season 2 Part 2" -> S3.
*/
const MUSHOKU_SEARCH: AnilistSeasonMedia[] = [
{
id: 108465,
episodes: 11,
format: 'TV',
seasonYear: 2021,
title: {
romaji: 'Mushoku Tensei: Isekai Ittara Honki Dasu',
english: 'Mushoku Tensei: Jobless Reincarnation',
native: null,
},
},
{
id: 127720,
episodes: 12,
format: 'TV',
seasonYear: 2021,
title: {
romaji: 'Mushoku Tensei: Isekai Ittara Honki Dasu Part 2',
english: 'Mushoku Tensei: Jobless Reincarnation Cour 2',
native: null,
},
},
{
id: 146065,
episodes: 13,
format: 'TV',
seasonYear: 2023,
title: {
romaji: 'Mushoku Tensei II: Isekai Ittara Honki Dasu',
english: 'Mushoku Tensei: Jobless Reincarnation Season 2',
native: null,
},
},
{
id: 166873,
episodes: 12,
format: 'TV',
seasonYear: 2024,
title: {
romaji: 'Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2',
english: 'Mushoku Tensei: Jobless Reincarnation Season 2 Part 2',
native: null,
},
},
{
id: 178789,
episodes: 14,
format: 'TV',
seasonYear: 2026,
title: {
romaji: 'Mushoku Tensei III: Isekai Ittara Honki Dasu',
english: 'Mushoku Tensei: Jobless Reincarnation Season 3',
native: null,
},
},
];
const MUSHOKU_RELATIONS: Record<
number,
Array<{ relationType: string; node: AnilistSeasonMedia }>
> = {
108465: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[1]! }],
127720: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[2]! }],
146065: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[3]! }],
166873: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[4]! }],
};
test('splitPartMarker separates a split-cour marker from the season title', () => {
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Season 2 Part 2'), {
base: 'mushoku tensei: jobless reincarnation season 2',
hasPart: true,
});
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Cour 2'), {
base: 'mushoku tensei: jobless reincarnation',
hasPart: true,
});
assert.deepEqual(splitPartMarker('Attack on Titan Final Season Part 3'), {
base: 'attack on titan final season',
hasPart: true,
});
assert.deepEqual(splitPartMarker('進撃の巨人 第2部'), { base: '進撃の巨人', hasPart: true });
});
test('splitPartMarker leaves a numbered work alone', () => {
// The number is followed by a subtitle, so it names the work, not a cour.
assert.deepEqual(splitPartMarker('JoJo no Kimyou na Bouken Part 5: Ougon no Kaze'), {
base: 'jojo no kimyou na bouken part 5: ougon no kaze',
hasPart: false,
});
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Season 3'), {
base: 'mushoku tensei: jobless reincarnation season 3',
hasPart: false,
});
});
test('a split-cour continuation does not consume a season step', async () => {
const { execute, relationLookups } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 3, episode: 4 },
{ execute },
);
assert.equal(result?.id, 178789);
assert.equal(result?.title, 'Mushoku Tensei: Jobless Reincarnation Season 3');
assert.equal(result?.seasonResolved, true);
assert.equal(result?.via, 'sequel-chain');
// Four hops for three seasons: two of them were cour continuations.
assert.deepEqual(relationLookups, [108465, 127720, 146065, 166873]);
});
test('season 2 stops at the front half rather than its second cour', async () => {
const { execute } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 2, episode: 1 },
{ execute },
);
assert.equal(result?.id, 146065);
assert.equal(result?.seasonResolved, true);
});
test('air-order fallback skips split-cour entries too', () => {
const anchor = MUSHOKU_SEARCH[0]!;
assert.equal(pickByAirOrder(anchor, 2, MUSHOKU_SEARCH)?.id, 146065);
assert.equal(pickByAirOrder(anchor, 3, MUSHOKU_SEARCH)?.id, 178789);
// Only three real seasons exist, so a fourth must not fall out of the list.
assert.equal(pickByAirOrder(anchor, 4, MUSHOKU_SEARCH), null);
});
test('a sequel that repeats its predecessor name is still a new season', async () => {
// No part marker, so the identical title must not be read as a continuation.
const search: AnilistSeasonMedia[] = [
{
id: 1,
episodes: 12,
format: 'TV',
seasonYear: 2020,
title: { romaji: 'Same Name Show', english: 'Same Name Show', native: null },
},
{
id: 2,
episodes: 12,
format: 'TV',
seasonYear: 2022,
title: { romaji: 'Same Name Show', english: 'Same Name Show', native: null },
},
];
const { execute } = createExecutor(search, {
1: [{ relationType: 'SEQUEL', node: search[1]! }],
});
const result = await resolveAnilistSeasonMedia(
{ title: 'Same Name Show', season: 2, episode: 3 },
{ execute },
);
assert.equal(result?.id, 2);
assert.equal(result?.seasonResolved, true);
});
/**
* Real AniList payloads for Bleach TYBW, whose cours are titled with their own
* arc names — the part markers live only in localized synonyms, and the English
* title hyphenates "Thousand-Year" while those synonyms do not.
*/
const BLEACH_SEARCH: AnilistSeasonMedia[] = [
{
id: 116674,
episodes: 13,
format: 'TV',
seasonYear: 2022,
synonyms: ['BLEACH TYBW'],
title: {
romaji: 'BLEACH: Sennen Kessen-hen',
english: 'BLEACH: Thousand-Year Blood War',
native: 'BLEACH 千年血戦篇',
},
},
{
id: 159322,
episodes: 13,
format: 'TV',
seasonYear: 2023,
synonyms: [
'BLEACH: Thousand Year Blood War Part 2',
'BLEACH 千年血戦篇 第2クール',
'BLEACH TYBW',
],
title: {
romaji: 'BLEACH: Sennen Kessen-hen - Ketsubetsu-tan',
english: 'BLEACH: Thousand-Year Blood War - The Separation',
native: 'BLEACH 千年血戦篇-訣別譚-',
},
},
];
test('a cour marked only in a synonym, and spelled without the hyphen, still counts as one', async () => {
const { execute } = createExecutor(BLEACH_SEARCH, {
116674: [{ relationType: 'SEQUEL', node: BLEACH_SEARCH[1]! }],
});
const result = await resolveAnilistSeasonMedia(
{ title: 'BLEACH: Thousand-Year Blood War', season: 2, episode: 1 },
{ execute },
);
// The only sequel is cour 2 of the same season, so there is no season 2 to
// find — reporting that beats confidently returning the wrong arc.
assert.equal(result?.seasonResolved, false);
assert.notEqual(result?.id, 159322);
});
test('splitPartMarker reads the Japanese cour marker', () => {
assert.deepEqual(splitPartMarker('BLEACH 千年血戦篇 第2クール'), {
base: 'bleach 千年血戦篇',
hasPart: true,
});
});
/** Dr. STONE routes its sequel chain through a one-episode special. */
const DR_STONE_SEARCH: AnilistSeasonMedia[] = [
{
id: 105333,
episodes: 24,
format: 'TV',
seasonYear: 2019,
title: { romaji: 'Dr. STONE', english: 'Dr. STONE', native: null },
},
{
id: 113936,
episodes: 11,
format: 'TV',
seasonYear: 2021,
title: { romaji: 'Dr. STONE: STONE WARS', english: 'Dr. STONE: STONE WARS', native: null },
},
{
id: 142876,
episodes: 1,
format: 'SPECIAL',
seasonYear: 2022,
title: {
romaji: 'Dr. STONE: Ryuusui',
english: 'Dr. STONE Special Episode RYUSUI',
native: null,
},
},
{
id: 131518,
episodes: 11,
format: 'TV',
seasonYear: 2023,
title: { romaji: 'Dr. STONE: NEW WORLD', english: 'Dr. STONE New World', native: null },
},
];
test('a special in the sequel chain is walked through but does not count as a season', async () => {
const { execute } = createExecutor(DR_STONE_SEARCH, {
105333: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[1]! }],
113936: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[2]! }],
142876: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[3]! }],
});
const result = await resolveAnilistSeasonMedia(
{ title: 'Dr. STONE', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 131518);
assert.equal(result?.title, 'Dr. STONE New World');
assert.equal(result?.seasonResolved, true);
});
test('air-order fallback keeps a season whose own synonym spells it as a part', () => {
// AniList carries "…Season 3 Part 1" as a localized synonym *of season 3*.
const search: AnilistSeasonMedia[] = [
{
id: 1,
episodes: 25,
format: 'TV',
seasonYear: 2013,
title: { romaji: 'Some Titans', english: 'Some Titans', native: null },
},
{
id: 2,
episodes: 12,
format: 'TV',
seasonYear: 2017,
title: { romaji: 'Some Titans Season 2', english: 'Some Titans Season 2', native: null },
},
{
id: 3,
episodes: 12,
format: 'TV',
seasonYear: 2018,
synonyms: ['Some Titans Season 3 Part 1'],
title: { romaji: 'Some Titans Season 3', english: 'Some Titans Season 3', native: null },
},
];
assert.equal(pickByAirOrder(search[0]!, 3, search)?.id, 3);
});
test('a transport failure mid-walk is surfaced instead of guessed around', async () => {
const { execute } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
const failing = async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
if (query === ANILIST_SEASON_RELATIONS_QUERY) {
throw new Error('Too Many Requests.');
}
return execute<T>(query, variables);
};
// Air order could answer here, but it would be answering from a franchise
// picture the failed walk never confirmed.
await assert.rejects(
() =>
resolveAnilistSeasonMedia(
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 3, episode: 4 },
{ execute: failing },
),
/Too Many Requests/,
);
});
+133 -15
View File
@@ -115,10 +115,45 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
const MAX_SEQUEL_HOPS = 12;
/**
* A split-cour continuation adds sequel hops without adding a season, so the
* walk needs room for more hops than the season number alone implies.
*/
const MAX_SEQUEL_STEPS = 24;
/**
* Markers AniList appends when one broadcast season is listed as several
* entries. Anchored to the end so a numbered *work* ("JoJo no Kimyou na Bouken
* Part 5: Ougon no Kaze") is not mistaken for a continuation — those carry a
* subtitle after the number and really are separate seasons.
*/
const PART_MARKER_PATTERNS: RegExp[] = [
/\bpart\s*(?:\d{1,2}|i{1,3}|iv|v)\s*$/i,
/\bcour\s*\d{1,2}\s*$/i,
/\b\d{1,2}(?:st|nd|rd|th)\s+(?:part|cour)\s*$/i,
/\b(?:second|final)\s+(?:part|cour)\s*$/i,
/\s*第\s*\d{1,2}\s*(?:部|クール)\s*$/,
];
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
/**
* Comparison key that ignores punctuation.
*
* AniList spells the same name differently across a franchise's own entries —
* "BLEACH: Thousand-Year Blood War" against the synonym "BLEACH: Thousand Year
* Blood War Part 2" — so an exact string compare misses the continuation.
*/
function titleMatchKey(value: string): string {
return value
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
}
/**
* Drops season markers a release name carries but AniList titles never do,
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
@@ -132,6 +167,50 @@ export function stripSeasonSuffix(title: string): string {
.trim();
}
/**
* Splits a trailing part/cour marker off a title.
*
* "Mushoku Tensei: Jobless Reincarnation Season 2 Part 2" is the back half of
* season 2, not season 3 — AniList lists it as its own SEQUEL of the front
* half, which is what used to make every split-cour franchise resolve one
* season short.
*/
export function splitPartMarker(title: string): { base: string; hasPart: boolean } {
const trimmed = title.trim();
for (const pattern of PART_MARKER_PATTERNS) {
const match = trimmed.match(pattern);
if (!match || match.index === undefined) continue;
const base = normalizeTitle(trimmed.slice(0, match.index));
if (!base) continue;
return { base, hasPart: true };
}
return { base: normalizeTitle(trimmed), hasPart: false };
}
/**
* Whether `next` continues `current`'s season rather than starting a new one.
*
* The marker is required, not just a matching base title: a sequel that repeats
* its predecessor's name exactly is a new season that AniList simply did not
* number, and skipping it would undercount in the other direction.
*/
function isSeasonContinuation(current: AnilistSeasonMedia, next: AnilistSeasonMedia): boolean {
const parts = mediaTitles(next)
.map((title) => splitPartMarker(title))
.filter((part) => part.hasPart);
if (parts.length === 0) return false;
// Both the full title and its own stripped base, so a "Part 3" can be matched
// against a season the walk only ever saw as its "Part 2".
const currentKeys = new Set(
mediaTitles(current).flatMap((title) => [
titleMatchKey(title),
titleMatchKey(splitPartMarker(title).base),
]),
);
return parts.some((part) => currentKeys.has(titleMatchKey(part.base)));
}
function mediaTitles(media: AnilistSeasonMedia): string[] {
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
@@ -250,16 +329,21 @@ async function walkSequelChain(
season: number,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonMedia | null> {
// Walking fewer hops than requested would land on the wrong season and report it as
// Advancing fewer seasons than requested would land on the wrong one and report it as
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
const hops = season - 1;
if (hops > MAX_SEQUEL_HOPS) {
const targetAdvances = season - 1;
if (targetAdvances > MAX_SEQUEL_HOPS) {
return null;
}
const visited = new Set<number>([anchor.id]);
let current = anchor;
// The last entry that counted as a season. Continuations are compared against
// this rather than against `current`, so a special sitting between a season
// and its second cour cannot hide the link between them.
let seasonStart = anchor;
let advances = 0;
for (let hop = 0; hop < hops; hop += 1) {
for (let step = 0; step < MAX_SEQUEL_STEPS && advances < targetAdvances; step += 1) {
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
const response = await deps.execute<AnilistSeasonRelationsResponse>(
ANILIST_SEASON_RELATIONS_QUERY,
@@ -296,11 +380,25 @@ async function walkSequelChain(
return a.id - b.id;
});
current = sequels[0]!;
const next = sequels[0]!;
// Two kinds of hop move along the chain without moving to the next season:
// the back half of a split cour, which is its own SEQUEL entry, and a
// special or OVA, which is not a season at all. Both are still traversed,
// because the rest of the franchise hangs off them.
if (isSeasonalFormat(next) && !isSeasonContinuation(seasonStart, next)) {
advances += 1;
seasonStart = next;
}
current = next;
visited.add(current.id);
}
return current.id === anchor.id ? null : current;
if (advances < targetAdvances) {
return null;
}
// seasonStart, not current: it is the entry the season count landed on, and
// it is always a seasonal format because only those advance the count.
return seasonStart.id === anchor.id ? null : seasonStart;
}
/**
@@ -323,13 +421,31 @@ export function pickByAirOrder(
stripSeasonSuffix(candidate).includes(anchorBase),
);
});
if (franchise.length < season) return null;
// Drop the back halves of split-cour seasons, which are separate entries here
// but not separate seasons — counting them shifts every later season index.
// The comparison excludes the entry's own titles: a localized synonym often
// spells a season as "<season> Part 1", which would otherwise delete the very
// season it names.
const seasons = franchise.filter((entry) => {
if (entry.id === anchor.id) return true;
const otherKeys = new Set(
franchise
.filter((other) => other.id !== entry.id)
.flatMap((other) => mediaTitles(other).map((title) => titleMatchKey(title))),
);
return !mediaTitles(entry).some((title) => {
const part = splitPartMarker(title);
return part.hasPart && otherKeys.has(titleMatchKey(part.base));
});
});
if (seasons.length < season) return null;
// Ordering by air date is only meaningful when every entry has one: an unknown year
// would sort last and shift the season index while still reporting a resolved match.
if (franchise.some((entry) => airYear(entry) === null)) return null;
if (seasons.some((entry) => airYear(entry) === null)) return null;
const ordered = [...franchise].sort((a, b) => {
const ordered = [...seasons].sort((a, b) => {
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
if (yearDelta !== 0) return yearDelta;
return a.id - b.id;
@@ -386,6 +502,14 @@ export async function resolveAnilistSeasonMedia(
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
}
// The chain failed for transport reasons rather than because the season is absent, so
// nothing was learned about the franchise's shape. Surface it and let the caller retry:
// the air-order fallback is for a chain that answered and had no edge to follow, and
// running it here would turn a rate limit into a confident wrong season.
if (chainError) {
throw chainError;
}
const viaAirOrder = pickByAirOrder(anchor, season, media);
if (viaAirOrder) {
deps.logInfo?.(
@@ -394,12 +518,6 @@ export async function resolveAnilistSeasonMedia(
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
}
// The chain failed for transport reasons rather than because the season is absent;
// surface that so callers retry instead of reporting an unresolvable season.
if (chainError) {
throw chainError;
}
deps.logInfo?.(
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
);
@@ -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);
}
});
+116 -32
View File
@@ -320,6 +320,31 @@ export interface JellyfinPlaybackMetadataInput {
itemId: string;
}
/**
* An episode the anime browser is about to stream.
*
* Reported up front for the same reason the Jellyfin variant is: the stream URL
* mpv sees is a per-playback proxy address ending in `.m3u8`, so a session
* started from it alone lands in a series named after the file extension.
*/
export interface StreamPlaybackMetadataInput {
/** The URL handed to mpv; recorded as an alias of `statsPath`. */
mediaPath: string;
/** Stable per-episode identity. Survives the proxy port and token changing. */
statsPath: string;
displayTitle: string;
seriesTitle: string;
seasonNumber: number | null;
episodeNumber: number | null;
}
/**
* Parser sources that are recorded before playback starts. A video carrying one
* already has better metadata than filename guessing could produce, so the
* guess is skipped rather than allowed to overwrite it.
*/
const PREPLAYBACK_PARSER_SOURCES = new Set(['jellyfin', 'anime-browser']);
function normalizeMetadataInt(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
}
@@ -353,7 +378,7 @@ function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names:
}
}
function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] {
function buildMediaPathAliasCandidates(mediaPath: string): string[] {
const candidates = new Set<string>([mediaPath]);
try {
const parsed = new URL(mediaPath);
@@ -1251,15 +1276,11 @@ export class ImmersionTrackerService {
if (!rawPath) {
return;
}
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
this.mediaPathAliases.set(alias, normalizedPath);
}
const statsPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
const displayTitle =
normalizeText(metadata.displayTitle) ||
normalizeText(metadata.itemTitle) ||
deriveCanonicalTitle(normalizedPath);
deriveCanonicalTitle(statsPath);
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const libraryTitle = seriesTitle || itemTitle;
@@ -1269,47 +1290,110 @@ export class ImmersionTrackerService {
return;
}
this.recordPrePlaybackMetadata({
statsPath,
aliases: buildMediaPathAliasCandidates(rawPath),
displayTitle,
libraryTitle,
seasonNumber,
episodeNumber,
parserSource: 'jellyfin',
metadataJson: JSON.stringify({
source: 'jellyfin',
itemId: normalizeText(metadata.itemId) || null,
itemTitle,
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber,
episodeNumber,
}),
});
}
recordStreamPlaybackMetadata(metadata: StreamPlaybackMetadataInput): void {
const rawPath = normalizeMediaPath(metadata.mediaPath);
const statsPath = normalizeMediaPath(metadata.statsPath) || rawPath;
if (!statsPath) {
return;
}
const seriesTitle = normalizeText(metadata.seriesTitle);
const displayTitle =
normalizeText(metadata.displayTitle) || seriesTitle || deriveCanonicalTitle(statsPath);
const libraryTitle = seriesTitle || displayTitle;
if (!libraryTitle) {
return;
}
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
this.recordPrePlaybackMetadata({
statsPath,
aliases: rawPath ? buildMediaPathAliasCandidates(rawPath) : [],
displayTitle,
libraryTitle,
seasonNumber,
episodeNumber,
parserSource: 'anime-browser',
metadataJson: JSON.stringify({
source: 'anime-browser',
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber,
episodeNumber,
}),
});
}
/**
* Creates the video row and its series link ahead of playback, so the session
* mpv's path change starts already belongs to the right anime.
*/
private recordPrePlaybackMetadata(params: {
statsPath: string;
aliases: string[];
displayTitle: string;
libraryTitle: string;
seasonNumber: number | null;
episodeNumber: number | null;
parserSource: string;
metadataJson: string;
}): void {
for (const alias of params.aliases) {
this.mediaPathAliases.set(alias, params.statsPath);
}
const videoId = getOrCreateVideoRecord(
this.db,
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
buildVideoKey(params.statsPath, SOURCE_TYPE_REMOTE),
{
canonicalTitle: displayTitle,
canonicalTitle: params.displayTitle,
sourcePath: null,
sourceUrl: normalizedPath,
sourceUrl: params.statsPath,
sourceType: SOURCE_TYPE_REMOTE,
},
);
const previousLink = this.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
const metadataJson = JSON.stringify({
source: 'jellyfin',
itemId: normalizeText(metadata.itemId) || null,
itemTitle,
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber,
episodeNumber,
});
const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: libraryTitle,
canonicalTitle: libraryTitle,
seasonScope: seasonNumber,
parsedTitle: params.libraryTitle,
canonicalTitle: params.libraryTitle,
seasonScope: params.seasonNumber,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson,
metadataJson: params.metadataJson,
});
linkVideoToAnimeRecord(this.db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: libraryTitle,
parsedSeason: seasonNumber,
parsedEpisode: episodeNumber,
parserSource: 'jellyfin',
parsedTitle: params.libraryTitle,
parsedSeason: params.seasonNumber,
parsedEpisode: params.episodeNumber,
parserSource: params.parserSource,
parserConfidence: 1,
parseMetadataJson: metadataJson,
parseMetadataJson: params.metadataJson,
});
const hasLifetimeMedia = Boolean(
@@ -1320,17 +1404,17 @@ export class ImmersionTrackerService {
}
}
private hasJellyfinMetadata(videoId: number): boolean {
private hasPrePlaybackMetadata(videoId: number): boolean {
const row = this.db
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
.get(videoId) as { parserSource: string | null } | null;
return row?.parserSource === 'jellyfin';
return row?.parserSource !== null && PREPLAYBACK_PARSER_SOURCES.has(row?.parserSource ?? '');
}
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const rawPath = normalizeMediaPath(mediaPath);
const normalizedPath =
buildJellyfinMediaPathAliasCandidates(rawPath)
buildMediaPathAliasCandidates(rawPath)
.map((alias) => this.mediaPathAliases.get(alias))
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle);
@@ -1380,7 +1464,7 @@ export class ImmersionTrackerService {
if (youtubeVideoId) {
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
} else if (!this.hasPrePlaybackMetadata(sessionInfo.videoId)) {
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
}
if (!youtubeVideoId) {
@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
convertSubtitleForAlass,
formatCuesAsSrt,
needsAlassConversion,
parseTimedCues,
} from './subsync-alass-input';
const VTT = [
'WEBVTT',
'',
'NOTE this comment is not a cue',
'',
'cue-1',
'00:00:01.500 --> 00:00:03.250 line:90% align:center',
'<v Geese>しっかし',
'まさかお前が',
'',
'00:01:02.000 --> 00:01:04.000',
'ゼニスも きっと幸せじゃろう',
'',
].join('\n');
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'alass-input-test-'));
}
test('parseTimedCues keeps cue payloads verbatim and skips headers', () => {
const cues = parseTimedCues(VTT);
assert.equal(cues.length, 2);
assert.equal(cues[0]!.start, 1.5);
assert.equal(cues[0]!.end, 3.25);
assert.equal(cues[0]!.text, '<v Geese>しっかし\nまさかお前が');
assert.equal(cues[1]!.start, 62);
assert.equal(cues[1]!.text, 'ゼニスも きっと幸せじゃろう');
});
test('parseTimedCues reads VTT timestamps without an hours field', () => {
const cues = parseTimedCues('WEBVTT\n\n01:02.000 --> 01:03.000\nhello\n');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.start, 62);
assert.equal(cues[0]!.end, 63);
});
test('formatCuesAsSrt writes numbered SubRip blocks', () => {
assert.equal(
formatCuesAsSrt(parseTimedCues(VTT)),
'1\n00:00:01,500 --> 00:00:03,250\n<v Geese>しっかし\nまさかお前が\n\n' +
'2\n00:01:02,000 --> 00:01:04,000\nゼニスも きっと幸せじゃろう\n',
);
});
test('needsAlassConversion targets VTT by extension and by content', () => {
assert.equal(needsAlassConversion('/tmp/track-0.vtt', 'WEBVTT\n'), true);
assert.equal(needsAlassConversion('/tmp/track-0.srt', 'WEBVTT\n'), true);
assert.equal(needsAlassConversion('/tmp/track-0.ttml', '<?xml version="1.0"?>'), true);
assert.equal(
needsAlassConversion('/tmp/track-0.srt', '1\n00:00:01,000 --> 00:00:02,000\n'),
false,
);
assert.equal(needsAlassConversion('/tmp/track-0.ass', '[Script Info]\n'), false);
});
test('convertSubtitleForAlass rewrites a VTT track as SRT', () => {
const dir = makeTempDir();
const source = path.join(dir, 'track-0.vtt');
fs.writeFileSync(source, VTT);
const converted = convertSubtitleForAlass(source);
assert.notEqual(converted, null);
assert.equal(converted!.temporary, true);
assert.equal(path.extname(converted!.path), '.srt');
assert.match(fs.readFileSync(converted!.path, 'utf8'), /00:00:01,500 --> 00:00:03,250/);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.dirname(converted!.path), { recursive: true, force: true });
});
test('convertSubtitleForAlass converts a VTT payload hiding behind an .srt name', () => {
const dir = makeTempDir();
const source = path.join(dir, 'remote_track.srt');
fs.writeFileSync(source, VTT);
const converted = convertSubtitleForAlass(source);
assert.notEqual(converted, null);
assert.notEqual(converted!.path, source);
assert.equal(fs.readFileSync(converted!.path, 'utf8').startsWith('1\n'), true);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.dirname(converted!.path), { recursive: true, force: true });
});
test('convertSubtitleForAlass leaves an SRT file alone', () => {
const dir = makeTempDir();
const source = path.join(dir, 'track-0.srt');
fs.writeFileSync(source, '1\n00:00:01,000 --> 00:00:02,000\nhello\n');
assert.equal(convertSubtitleForAlass(source), null);
fs.rmSync(dir, { recursive: true, force: true });
});
test('convertSubtitleForAlass reports a file with no readable timings', () => {
const dir = makeTempDir();
const source = path.join(dir, 'track-0.vtt');
fs.writeFileSync(source, 'WEBVTT\n\nnot a cue at all\n');
assert.throws(() => convertSubtitleForAlass(source), /Could not read subtitle timings/);
fs.rmSync(dir, { recursive: true, force: true });
});
+155
View File
@@ -0,0 +1,155 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { FileExtractionResult } from './subsync-extract';
import { createLogger } from '../../logger';
const logger = createLogger('subsync');
/**
* alass picks its parser purely from the file extension, and WebVTT is not one
* of the formats it knows. A `.vtt` reference is therefore treated as a *video*
* ("no audio stream in file"), and the same file renamed to `.srt` dies in the
* SubRip parser on the `WEBVTT` header. Extension-backed anime streams hand out
* VTT almost exclusively, so every alass input is converted to SRT first.
*/
const ALASS_SUBTITLE_EXTENSIONS = new Set(['srt', 'ass', 'ssa', 'sub', 'idx']);
/** How much of the file is decoded to recognise a WebVTT header. */
const SNIFF_BYTES = 1024;
const CUE_TIMING_PATTERN =
/^\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/;
interface RawCue {
start: number;
end: number;
text: string;
}
function toSeconds(
hours: string | undefined,
minutes: string,
seconds: string,
millis: string,
): number {
return (
Number(hours ?? 0) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(millis.padEnd(3, '0')) / 1000
);
}
function formatTimestamp(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const millis = Math.round(clamped * 1000);
const hours = Math.floor(millis / 3_600_000);
const minutes = Math.floor((millis % 3_600_000) / 60_000);
const seconds = Math.floor((millis % 60_000) / 1000);
const remainder = millis % 1000;
const pad = (value: number, width: number): string => String(value).padStart(width, '0');
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(remainder, 3)}`;
}
/**
* Read timed cues out of a WebVTT (or SRT-shaped) file.
*
* The payload is kept verbatim rather than sanitised: this same file is what
* alass retimes and mpv then loads, so stripping inline tags here would show up
* on screen. Cue identifiers, `NOTE`/`STYLE`/`REGION` blocks and the `WEBVTT`
* header all fall out for free — only lines that follow a timing line are kept.
*/
export function parseTimedCues(content: string): RawCue[] {
const lines = content.replace(/^/, '').split(/\r?\n/);
const cues: RawCue[] = [];
for (let index = 0; index < lines.length; index += 1) {
const timing = CUE_TIMING_PATTERN.exec(lines[index]!);
if (!timing) continue;
const start = toSeconds(timing[1], timing[2]!, timing[3]!, timing[4]!);
const end = toSeconds(timing[5], timing[6]!, timing[7]!, timing[8]!);
const textLines: string[] = [];
index += 1;
while (index < lines.length && lines[index]!.trim().length > 0) {
textLines.push(lines[index]!);
index += 1;
}
const text = textLines.join('\n').trim();
if (text.length > 0) {
cues.push({ start, end, text });
}
}
return cues;
}
export function formatCuesAsSrt(cues: RawCue[]): string {
return cues
.map(
(cue, index) =>
`${index + 1}\n${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}\n`,
)
.join('\n');
}
function readHead(filePath: string): string {
const handle = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(SNIFF_BYTES);
const bytesRead = fs.readSync(handle, buffer, 0, SNIFF_BYTES, 0);
return buffer.subarray(0, bytesRead).toString('utf8');
} finally {
fs.closeSync(handle);
}
}
function isWebVttContent(head: string): boolean {
return /^WEBVTT(\s|$)/.test(head.replace(/^/, '').trimStart());
}
/**
* Decide whether alass can read the file as-is.
*
* Content wins over the extension in one direction only: a file alass would
* accept by extension still needs converting when it turns out to hold VTT,
* while an unrecognised extension is always converted. Anything else is passed
* through untouched, which also keeps its original charset intact — alass
* detects encodings that a UTF-8 round trip here would mangle.
*/
export function needsAlassConversion(filePath: string, head: string): boolean {
const extension = path.extname(filePath).slice(1).toLowerCase();
if (!ALASS_SUBTITLE_EXTENSIONS.has(extension)) return true;
return isWebVttContent(head);
}
/**
* Rewrite a subtitle file as SRT for alass, or return null when alass can
* already read it. The result is a temporary file in its own directory, so
* `cleanupTemporaryFile` can remove it (and keep the retimed output beside it).
*/
export function convertSubtitleForAlass(filePath: string): FileExtractionResult | null {
if (!needsAlassConversion(filePath, readHead(filePath))) return null;
const cues = parseTimedCues(fs.readFileSync(filePath, 'utf8'));
if (cues.length === 0) {
throw new Error(`Could not read subtitle timings for alass from ${path.basename(filePath)}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-alass-'));
const outputPath = path.join(tempDir, `${path.parse(filePath).name}.srt`);
try {
fs.writeFileSync(outputPath, formatCuesAsSrt(cues), 'utf8');
} catch (error) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
throw error;
}
logger.info(`Converted ${filePath} to SRT for alass: ${outputPath} (${cues.length} cues)`);
return { path: outputPath, temporary: true };
}
+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');
+30 -5
View File
@@ -21,6 +21,7 @@ import {
extractSubtitleTrackToFile,
FileExtractionResult,
} from './subsync-extract';
import { convertSubtitleForAlass } from './subsync-alass-input';
import { resolveMpvHttpHeaders, ResolvedMpvHttpHeaders } from './mpv-http-headers';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
@@ -299,23 +300,45 @@ async function subsyncToReference(
track: targetTrack,
httpHeaders,
});
const replaceTarget = resolved.replace !== false && !targetExtraction.temporary;
const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget);
// alass reads neither the target nor the reference as WebVTT, so both are
// rewritten as SRT first; ffsubsync parses VTT itself. A video reference is
// left alone: alass reads its audio, and there are no cues to convert.
const referenceIsVideo = referenceFilePath === context.videoPath;
let convertedTarget: FileExtractionResult | null = null;
let convertedReference: FileExtractionResult | null = null;
if (engine === 'alass') {
try {
convertedTarget = convertSubtitleForAlass(targetExtraction.path);
convertedReference = referenceIsVideo ? null : convertSubtitleForAlass(referenceFilePath);
} catch (error) {
if (convertedTarget) cleanupTemporaryFile(convertedTarget);
cleanupTemporaryFile(targetExtraction);
const message = `alass synchronization failed: ${(error as Error).message}`;
logger.error(message);
return { ok: false, message };
}
}
const target = convertedTarget ?? targetExtraction;
const referencePath = convertedReference?.path ?? referenceFilePath;
const replaceTarget = resolved.replace !== false && !target.temporary;
const outputPath = buildRetimedPath(target.path, replaceTarget);
logger.info(
`Running ${engine}: target=${targetExtraction.path} reference=${referenceFilePath} output=${outputPath}`,
`Running ${engine}: target=${target.path} reference=${referencePath} output=${outputPath}`,
);
try {
let result: CommandResult;
if (engine === 'alass') {
const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath);
result = await runAlassSync(alassPath, referencePath, target.path, outputPath);
} else {
const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
targetExtraction.path,
target.path,
outputPath,
context.audioStreamIndex,
);
@@ -337,6 +360,8 @@ async function subsyncToReference(
message: `Subtitle synchronized with ${engine}`,
};
} finally {
if (convertedReference) cleanupTemporaryFile(convertedReference);
if (convertedTarget) cleanupTemporaryFile(convertedTarget, outputPath);
cleanupTemporaryFile(targetExtraction, outputPath);
}
}
+29 -4
View File
@@ -4,6 +4,7 @@ import * as path from 'path';
import * as fs from 'fs';
import * as childProcess from 'child_process';
import { createLogger } from '../logger';
import { splitSeasonFromTitle } from '../anime-bridge/episode-metadata';
import {
JimakuApiResponse,
JimakuConfig,
@@ -163,6 +164,28 @@ function matchEpisodeFromName(name: string): {
};
}
// Spelled-out labels. Streaming sources name episodes this way ("Episode 4"),
// and the abbreviated `E04` pattern below cannot see them.
const worded = name.match(/(?:^|[\s._-])episode\s*[.#]?\s*(\d{1,3})(?:\D|$)/i);
if (worded && worded.index !== undefined) {
return {
season: null,
episode: Number.parseInt(worded[1]!, 10),
index: worded.index,
confidence: 'medium',
};
}
const japanese = name.match(/第\s*(\d{1,3})\s*話/);
if (japanese && japanese.index !== undefined) {
return {
season: null,
episode: Number.parseInt(japanese[1]!, 10),
index: japanese.index,
confidence: 'medium',
};
}
const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
if (epOnly && epOnly.index !== undefined) {
return {
@@ -229,12 +252,14 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
titlePart = name.slice(0, parsed.index);
}
const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath);
const title = cleanupTitle(titlePart || name);
// A season named in the title ("… Season 3") belongs in the season field, not
// glued to the title — otherwise it is searched for as part of the name.
const titleSeason = splitSeasonFromTitle(cleanupTitle(titlePart || name));
const season = parsed.season ?? titleSeason.season ?? detectSeasonFromDir(normalizedMediaPath);
return {
title,
season: seasonFromDir,
title: titleSeason.title,
season,
episode: parsed.episode,
confidence: parsed.confidence,
filename,
+58 -4
View File
@@ -419,6 +419,7 @@ import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update
import {
guessAnilistMediaInfo,
updateAnilistPostWatchProgress,
type AnilistMediaGuess,
} from './core/services/anilist/anilist-updater';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
@@ -483,6 +484,11 @@ import {
getJlptDictionarySearchPaths,
} from './main/jlpt-runtime';
import { createMediaRuntimeService } from './main/media-runtime';
import {
createStreamPlaybackMetadataStore,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './main/runtime/stream-playback-metadata';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
@@ -2385,6 +2391,31 @@ const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroup
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
/**
* What the anime browser resolved for the stream that is playing. Consulted by
* everything that would otherwise have to parse the mpv title, or worse the
* stream URL, which names only the proxy and the container format.
*/
const streamPlaybackMetadata = createStreamPlaybackMetadataStore();
/** The stream metadata for whatever mpv currently has open, if it is a stream. */
function getActiveStreamMetadata() {
return streamPlaybackMetadata.match(appState.currentMediaPath);
}
/**
* The AniList guess for what is playing. A stream answers from the fields its
* source reported; everything else goes through the usual title parsers.
*/
async function guessAnilistMediaInfoForCurrentMedia(
mediaPath: string | null,
mediaTitle: string | null,
): Promise<AnilistMediaGuess | null> {
const stream = getActiveStreamMetadata();
const streamGuess = stream ? toAnilistMediaGuess(stream) : null;
return streamGuess ?? guessAnilistMediaInfo(mediaPath, mediaTitle);
}
const mediaRuntime = createMediaRuntimeService(
createBuildMediaRuntimeMainDepsHandler({
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath),
@@ -2417,7 +2448,8 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
getNameMatchImagesEnabled: () => configService.getConfig().subtitleStyle.nameMatchImagesEnabled,
getCollapsibleSectionOpenState: (section) =>
configService.getConfig().anilist.characterDictionary.collapsibleSections[section],
@@ -3252,6 +3284,22 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
},
showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
// Set before mpv reports the path change, so the session that change starts
// is titled and grouped from the source's own listing rather than from the
// proxy URL, whose only readable part is the `.m3u8` extension.
mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle);
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordStreamPlaybackMetadata({
mediaPath: metadata.mediaPath,
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
});
},
onBridgeState: (state) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
@@ -3708,7 +3756,8 @@ const {
mediaRuntime.resolveMediaPathForJimaku(currentMediaPath),
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
},
processNextRetryUpdateMainDeps: {
nextReady: () => anilistUpdateQueue.nextReady(),
@@ -5860,8 +5909,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getFieldGroupingResolver: () => getFieldGroupingResolverHandler(),
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
setFieldGroupingResolverHandler(resolver),
parseMediaInfo: (mediaPath: string | null) =>
parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)),
parseMediaInfo: (mediaPath: string | null) => {
// A stream already knows its series, season and episode; parsing the
// title back out of a string would only lose what the source told us.
const stream = getActiveStreamMetadata();
if (stream) return toJimakuMediaInfo(stream);
return parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath));
},
getCurrentMediaPath: () => appState.currentMediaPath,
jimakuFetchJson: <T>(
endpoint: string,
+24 -1
View File
@@ -18,6 +18,10 @@ import {
buildTrackCommands,
selectPreferredStream,
} from '../../anime-bridge/mpv-playback';
import {
buildAnimeStreamMetadata,
type AnimeStreamMetadata,
} from '../../anime-bridge/episode-metadata';
import {
listExtensionSources,
readInstalledExtensions,
@@ -87,6 +91,13 @@ export interface AnimeBrowserRuntimeDeps {
readMpvProperty?: (name: string) => Promise<unknown>;
showMpvOsd?: (message: string) => void;
showVisibleOverlay?: () => void;
/**
* Publishes what is about to play. Called *before* `loadfile` so the title
* and the episode's identity are already known when mpv reports the path
* change — otherwise stats sees only the proxy URL and groups every stream
* under its file extension.
*/
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
/** Lets tests drive the pause between `loadfile` and the track commands. */
wait?: (ms: number) => Promise<void>;
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
@@ -616,7 +627,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
: null;
try {
const title = `${request.animeTitle}${request.episodeName}`;
const metadata = buildAnimeStreamMetadata({
sourceId: request.sourceId,
animeUrl: request.animeUrl,
animeTitle: request.animeTitle,
episodeUrl: request.episodeUrl,
episodeName: request.episodeName,
episodeNumber: request.episodeNumber ?? null,
mediaPath: stream.url,
});
const title = metadata.displayTitle;
// Before loadfile: mpv's path change is what starts a stats session,
// and it must find this already recorded.
deps.onPlaybackMetadata?.(metadata);
for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command);
}
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createStreamPlaybackMetadataStore,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './stream-playback-metadata';
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMetadata {
return {
mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
seasonNumber: 3,
episodeNumber: 4,
episodeTitle: null,
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
...overrides,
};
}
test('the store answers for the stream URL and for the stats path', () => {
const store = createStreamPlaybackMetadataStore();
const current = metadata();
store.set(current);
assert.equal(store.match(current.mediaPath), current);
assert.equal(store.match(current.statsPath), current);
assert.equal(store.match(` ${current.mediaPath} `), current);
});
test('the store stops answering once the player moves on', () => {
const store = createStreamPlaybackMetadataStore();
store.set(metadata());
assert.equal(store.match('/home/user/Videos/local.mkv'), null);
assert.equal(store.match(null), null);
assert.equal(store.match(''), null);
store.clear();
assert.equal(store.match(metadata().mediaPath), null);
});
test('toJimakuMediaInfo prefills the modals with the source-reported fields', () => {
assert.deepEqual(toJimakuMediaInfo(metadata()), {
title: 'Mushoku Tensei: Jobless Reincarnation',
season: 3,
episode: 4,
confidence: 'high',
filename: 'Mushoku Tensei: Jobless Reincarnation S03E04',
rawTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
});
});
test('toJimakuMediaInfo drops to low confidence when there is no episode', () => {
const info = toJimakuMediaInfo(metadata({ episodeNumber: null, seasonNumber: null }));
assert.equal(info.episode, null);
assert.equal(info.confidence, 'low');
assert.equal(info.title, 'Mushoku Tensei: Jobless Reincarnation');
});
test('toAnilistMediaGuess reports the stream fields verbatim', () => {
assert.deepEqual(toAnilistMediaGuess(metadata()), {
title: 'Mushoku Tensei: Jobless Reincarnation',
season: 3,
episode: 4,
source: 'stream',
});
});
test('toAnilistMediaGuess declines a special so the caller can fall back', () => {
// AniList progress counts whole episodes; a 6.5 special is not one of them.
assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: 6.5 })), null);
assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: null })), null);
assert.equal(toAnilistMediaGuess(metadata({ seriesTitle: '' })), null);
});
@@ -0,0 +1,78 @@
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import type { JimakuMediaInfo } from '../../types';
/**
* Holds what the anime browser resolved for the episode currently streaming.
*
* Consumers otherwise have to re-derive the series and episode from the mpv
* title, and some of them only ever see the stream URL, which carries no title
* at all. Keeping the resolved fields lets each of them ask instead of guess.
*/
export interface StreamPlaybackMetadataStore {
set: (metadata: AnimeStreamMetadata) => void;
clear: () => void;
/**
* The metadata for `mediaPath`, or null when the player has moved on to
* something else. Matching on the path is what makes this self-expiring —
* there is no teardown hook to miss.
*/
match: (mediaPath: string | null) => AnimeStreamMetadata | null;
}
export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore {
let current: AnimeStreamMetadata | null = null;
return {
set(metadata: AnimeStreamMetadata): void {
current = metadata;
},
clear(): void {
current = null;
},
match(mediaPath: string | null): AnimeStreamMetadata | null {
if (!current) return null;
const trimmed = typeof mediaPath === 'string' ? mediaPath.trim() : '';
if (!trimmed) return null;
// The stats path is accepted too: stats rewrites the volatile stream URL
// to it, so callers reading from there still resolve.
return trimmed === current.mediaPath || trimmed === current.statsPath ? current : null;
},
};
}
/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */
function wholeEpisode(episode: number | null): number | null {
return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null;
}
/**
* The subtitle modals' prefill. `high` confidence is honest here: these fields
* came from the source's own listing rather than from a filename guess, so the
* modals may search on them without waiting for the user to confirm.
*/
export function toJimakuMediaInfo(metadata: AnimeStreamMetadata): JimakuMediaInfo {
return {
title: metadata.seriesTitle,
season: metadata.seasonNumber,
episode: wholeEpisode(metadata.episodeNumber),
confidence: metadata.episodeNumber !== null ? 'high' : 'low',
filename: metadata.displayTitle,
rawTitle: metadata.displayTitle,
};
}
/**
* The AniList guess, or null when there is no episode to report — in which case
* the caller should fall back to parsing the title as usual.
*/
export function toAnilistMediaGuess(metadata: AnimeStreamMetadata): AnilistMediaGuess | null {
const episode = wholeEpisode(metadata.episodeNumber);
if (!metadata.seriesTitle || episode === null) return null;
return {
title: metadata.seriesTitle,
season: metadata.seasonNumber,
episode,
source: 'stream',
};
}
+4
View File
@@ -148,6 +148,10 @@
<span>Title</span>
<input id="tsukihimeTitle" type="text" placeholder="Anime title" />
</label>
<label class="jimaku-field">
<span>Season</span>
<input id="tsukihimeSeason" type="number" min="1" placeholder="1" />
</label>
<label class="jimaku-field">
<span>Episode</span>
<input id="tsukihimeEpisode" type="number" min="1" placeholder="1" />
+1
View File
@@ -188,6 +188,7 @@ function createModalHarness(
setAttribute: () => {},
},
tsukihimeTitleInput: { value: '' },
tsukihimeSeasonInput: { value: '' },
tsukihimeEpisodeInput: { value: '' },
tsukihimeSearchButton: { addEventListener: () => {} },
tsukihimeCloseButton: { addEventListener: () => {} },
+18 -6
View File
@@ -191,16 +191,27 @@ export function createTsukihimeModal(
});
}
function readNumericInput(input: HTMLInputElement): number | null {
if (!input.value) return null;
const parsed = Number.parseInt(input.value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getSearchQuery(): string {
const title = ctx.dom.tsukihimeTitleInput.value.trim();
if (!title) return '';
const episodeValue = ctx.dom.tsukihimeEpisodeInput.value
? Number.parseInt(ctx.dom.tsukihimeEpisodeInput.value, 10)
: null;
if (episodeValue !== null && Number.isFinite(episodeValue)) {
return `${title} ${String(episodeValue).padStart(2, '0')}`;
const season = readNumericInput(ctx.dom.tsukihimeSeasonInput);
const episode = readNumericInput(ctx.dom.tsukihimeEpisodeInput);
// Season 1 is left out on purpose: releases of a first season almost never
// put "S01" in the name, so adding it narrows the search to nothing.
const parts = [title];
if (season !== null && season > 1) {
parts.push(`S${String(season).padStart(2, '0')}`);
}
return title;
if (episode !== null) {
parts.push(String(episode).padStart(2, '0'));
}
return parts.join(' ');
}
async function performTsukihimeSearch(): Promise<void> {
@@ -369,6 +380,7 @@ export function createTsukihimeModal(
.getJimakuMediaInfo()
.then(async (info: JimakuMediaInfo) => {
ctx.dom.tsukihimeTitleInput.value = info.title || '';
ctx.dom.tsukihimeSeasonInput.value = info.season ? String(info.season) : '';
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
if (info.confidence === 'high' && info.title && info.episode) {
+2
View File
@@ -24,6 +24,7 @@ export type RendererDom = {
tsukihimeModal: HTMLDivElement;
tsukihimeTitleInput: HTMLInputElement;
tsukihimeSeasonInput: HTMLInputElement;
tsukihimeEpisodeInput: HTMLInputElement;
tsukihimeSearchButton: HTMLButtonElement;
tsukihimeCloseButton: HTMLButtonElement;
@@ -171,6 +172,7 @@ export function resolveRendererDom(): RendererDom {
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
tsukihimeSeasonInput: getRequiredElement<HTMLInputElement>('tsukihimeSeason'),
tsukihimeEpisodeInput: getRequiredElement<HTMLInputElement>('tsukihimeEpisode'),
tsukihimeSearchButton: getRequiredElement<HTMLButtonElement>('tsukihimeSearch'),
tsukihimeCloseButton: getRequiredElement<HTMLButtonElement>('tsukihimeClose'),
+5
View File
@@ -166,6 +166,11 @@ export interface AnimeBrowserPlayRequest {
animeTitle: string;
episodeUrl: string;
episodeName: string;
/**
* The episode's number as the source reported it. Carried rather than
* re-parsed out of `episodeName`, which is free-form and often lacks one.
*/
episodeNumber: number | null;
}
export interface AnimeBrowserPlayResult {