mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -07:00
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
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
|
||||
|
||||
export type YoutubePrimarySubtitleNotificationTimer = ReturnType<typeof setTimeout> | { id: number };
|
||||
|
||||
type SubtitleTrackEntry = {
|
||||
id: number | null;
|
||||
type: string;
|
||||
lang: string;
|
||||
external: boolean;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTrack(entry: unknown): SubtitleTrackEntry | null {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const track = entry as Record<string, unknown>;
|
||||
return {
|
||||
id: parseTrackId(track.id),
|
||||
type: String(track.type || '').trim(),
|
||||
lang: String(track.lang || '').trim(),
|
||||
external: track.external === true,
|
||||
selected: track.selected === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearYoutubePrimarySubtitleNotificationTimer(
|
||||
timer: YoutubePrimarySubtitleNotificationTimer | null,
|
||||
): void {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
if (typeof timer === 'object' && timer !== null && 'id' in timer) {
|
||||
clearTimeout((timer as { id: number }).id);
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
function buildPreferredLanguageSet(values: string[]): Set<string> {
|
||||
const normalized = values
|
||||
.map((value) => normalizeYoutubeLangCode(value))
|
||||
.filter((value) => value.length > 0);
|
||||
return new Set(normalized);
|
||||
}
|
||||
|
||||
function matchesPreferredLanguage(language: string, preferred: Set<string>): boolean {
|
||||
if (preferred.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeYoutubeLangCode(language);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (preferred.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const base = normalized.split('-')[0] || normalized;
|
||||
return preferred.has(base);
|
||||
}
|
||||
|
||||
function hasSelectedPrimarySubtitle(
|
||||
sid: number | null,
|
||||
trackList: unknown[] | null,
|
||||
preferredLanguages: Set<string>,
|
||||
): boolean {
|
||||
if (!Array.isArray(trackList)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tracks = trackList.map(normalizeTrack);
|
||||
const activeTrack =
|
||||
(sid === null ? null : tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null) ??
|
||||
tracks.find((track) => track?.type === 'sub' && track.selected) ??
|
||||
null;
|
||||
if (!activeTrack) {
|
||||
return false;
|
||||
}
|
||||
if (activeTrack.external) {
|
||||
return true;
|
||||
}
|
||||
return matchesPreferredLanguage(activeTrack.lang, preferredLanguages);
|
||||
}
|
||||
|
||||
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
getPrimarySubtitleLanguages: () => string[];
|
||||
notifyFailure: (message: string) => void;
|
||||
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
|
||||
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? 5000;
|
||||
let currentMediaPath: string | null = null;
|
||||
let currentSid: number | null = null;
|
||||
let currentTrackList: unknown[] | null = null;
|
||||
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
|
||||
let lastReportedMediaPath: string | null = null;
|
||||
let appOwnedFlowInFlight = false;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
deps.clearSchedule(pendingTimer);
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const maybeReportFailure = (): void => {
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
if (lastReportedMediaPath === mediaPath) {
|
||||
return;
|
||||
}
|
||||
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
||||
if (preferredLanguages.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
||||
return;
|
||||
}
|
||||
lastReportedMediaPath = mediaPath;
|
||||
deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.');
|
||||
};
|
||||
|
||||
const schedulePendingCheck = (): void => {
|
||||
clearPendingTimer();
|
||||
if (appOwnedFlowInFlight) {
|
||||
return;
|
||||
}
|
||||
const mediaPath = currentMediaPath?.trim() || '';
|
||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
pendingTimer = null;
|
||||
maybeReportFailure();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
return {
|
||||
handleMediaPathChange: (path: string | null): void => {
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
||||
if (currentMediaPath !== normalizedPath) {
|
||||
lastReportedMediaPath = null;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
currentSid = null;
|
||||
currentTrackList = null;
|
||||
schedulePendingCheck();
|
||||
},
|
||||
handleSubtitleTrackChange: (sid: number | null): void => {
|
||||
currentSid = sid;
|
||||
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
||||
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
},
|
||||
handleSubtitleTrackListChange: (trackList: unknown[] | null): void => {
|
||||
currentTrackList = trackList;
|
||||
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
||||
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
},
|
||||
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
|
||||
appOwnedFlowInFlight = inFlight;
|
||||
if (inFlight) {
|
||||
clearPendingTimer();
|
||||
return;
|
||||
}
|
||||
schedulePendingCheck();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user