mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 19:21:32 -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:
@@ -18,6 +18,10 @@ import {
|
||||
buildTrackCommands,
|
||||
selectPreferredStream,
|
||||
} from '../../anime-bridge/mpv-playback';
|
||||
import {
|
||||
buildAnimeStreamMetadata,
|
||||
type AnimeStreamMetadata,
|
||||
} from '../../anime-bridge/episode-metadata';
|
||||
import {
|
||||
listExtensionSources,
|
||||
readInstalledExtensions,
|
||||
@@ -87,6 +91,13 @@ export interface AnimeBrowserRuntimeDeps {
|
||||
readMpvProperty?: (name: string) => Promise<unknown>;
|
||||
showMpvOsd?: (message: string) => void;
|
||||
showVisibleOverlay?: () => void;
|
||||
/**
|
||||
* Publishes what is about to play. Called *before* `loadfile` so the title
|
||||
* and the episode's identity are already known when mpv reports the path
|
||||
* change — otherwise stats sees only the proxy URL and groups every stream
|
||||
* under its file extension.
|
||||
*/
|
||||
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
|
||||
/** Lets tests drive the pause between `loadfile` and the track commands. */
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
|
||||
@@ -616,7 +627,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
: null;
|
||||
|
||||
try {
|
||||
const title = `${request.animeTitle} — ${request.episodeName}`;
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: request.sourceId,
|
||||
animeUrl: request.animeUrl,
|
||||
animeTitle: request.animeTitle,
|
||||
episodeUrl: request.episodeUrl,
|
||||
episodeName: request.episodeName,
|
||||
episodeNumber: request.episodeNumber ?? null,
|
||||
mediaPath: stream.url,
|
||||
});
|
||||
const title = metadata.displayTitle;
|
||||
// Before loadfile: mpv's path change is what starts a stats session,
|
||||
// and it must find this already recorded.
|
||||
deps.onPlaybackMetadata?.(metadata);
|
||||
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStreamPlaybackMetadataStore,
|
||||
toAnilistMediaGuess,
|
||||
toJimakuMediaInfo,
|
||||
} from './stream-playback-metadata';
|
||||
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
|
||||
function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMetadata {
|
||||
return {
|
||||
mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8',
|
||||
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
|
||||
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 4,
|
||||
episodeTitle: null,
|
||||
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('the store answers for the stream URL and for the stats path', () => {
|
||||
const store = createStreamPlaybackMetadataStore();
|
||||
const current = metadata();
|
||||
store.set(current);
|
||||
|
||||
assert.equal(store.match(current.mediaPath), current);
|
||||
assert.equal(store.match(current.statsPath), current);
|
||||
assert.equal(store.match(` ${current.mediaPath} `), current);
|
||||
});
|
||||
|
||||
test('the store stops answering once the player moves on', () => {
|
||||
const store = createStreamPlaybackMetadataStore();
|
||||
store.set(metadata());
|
||||
|
||||
assert.equal(store.match('/home/user/Videos/local.mkv'), null);
|
||||
assert.equal(store.match(null), null);
|
||||
assert.equal(store.match(''), null);
|
||||
|
||||
store.clear();
|
||||
assert.equal(store.match(metadata().mediaPath), null);
|
||||
});
|
||||
|
||||
test('toJimakuMediaInfo prefills the modals with the source-reported fields', () => {
|
||||
assert.deepEqual(toJimakuMediaInfo(metadata()), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
season: 3,
|
||||
episode: 4,
|
||||
confidence: 'high',
|
||||
filename: 'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
rawTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
});
|
||||
});
|
||||
|
||||
test('toJimakuMediaInfo drops to low confidence when there is no episode', () => {
|
||||
const info = toJimakuMediaInfo(metadata({ episodeNumber: null, seasonNumber: null }));
|
||||
assert.equal(info.episode, null);
|
||||
assert.equal(info.confidence, 'low');
|
||||
assert.equal(info.title, 'Mushoku Tensei: Jobless Reincarnation');
|
||||
});
|
||||
|
||||
test('toAnilistMediaGuess reports the stream fields verbatim', () => {
|
||||
assert.deepEqual(toAnilistMediaGuess(metadata()), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
season: 3,
|
||||
episode: 4,
|
||||
source: 'stream',
|
||||
});
|
||||
});
|
||||
|
||||
test('toAnilistMediaGuess declines a special so the caller can fall back', () => {
|
||||
// AniList progress counts whole episodes; a 6.5 special is not one of them.
|
||||
assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: 6.5 })), null);
|
||||
assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: null })), null);
|
||||
assert.equal(toAnilistMediaGuess(metadata({ seriesTitle: '' })), null);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import type { JimakuMediaInfo } from '../../types';
|
||||
|
||||
/**
|
||||
* Holds what the anime browser resolved for the episode currently streaming.
|
||||
*
|
||||
* Consumers otherwise have to re-derive the series and episode from the mpv
|
||||
* title, and some of them only ever see the stream URL, which carries no title
|
||||
* at all. Keeping the resolved fields lets each of them ask instead of guess.
|
||||
*/
|
||||
export interface StreamPlaybackMetadataStore {
|
||||
set: (metadata: AnimeStreamMetadata) => void;
|
||||
clear: () => void;
|
||||
/**
|
||||
* The metadata for `mediaPath`, or null when the player has moved on to
|
||||
* something else. Matching on the path is what makes this self-expiring —
|
||||
* there is no teardown hook to miss.
|
||||
*/
|
||||
match: (mediaPath: string | null) => AnimeStreamMetadata | null;
|
||||
}
|
||||
|
||||
export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore {
|
||||
let current: AnimeStreamMetadata | null = null;
|
||||
|
||||
return {
|
||||
set(metadata: AnimeStreamMetadata): void {
|
||||
current = metadata;
|
||||
},
|
||||
clear(): void {
|
||||
current = null;
|
||||
},
|
||||
match(mediaPath: string | null): AnimeStreamMetadata | null {
|
||||
if (!current) return null;
|
||||
const trimmed = typeof mediaPath === 'string' ? mediaPath.trim() : '';
|
||||
if (!trimmed) return null;
|
||||
// The stats path is accepted too: stats rewrites the volatile stream URL
|
||||
// to it, so callers reading from there still resolve.
|
||||
return trimmed === current.mediaPath || trimmed === current.statsPath ? current : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */
|
||||
function wholeEpisode(episode: number | null): number | null {
|
||||
return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle modals' prefill. `high` confidence is honest here: these fields
|
||||
* came from the source's own listing rather than from a filename guess, so the
|
||||
* modals may search on them without waiting for the user to confirm.
|
||||
*/
|
||||
export function toJimakuMediaInfo(metadata: AnimeStreamMetadata): JimakuMediaInfo {
|
||||
return {
|
||||
title: metadata.seriesTitle,
|
||||
season: metadata.seasonNumber,
|
||||
episode: wholeEpisode(metadata.episodeNumber),
|
||||
confidence: metadata.episodeNumber !== null ? 'high' : 'low',
|
||||
filename: metadata.displayTitle,
|
||||
rawTitle: metadata.displayTitle,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The AniList guess, or null when there is no episode to report — in which case
|
||||
* the caller should fall back to parsing the title as usual.
|
||||
*/
|
||||
export function toAnilistMediaGuess(metadata: AnimeStreamMetadata): AnilistMediaGuess | null {
|
||||
const episode = wholeEpisode(metadata.episodeNumber);
|
||||
if (!metadata.seriesTitle || episode === null) return null;
|
||||
return {
|
||||
title: metadata.seriesTitle,
|
||||
season: metadata.seasonNumber,
|
||||
episode,
|
||||
source: 'stream',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user