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

- Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8"
- Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess
- Add a Season field to the TsukiHime modal and include later seasons in its search query
- Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT
- Update CHANGELOG and troubleshooting docs
This commit is contained in:
2026-08-14 22:40:23 -07:00
parent 93301e4a22
commit 44956f8382
25 changed files with 1725 additions and 69 deletions
+58 -4
View File
@@ -420,6 +420,7 @@ import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update
import {
guessAnilistMediaInfo,
updateAnilistPostWatchProgress,
type AnilistMediaGuess,
} from './core/services/anilist/anilist-updater';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
@@ -486,6 +487,11 @@ import {
getJlptDictionarySearchPaths,
} from './main/jlpt-runtime';
import { createMediaRuntimeService } from './main/media-runtime';
import {
createStreamPlaybackMetadataStore,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './main/runtime/stream-playback-metadata';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
@@ -2407,6 +2413,31 @@ const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroup
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
/**
* What the anime browser resolved for the stream that is playing. Consulted by
* everything that would otherwise have to parse the mpv title, or worse the
* stream URL, which names only the proxy and the container format.
*/
const streamPlaybackMetadata = createStreamPlaybackMetadataStore();
/** The stream metadata for whatever mpv currently has open, if it is a stream. */
function getActiveStreamMetadata() {
return streamPlaybackMetadata.match(appState.currentMediaPath);
}
/**
* The AniList guess for what is playing. A stream answers from the fields its
* source reported; everything else goes through the usual title parsers.
*/
async function guessAnilistMediaInfoForCurrentMedia(
mediaPath: string | null,
mediaTitle: string | null,
): Promise<AnilistMediaGuess | null> {
const stream = getActiveStreamMetadata();
const streamGuess = stream ? toAnilistMediaGuess(stream) : null;
return streamGuess ?? guessAnilistMediaInfo(mediaPath, mediaTitle);
}
const mediaRuntime = createMediaRuntimeService(
createBuildMediaRuntimeMainDepsHandler({
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath),
@@ -2439,7 +2470,8 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
getNameMatchImagesEnabled: () => configService.getConfig().subtitleStyle.nameMatchImagesEnabled,
getCollapsibleSectionOpenState: (section) =>
configService.getConfig().anilist.characterDictionary.collapsibleSections[section],
@@ -3295,6 +3327,22 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
},
showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
// Set before mpv reports the path change, so the session that change starts
// is titled and grouped from the source's own listing rather than from the
// proxy URL, whose only readable part is the `.m3u8` extension.
mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle);
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordStreamPlaybackMetadata({
mediaPath: metadata.mediaPath,
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
});
},
onBridgeState: (state) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
@@ -3751,7 +3799,8 @@ const {
mediaRuntime.resolveMediaPathForJimaku(currentMediaPath),
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
},
processNextRetryUpdateMainDeps: {
nextReady: () => anilistUpdateQueue.nextReady(),
@@ -5936,8 +5985,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getFieldGroupingResolver: () => getFieldGroupingResolverHandler(),
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
setFieldGroupingResolverHandler(resolver),
parseMediaInfo: (mediaPath: string | null) =>
parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)),
parseMediaInfo: (mediaPath: string | null) => {
// A stream already knows its series, season and episode; parsing the
// title back out of a string would only lose what the source told us.
const stream = getActiveStreamMetadata();
if (stream) return toJimakuMediaInfo(stream);
return parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath));
},
getCurrentMediaPath: () => appState.currentMediaPath,
jimakuFetchJson: <T>(
endpoint: string,