mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 07:21:33 -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:
@@ -12,7 +12,14 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Split-Cour Season Resolution: Resolving "season N" against AniList no longer lands one season short for franchises whose broadcast seasons are listed as several entries. AniList records the back half of a split cour ("… Season 2 Part 2", "… Cour 2", "第2クール") as its own sequel, and the resolver counted each as a season — so Mushoku Tensei season 3 resolved to the season 2 entry and Re:ZERO season 4 to season 3, which then drove the character dictionary and AniList progress updates to the wrong series. A sequel carrying a part or cour marker that matches the season it continues is now followed without advancing the season count, in both the relation walk and the air-order fallback. Titles are compared ignoring punctuation, since AniList spells the same name inconsistently across a franchise's own entries, and the marker is looked for in every title and synonym rather than just the display title. Note that a wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs to be removed (or overridden from the character dictionary picker) for the affected series.
|
||||||
|
- Specials In The Season Chain: A special or OVA sitting in a franchise's sequel chain is now walked through without counting as a season. Dr. STONE links STONE WARS to New World through a one-episode special, which made New World resolve as season 4 and every later season shift with it.
|
||||||
|
- Season Resolution Under Rate Limiting: An AniList rate limit or network failure while walking sequel relations is now reported so the caller can retry, instead of falling through to the air-date fallback. That fallback is for a franchise with missing relation edges; running it after a failed lookup turned a transient 429 into a confidently wrong season.
|
||||||
|
- Anime Browser Titles: An episode launched from the anime browser now carries its series, season and episode number through the app instead of a single joined string. Stats groups streams by series (every stream previously landed in one entry named `m3u8`, because the only readable part of the strip-proxy URL was its file extension), rewatching an episode reuses its entry rather than creating a new one, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing instead of leaving them blank, and AniList updates use those fields directly. The mpv title now reads `Series S03E04 - Episode Name`.
|
||||||
|
- TsukiHime Season: The TsukiHime modal has a Season field, matching Jimaku. It is filled in automatically for anime browser streams, and seasons after the first are included in the search query so a later season's releases are actually found.
|
||||||
|
- Episode Parsing: The shared title parser now recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named in the title (`… Season 3`, `… 2nd Season`, `… S3`), which are put in the Season field instead of being searched for as part of the series name.
|
||||||
- Subtitle Sync On Streams: Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. Such a track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can now be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
|
- Subtitle Sync On Streams: Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. Such a track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can now be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
|
||||||
|
- Subsync With WebVTT Tracks: Alass now works on tracks that arrive as WebVTT, which is what Aniyomi extension streams serve. Alass picks its parser from the file extension and has no WebVTT support, so it treated a `.vtt` reference as a video file and failed with "no audio stream in file" (and a VTT saved under an `.srt` name died in its SubRip parser instead). Both the sync target and the reference are now rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched.
|
||||||
- Subsync Tool Discovery: Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
|
- Subsync Tool Discovery: Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
|
||||||
- Subsync Logging: Subsync failures are now written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
|
- Subsync Logging: Subsync failures are now written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
|
||||||
- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
|
- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first,
|
|||||||
|
|
||||||
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
|
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
|
||||||
|
|
||||||
|
Streams usually serve WebVTT, which alass cannot parse — it picks its parser from the file extension and treats a `.vtt` file as a video, failing with "no audio stream in file". SubMiner rewrites both the target and the reference as SRT for alass, so this is handled automatically; the retimed track mpv loads is that SRT.
|
||||||
|
|
||||||
## TsukiHime
|
## TsukiHime
|
||||||
|
|
||||||
**"xz binary not found"**
|
**"xz binary not found"**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -333,6 +333,7 @@ async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpiso
|
|||||||
animeTitle: selectedAnime.title,
|
animeTitle: selectedAnime.title,
|
||||||
episodeUrl: episode.url,
|
episodeUrl: episode.url,
|
||||||
episodeName: episode.name,
|
episodeName: episode.name,
|
||||||
|
episodeNumber: episode.number,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export interface AnilistMediaGuess {
|
|||||||
year?: number;
|
year?: number;
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episode: 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 {
|
export interface AnilistPostWatchUpdateResult {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
guessAnilistMediaInfo,
|
guessAnilistMediaInfo,
|
||||||
runGuessit,
|
runGuessit,
|
||||||
|
type AnilistMediaGuess,
|
||||||
type GuessAnilistMediaInfoDeps,
|
type GuessAnilistMediaInfoDeps,
|
||||||
} from './anilist-updater';
|
} from './anilist-updater';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,7 @@ interface Logger {
|
|||||||
|
|
||||||
interface CoverArtCandidate {
|
interface CoverArtCandidate {
|
||||||
title: string;
|
title: string;
|
||||||
source: 'guessit' | 'fallback';
|
source: AnilistMediaGuess['source'];
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ANILIST_SEASON_SEARCH_QUERY,
|
ANILIST_SEASON_SEARCH_QUERY,
|
||||||
pickByAirOrder,
|
pickByAirOrder,
|
||||||
resolveAnilistSeasonMedia,
|
resolveAnilistSeasonMedia,
|
||||||
|
splitPartMarker,
|
||||||
stripSeasonSuffix,
|
stripSeasonSuffix,
|
||||||
type AnilistSeasonMedia,
|
type AnilistSeasonMedia,
|
||||||
} from './season-resolver';
|
} 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?.seasonResolved, false);
|
||||||
assert.equal(result?.via, 'anchor');
|
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;
|
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 {
|
function normalizeTitle(value: string): string {
|
||||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
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,
|
* 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".
|
* 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();
|
.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[] {
|
function mediaTitles(media: AnilistSeasonMedia): string[] {
|
||||||
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
||||||
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
||||||
@@ -250,16 +329,21 @@ async function walkSequelChain(
|
|||||||
season: number,
|
season: number,
|
||||||
deps: ResolveAnilistSeasonMediaDeps,
|
deps: ResolveAnilistSeasonMediaDeps,
|
||||||
): Promise<AnilistSeasonMedia | null> {
|
): 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.
|
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
|
||||||
const hops = season - 1;
|
const targetAdvances = season - 1;
|
||||||
if (hops > MAX_SEQUEL_HOPS) {
|
if (targetAdvances > MAX_SEQUEL_HOPS) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const visited = new Set<number>([anchor.id]);
|
const visited = new Set<number>([anchor.id]);
|
||||||
let current = anchor;
|
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".
|
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
|
||||||
const response = await deps.execute<AnilistSeasonRelationsResponse>(
|
const response = await deps.execute<AnilistSeasonRelationsResponse>(
|
||||||
ANILIST_SEASON_RELATIONS_QUERY,
|
ANILIST_SEASON_RELATIONS_QUERY,
|
||||||
@@ -296,11 +380,25 @@ async function walkSequelChain(
|
|||||||
return a.id - b.id;
|
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);
|
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),
|
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
|
// 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.
|
// 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);
|
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
|
||||||
if (yearDelta !== 0) return yearDelta;
|
if (yearDelta !== 0) return yearDelta;
|
||||||
return a.id - b.id;
|
return a.id - b.id;
|
||||||
@@ -386,6 +502,14 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
|
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);
|
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||||
if (viaAirOrder) {
|
if (viaAirOrder) {
|
||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
@@ -394,12 +518,6 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
|
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?.(
|
deps.logInfo?.(
|
||||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
`[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);
|
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;
|
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 {
|
function normalizeMetadataInt(value: number | null | undefined): number | null {
|
||||||
return typeof value === 'number' && Number.isSafeInteger(value) ? value : 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]);
|
const candidates = new Set<string>([mediaPath]);
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(mediaPath);
|
const parsed = new URL(mediaPath);
|
||||||
@@ -1251,15 +1276,11 @@ export class ImmersionTrackerService {
|
|||||||
if (!rawPath) {
|
if (!rawPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
const statsPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||||
for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
|
|
||||||
this.mediaPathAliases.set(alias, normalizedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayTitle =
|
const displayTitle =
|
||||||
normalizeText(metadata.displayTitle) ||
|
normalizeText(metadata.displayTitle) ||
|
||||||
normalizeText(metadata.itemTitle) ||
|
normalizeText(metadata.itemTitle) ||
|
||||||
deriveCanonicalTitle(normalizedPath);
|
deriveCanonicalTitle(statsPath);
|
||||||
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
||||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||||
const libraryTitle = seriesTitle || itemTitle;
|
const libraryTitle = seriesTitle || itemTitle;
|
||||||
@@ -1269,47 +1290,110 @@ export class ImmersionTrackerService {
|
|||||||
return;
|
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(
|
const videoId = getOrCreateVideoRecord(
|
||||||
this.db,
|
this.db,
|
||||||
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
|
buildVideoKey(params.statsPath, SOURCE_TYPE_REMOTE),
|
||||||
{
|
{
|
||||||
canonicalTitle: displayTitle,
|
canonicalTitle: params.displayTitle,
|
||||||
sourcePath: null,
|
sourcePath: null,
|
||||||
sourceUrl: normalizedPath,
|
sourceUrl: params.statsPath,
|
||||||
sourceType: SOURCE_TYPE_REMOTE,
|
sourceType: SOURCE_TYPE_REMOTE,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const previousLink = this.db
|
const previousLink = this.db
|
||||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||||
.get(videoId) as { animeId: number | null } | null;
|
.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, {
|
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||||
parsedTitle: libraryTitle,
|
parsedTitle: params.libraryTitle,
|
||||||
canonicalTitle: libraryTitle,
|
canonicalTitle: params.libraryTitle,
|
||||||
seasonScope: seasonNumber,
|
seasonScope: params.seasonNumber,
|
||||||
anilistId: null,
|
anilistId: null,
|
||||||
titleRomaji: null,
|
titleRomaji: null,
|
||||||
titleEnglish: null,
|
titleEnglish: null,
|
||||||
titleNative: null,
|
titleNative: null,
|
||||||
metadataJson,
|
metadataJson: params.metadataJson,
|
||||||
});
|
});
|
||||||
linkVideoToAnimeRecord(this.db, videoId, {
|
linkVideoToAnimeRecord(this.db, videoId, {
|
||||||
animeId,
|
animeId,
|
||||||
parsedBasename: null,
|
parsedBasename: null,
|
||||||
parsedTitle: libraryTitle,
|
parsedTitle: params.libraryTitle,
|
||||||
parsedSeason: seasonNumber,
|
parsedSeason: params.seasonNumber,
|
||||||
parsedEpisode: episodeNumber,
|
parsedEpisode: params.episodeNumber,
|
||||||
parserSource: 'jellyfin',
|
parserSource: params.parserSource,
|
||||||
parserConfidence: 1,
|
parserConfidence: 1,
|
||||||
parseMetadataJson: metadataJson,
|
parseMetadataJson: params.metadataJson,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasLifetimeMedia = Boolean(
|
const hasLifetimeMedia = Boolean(
|
||||||
@@ -1320,17 +1404,17 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasJellyfinMetadata(videoId: number): boolean {
|
private hasPrePlaybackMetadata(videoId: number): boolean {
|
||||||
const row = this.db
|
const row = this.db
|
||||||
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
|
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
|
||||||
.get(videoId) as { parserSource: string | null } | null;
|
.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 {
|
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||||
const rawPath = normalizeMediaPath(mediaPath);
|
const rawPath = normalizeMediaPath(mediaPath);
|
||||||
const normalizedPath =
|
const normalizedPath =
|
||||||
buildJellyfinMediaPathAliasCandidates(rawPath)
|
buildMediaPathAliasCandidates(rawPath)
|
||||||
.map((alias) => this.mediaPathAliases.get(alias))
|
.map((alias) => this.mediaPathAliases.get(alias))
|
||||||
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
|
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
|
||||||
const normalizedTitle = normalizeText(mediaTitle);
|
const normalizedTitle = normalizeText(mediaTitle);
|
||||||
@@ -1380,7 +1464,7 @@ export class ImmersionTrackerService {
|
|||||||
if (youtubeVideoId) {
|
if (youtubeVideoId) {
|
||||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||||
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
} else if (!this.hasPrePlaybackMetadata(sessionInfo.videoId)) {
|
||||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||||
}
|
}
|
||||||
if (!youtubeVideoId) {
|
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 };
|
||||||
|
}
|
||||||
@@ -528,6 +528,85 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
|||||||
assert.equal(alassArgs[1], toShellPath(primaryPath));
|
assert.equal(alassArgs[1], toShellPath(primaryPath));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', sourceTrackId: 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 () => {
|
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 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
||||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
extractSubtitleTrackToFile,
|
extractSubtitleTrackToFile,
|
||||||
FileExtractionResult,
|
FileExtractionResult,
|
||||||
} from './subsync-extract';
|
} from './subsync-extract';
|
||||||
|
import { convertSubtitleForAlass } from './subsync-alass-input';
|
||||||
import { resolveMpvHttpHeaders, ResolvedMpvHttpHeaders } from './mpv-http-headers';
|
import { resolveMpvHttpHeaders, ResolvedMpvHttpHeaders } from './mpv-http-headers';
|
||||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
@@ -218,23 +219,43 @@ async function subsyncToReference(
|
|||||||
track: context.primaryTrack,
|
track: context.primaryTrack,
|
||||||
httpHeaders,
|
httpHeaders,
|
||||||
});
|
});
|
||||||
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
|
||||||
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
// alass reads neither the target nor the reference as WebVTT, so both are
|
||||||
|
// rewritten as SRT first; ffsubsync parses VTT itself.
|
||||||
|
let convertedPrimary: FileExtractionResult | null = null;
|
||||||
|
let convertedReference: FileExtractionResult | null = null;
|
||||||
|
if (engine === 'alass') {
|
||||||
|
try {
|
||||||
|
convertedPrimary = convertSubtitleForAlass(primaryExtraction.path);
|
||||||
|
convertedReference = convertSubtitleForAlass(referenceFilePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (convertedPrimary) cleanupTemporaryFile(convertedPrimary);
|
||||||
|
cleanupTemporaryFile(primaryExtraction);
|
||||||
|
const message = `alass synchronization failed: ${(error as Error).message}`;
|
||||||
|
logger.error(message);
|
||||||
|
return { ok: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = convertedPrimary ?? primaryExtraction;
|
||||||
|
const referencePath = convertedReference?.path ?? referenceFilePath;
|
||||||
|
const replacePrimary = resolved.replace !== false && !target.temporary;
|
||||||
|
const outputPath = buildRetimedPath(target.path, replacePrimary);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Running ${engine}: target=${primaryExtraction.path} reference=${referenceFilePath} output=${outputPath}`,
|
`Running ${engine}: target=${target.path} reference=${referencePath} output=${outputPath}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: CommandResult;
|
let result: CommandResult;
|
||||||
if (engine === 'alass') {
|
if (engine === 'alass') {
|
||||||
const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass');
|
const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass');
|
||||||
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
|
result = await runAlassSync(alassPath, referencePath, target.path, outputPath);
|
||||||
} else {
|
} else {
|
||||||
const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync');
|
const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync');
|
||||||
result = await runFfsubsyncSync(
|
result = await runFfsubsyncSync(
|
||||||
ffsubsyncPath,
|
ffsubsyncPath,
|
||||||
context.videoPath,
|
context.videoPath,
|
||||||
primaryExtraction.path,
|
target.path,
|
||||||
outputPath,
|
outputPath,
|
||||||
context.audioStreamIndex,
|
context.audioStreamIndex,
|
||||||
);
|
);
|
||||||
@@ -256,6 +277,8 @@ async function subsyncToReference(
|
|||||||
message: `Subtitle synchronized with ${engine}`,
|
message: `Subtitle synchronized with ${engine}`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
if (convertedReference) cleanupTemporaryFile(convertedReference);
|
||||||
|
if (convertedPrimary) cleanupTemporaryFile(convertedPrimary, outputPath);
|
||||||
cleanupTemporaryFile(primaryExtraction, outputPath);
|
cleanupTemporaryFile(primaryExtraction, outputPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-4
@@ -4,6 +4,7 @@ import * as path from 'path';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as childProcess from 'child_process';
|
import * as childProcess from 'child_process';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
|
import { splitSeasonFromTitle } from '../anime-bridge/episode-metadata';
|
||||||
import {
|
import {
|
||||||
JimakuApiResponse,
|
JimakuApiResponse,
|
||||||
JimakuConfig,
|
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);
|
const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
|
||||||
if (epOnly && epOnly.index !== undefined) {
|
if (epOnly && epOnly.index !== undefined) {
|
||||||
return {
|
return {
|
||||||
@@ -229,12 +252,14 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
|
|||||||
titlePart = name.slice(0, parsed.index);
|
titlePart = name.slice(0, parsed.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath);
|
// A season named in the title ("… Season 3") belongs in the season field, not
|
||||||
const title = cleanupTitle(titlePart || name);
|
// 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 {
|
return {
|
||||||
title,
|
title: titleSeason.title,
|
||||||
season: seasonFromDir,
|
season,
|
||||||
episode: parsed.episode,
|
episode: parsed.episode,
|
||||||
confidence: parsed.confidence,
|
confidence: parsed.confidence,
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
+58
-4
@@ -419,6 +419,7 @@ import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update
|
|||||||
import {
|
import {
|
||||||
guessAnilistMediaInfo,
|
guessAnilistMediaInfo,
|
||||||
updateAnilistPostWatchProgress,
|
updateAnilistPostWatchProgress,
|
||||||
|
type AnilistMediaGuess,
|
||||||
} from './core/services/anilist/anilist-updater';
|
} from './core/services/anilist/anilist-updater';
|
||||||
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
||||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||||
@@ -483,6 +484,11 @@ import {
|
|||||||
getJlptDictionarySearchPaths,
|
getJlptDictionarySearchPaths,
|
||||||
} from './main/jlpt-runtime';
|
} from './main/jlpt-runtime';
|
||||||
import { createMediaRuntimeService } from './main/media-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 { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
|
||||||
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
||||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-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 SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
|
||||||
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
|
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(
|
const mediaRuntime = createMediaRuntimeService(
|
||||||
createBuildMediaRuntimeMainDepsHandler({
|
createBuildMediaRuntimeMainDepsHandler({
|
||||||
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath),
|
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath),
|
||||||
@@ -2417,7 +2448,8 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
|||||||
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
|
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
|
||||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||||
|
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
|
||||||
getNameMatchImagesEnabled: () => configService.getConfig().subtitleStyle.nameMatchImagesEnabled,
|
getNameMatchImagesEnabled: () => configService.getConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||||
getCollapsibleSectionOpenState: (section) =>
|
getCollapsibleSectionOpenState: (section) =>
|
||||||
configService.getConfig().anilist.characterDictionary.collapsibleSections[section],
|
configService.getConfig().anilist.characterDictionary.collapsibleSections[section],
|
||||||
@@ -3252,6 +3284,22 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
|
|||||||
},
|
},
|
||||||
showMpvOsd: (text) =>
|
showMpvOsd: (text) =>
|
||||||
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
|
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) => {
|
onBridgeState: (state) => {
|
||||||
const window = appState.animeBrowserWindow;
|
const window = appState.animeBrowserWindow;
|
||||||
if (window && !window.isDestroyed()) {
|
if (window && !window.isDestroyed()) {
|
||||||
@@ -3708,7 +3756,8 @@ const {
|
|||||||
mediaRuntime.resolveMediaPathForJimaku(currentMediaPath),
|
mediaRuntime.resolveMediaPathForJimaku(currentMediaPath),
|
||||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||||
|
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
|
||||||
},
|
},
|
||||||
processNextRetryUpdateMainDeps: {
|
processNextRetryUpdateMainDeps: {
|
||||||
nextReady: () => anilistUpdateQueue.nextReady(),
|
nextReady: () => anilistUpdateQueue.nextReady(),
|
||||||
@@ -5860,8 +5909,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getFieldGroupingResolver: () => getFieldGroupingResolverHandler(),
|
getFieldGroupingResolver: () => getFieldGroupingResolverHandler(),
|
||||||
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
|
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
|
||||||
setFieldGroupingResolverHandler(resolver),
|
setFieldGroupingResolverHandler(resolver),
|
||||||
parseMediaInfo: (mediaPath: string | null) =>
|
parseMediaInfo: (mediaPath: string | null) => {
|
||||||
parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)),
|
// 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,
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
jimakuFetchJson: <T>(
|
jimakuFetchJson: <T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
buildTrackCommands,
|
buildTrackCommands,
|
||||||
selectPreferredStream,
|
selectPreferredStream,
|
||||||
} from '../../anime-bridge/mpv-playback';
|
} from '../../anime-bridge/mpv-playback';
|
||||||
|
import {
|
||||||
|
buildAnimeStreamMetadata,
|
||||||
|
type AnimeStreamMetadata,
|
||||||
|
} from '../../anime-bridge/episode-metadata';
|
||||||
import {
|
import {
|
||||||
listExtensionSources,
|
listExtensionSources,
|
||||||
readInstalledExtensions,
|
readInstalledExtensions,
|
||||||
@@ -87,6 +91,13 @@ export interface AnimeBrowserRuntimeDeps {
|
|||||||
readMpvProperty?: (name: string) => Promise<unknown>;
|
readMpvProperty?: (name: string) => Promise<unknown>;
|
||||||
showMpvOsd?: (message: string) => void;
|
showMpvOsd?: (message: string) => void;
|
||||||
showVisibleOverlay?: () => 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. */
|
/** Lets tests drive the pause between `loadfile` and the track commands. */
|
||||||
wait?: (ms: number) => Promise<void>;
|
wait?: (ms: number) => Promise<void>;
|
||||||
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
|
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
|
||||||
@@ -616,7 +627,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
try {
|
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 })) {
|
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||||
deps.sendMpvCommand(command);
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -148,6 +148,10 @@
|
|||||||
<span>Title</span>
|
<span>Title</span>
|
||||||
<input id="tsukihimeTitle" type="text" placeholder="Anime title" />
|
<input id="tsukihimeTitle" type="text" placeholder="Anime title" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="jimaku-field">
|
||||||
|
<span>Season</span>
|
||||||
|
<input id="tsukihimeSeason" type="number" min="1" placeholder="1" />
|
||||||
|
</label>
|
||||||
<label class="jimaku-field">
|
<label class="jimaku-field">
|
||||||
<span>Episode</span>
|
<span>Episode</span>
|
||||||
<input id="tsukihimeEpisode" type="number" min="1" placeholder="1" />
|
<input id="tsukihimeEpisode" type="number" min="1" placeholder="1" />
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ function createModalHarness(
|
|||||||
setAttribute: () => {},
|
setAttribute: () => {},
|
||||||
},
|
},
|
||||||
tsukihimeTitleInput: { value: '' },
|
tsukihimeTitleInput: { value: '' },
|
||||||
|
tsukihimeSeasonInput: { value: '' },
|
||||||
tsukihimeEpisodeInput: { value: '' },
|
tsukihimeEpisodeInput: { value: '' },
|
||||||
tsukihimeSearchButton: { addEventListener: () => {} },
|
tsukihimeSearchButton: { addEventListener: () => {} },
|
||||||
tsukihimeCloseButton: { addEventListener: () => {} },
|
tsukihimeCloseButton: { addEventListener: () => {} },
|
||||||
|
|||||||
@@ -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 {
|
function getSearchQuery(): string {
|
||||||
const title = ctx.dom.tsukihimeTitleInput.value.trim();
|
const title = ctx.dom.tsukihimeTitleInput.value.trim();
|
||||||
if (!title) return '';
|
if (!title) return '';
|
||||||
const episodeValue = ctx.dom.tsukihimeEpisodeInput.value
|
const season = readNumericInput(ctx.dom.tsukihimeSeasonInput);
|
||||||
? Number.parseInt(ctx.dom.tsukihimeEpisodeInput.value, 10)
|
const episode = readNumericInput(ctx.dom.tsukihimeEpisodeInput);
|
||||||
: null;
|
// Season 1 is left out on purpose: releases of a first season almost never
|
||||||
if (episodeValue !== null && Number.isFinite(episodeValue)) {
|
// put "S01" in the name, so adding it narrows the search to nothing.
|
||||||
return `${title} ${String(episodeValue).padStart(2, '0')}`;
|
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> {
|
async function performTsukihimeSearch(): Promise<void> {
|
||||||
@@ -369,6 +380,7 @@ export function createTsukihimeModal(
|
|||||||
.getJimakuMediaInfo()
|
.getJimakuMediaInfo()
|
||||||
.then(async (info: JimakuMediaInfo) => {
|
.then(async (info: JimakuMediaInfo) => {
|
||||||
ctx.dom.tsukihimeTitleInput.value = info.title || '';
|
ctx.dom.tsukihimeTitleInput.value = info.title || '';
|
||||||
|
ctx.dom.tsukihimeSeasonInput.value = info.season ? String(info.season) : '';
|
||||||
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
|
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||||
|
|
||||||
if (info.confidence === 'high' && info.title && info.episode) {
|
if (info.confidence === 'high' && info.title && info.episode) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type RendererDom = {
|
|||||||
|
|
||||||
tsukihimeModal: HTMLDivElement;
|
tsukihimeModal: HTMLDivElement;
|
||||||
tsukihimeTitleInput: HTMLInputElement;
|
tsukihimeTitleInput: HTMLInputElement;
|
||||||
|
tsukihimeSeasonInput: HTMLInputElement;
|
||||||
tsukihimeEpisodeInput: HTMLInputElement;
|
tsukihimeEpisodeInput: HTMLInputElement;
|
||||||
tsukihimeSearchButton: HTMLButtonElement;
|
tsukihimeSearchButton: HTMLButtonElement;
|
||||||
tsukihimeCloseButton: HTMLButtonElement;
|
tsukihimeCloseButton: HTMLButtonElement;
|
||||||
@@ -169,6 +170,7 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
|
|
||||||
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
|
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
|
||||||
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
|
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
|
||||||
|
tsukihimeSeasonInput: getRequiredElement<HTMLInputElement>('tsukihimeSeason'),
|
||||||
tsukihimeEpisodeInput: getRequiredElement<HTMLInputElement>('tsukihimeEpisode'),
|
tsukihimeEpisodeInput: getRequiredElement<HTMLInputElement>('tsukihimeEpisode'),
|
||||||
tsukihimeSearchButton: getRequiredElement<HTMLButtonElement>('tsukihimeSearch'),
|
tsukihimeSearchButton: getRequiredElement<HTMLButtonElement>('tsukihimeSearch'),
|
||||||
tsukihimeCloseButton: getRequiredElement<HTMLButtonElement>('tsukihimeClose'),
|
tsukihimeCloseButton: getRequiredElement<HTMLButtonElement>('tsukihimeClose'),
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ export interface AnimeBrowserPlayRequest {
|
|||||||
animeTitle: string;
|
animeTitle: string;
|
||||||
episodeUrl: string;
|
episodeUrl: string;
|
||||||
episodeName: 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 {
|
export interface AnimeBrowserPlayResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user