mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -07:00
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:
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user