Files
SubMiner/src/main/runtime/anilist-media-guess.ts
T
sudacode 5feed360ca feat: add app-owned YouTube subtitle flow with absPlayer-style parsing (#31)
* fix: harden preload argv parsing for popup windows

* fix: align youtube playback with shared overlay startup

* fix: unwrap mpv youtube streams for anki media mining

* docs: update docs for youtube subtitle and mining flow

* refactor: unify cli and runtime wiring for startup and youtube flow

* feat: update subtitle sidebar overlay behavior

* chore: add shared log-file source for diagnostics

* fix(ci): add changelog fragment for immersion changes

* fix: address CodeRabbit review feedback

* fix: persist canonical title from youtube metadata

* style: format stats library tab

* fix: address latest review feedback

* style: format stats library files

* test: stub launcher youtube deps in CI

* test: isolate launcher youtube flow deps

* test: stub launcher youtube deps in failing case

* test: force x11 backend in launcher ci harness

* test: address latest review feedback

* fix(launcher): preserve user YouTube ytdl raw options

* docs(backlog): update task tracking notes

* fix(immersion): special-case youtube media paths in runtime and tracking

* feat(stats): improve YouTube media metadata and picker key handling

* fix(ci): format stats media library hook

* fix: address latest CodeRabbit review items

* docs: update youtube release notes and docs

* feat: auto-load youtube subtitles before manual picker

* fix: restore app-owned youtube subtitle flow

* docs: update youtube playback docs and config copy

* refactor: remove legacy youtube launcher mode plumbing

* fix: refine youtube subtitle startup binding

* docs: clarify youtube subtitle startup behavior

* fix: address PR #31 latest review follow-ups

* fix: address PR #31 follow-up review comments

* test: harden youtube picker test harness

* udpate backlog

* fix: add timeout to youtube metadata probe

* docs: refresh youtube and stats docs

* update backlog

* update backlog

* chore: release v0.9.0
2026-03-24 00:01:24 -07:00

120 lines
3.5 KiB
TypeScript

import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { isYoutubeMediaPath } from './youtube-playback';
export type AnilistMediaGuessRuntimeState = {
mediaKey: string | null;
mediaDurationSec: number | null;
mediaGuess: AnilistMediaGuess | null;
mediaGuessPromise: Promise<AnilistMediaGuess | null> | null;
lastDurationProbeAtMs: number;
};
type GuessAnilistMediaInfo = (
mediaPath: string | null,
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
export function createMaybeProbeAnilistDurationHandler(deps: {
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
durationRetryIntervalMs: number;
now: () => number;
requestMpvDuration: () => Promise<unknown>;
logWarn: (message: string, error: unknown) => void;
}) {
return async (mediaKey: string): Promise<number | null> => {
const state = deps.getState();
if (state.mediaKey !== mediaKey) {
return null;
}
if (isYoutubeMediaPath(mediaKey)) {
return null;
}
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
return state.mediaDurationSec;
}
const now = deps.now();
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
return null;
}
deps.setState({
...state,
lastDurationProbeAtMs: now,
});
try {
const durationCandidate = await deps.requestMpvDuration();
const duration =
typeof durationCandidate === 'number' && Number.isFinite(durationCandidate)
? durationCandidate
: null;
const latestState = deps.getState();
if (duration && duration > 0 && latestState.mediaKey === mediaKey) {
deps.setState({
...latestState,
mediaDurationSec: duration,
});
return duration;
}
} catch (error) {
deps.logWarn('AniList duration probe failed:', error);
}
return null;
};
}
export function createEnsureAnilistMediaGuessHandler(deps: {
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null;
getCurrentMediaPath: () => string | null;
getCurrentMediaTitle: () => string | null;
guessAnilistMediaInfo: GuessAnilistMediaInfo;
}) {
return async (mediaKey: string): Promise<AnilistMediaGuess | null> => {
const state = deps.getState();
if (state.mediaKey !== mediaKey) {
return null;
}
if (isYoutubeMediaPath(mediaKey)) {
return null;
}
if (state.mediaGuess) {
return state.mediaGuess;
}
if (state.mediaGuessPromise) {
return state.mediaGuessPromise;
}
const mediaPathForGuess = deps.resolveMediaPathForJimaku(deps.getCurrentMediaPath());
const promise = deps
.guessAnilistMediaInfo(mediaPathForGuess, deps.getCurrentMediaTitle())
.then((guess) => {
const latestState = deps.getState();
if (latestState.mediaKey === mediaKey) {
deps.setState({
...latestState,
mediaGuess: guess,
});
}
return guess;
})
.finally(() => {
const latestState = deps.getState();
if (latestState.mediaKey === mediaKey) {
deps.setState({
...latestState,
mediaGuessPromise: null,
});
}
});
deps.setState({
...state,
mediaGuessPromise: promise,
});
return promise;
};
}