mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-26 00:26:05 -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:
@@ -58,6 +58,7 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
||||
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||
import { resolveMediaGenerationInputPath } from './anki-integration/media-source';
|
||||
|
||||
const log = createLogger('anki').child('integration');
|
||||
|
||||
@@ -597,6 +598,10 @@ export class AnkiIntegration {
|
||||
this.runtime.start();
|
||||
}
|
||||
|
||||
waitUntilReady(): Promise<void> {
|
||||
return this.runtime.waitUntilReady();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.runtime.stop();
|
||||
}
|
||||
@@ -647,7 +652,10 @@ export class AnkiIntegration {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoPath = mpvClient.currentVideoPath;
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
let startTime = mpvClient.currentSubStart;
|
||||
let endTime = mpvClient.currentSubEnd;
|
||||
|
||||
@@ -672,7 +680,10 @@ export class AnkiIntegration {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoPath = this.mpvClient.currentVideoPath;
|
||||
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
|
||||
if (this.config.media?.imageType === 'avif') {
|
||||
@@ -946,8 +957,15 @@ export class AnkiIntegration {
|
||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||
try {
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const notificationIconSource = await resolveMediaGenerationInputPath(
|
||||
this.mpvClient,
|
||||
'video',
|
||||
);
|
||||
if (!notificationIconSource) {
|
||||
throw new Error('No media source available for notification icon');
|
||||
}
|
||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
||||
this.mpvClient.currentVideoPath,
|
||||
notificationIconSource,
|
||||
timestamp,
|
||||
);
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
|
||||
@@ -35,6 +35,9 @@ export class AnkiConnectProxyServer {
|
||||
private pendingNoteIdSet = new Set<number>();
|
||||
private inFlightNoteIds = new Set<number>();
|
||||
private processingQueue = false;
|
||||
private readyPromise: Promise<void> | null = null;
|
||||
private resolveReady: (() => void) | null = null;
|
||||
private rejectReady: ((error: Error) => void) | null = null;
|
||||
|
||||
constructor(private readonly deps: AnkiConnectProxyServerDeps) {
|
||||
this.client = axios.create({
|
||||
@@ -48,6 +51,13 @@ export class AnkiConnectProxyServer {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
waitUntilReady(): Promise<void> {
|
||||
if (!this.server || this.server.listening) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.readyPromise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
start(options: StartProxyOptions): void {
|
||||
this.stop();
|
||||
|
||||
@@ -58,15 +68,26 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readyPromise = new Promise<void>((resolve, reject) => {
|
||||
this.resolveReady = resolve;
|
||||
this.rejectReady = reject;
|
||||
});
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
void this.handleRequest(req, res, options.upstreamUrl);
|
||||
});
|
||||
|
||||
this.server.on('error', (error) => {
|
||||
this.rejectReady?.(error as Error);
|
||||
this.resolveReady = null;
|
||||
this.rejectReady = null;
|
||||
this.deps.logError('[anki-proxy] Server error:', (error as Error).message);
|
||||
});
|
||||
|
||||
this.server.listen(options.port, options.host, () => {
|
||||
this.resolveReady?.();
|
||||
this.resolveReady = null;
|
||||
this.rejectReady = null;
|
||||
this.deps.logInfo(
|
||||
`[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`,
|
||||
);
|
||||
@@ -79,6 +100,10 @@ export class AnkiConnectProxyServer {
|
||||
this.server = null;
|
||||
this.deps.logInfo('[anki-proxy] Stopped');
|
||||
}
|
||||
this.rejectReady?.(new Error('AnkiConnect proxy stopped before becoming ready'));
|
||||
this.readyPromise = null;
|
||||
this.resolveReady = null;
|
||||
this.rejectReady = null;
|
||||
this.pendingNoteIds = [];
|
||||
this.pendingNoteIdSet.clear();
|
||||
this.inFlightNoteIds.clear();
|
||||
|
||||
@@ -283,3 +283,117 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
assert.equal(calls.notesInfo, 1);
|
||||
assert.equal(calls.updateNoteFields, 1);
|
||||
});
|
||||
|
||||
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentTimePos: 1.5,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
assert.equal(name, 'stream-open-filename');
|
||||
return edlSource;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createLogger } from '../logger';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { MpvClient } from '../types';
|
||||
import { resolveSentenceBackText } from './ai';
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
@@ -501,7 +502,12 @@ export class CardCreationService {
|
||||
this.deps.showOsdNotification('Creating sentence card...');
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = mpvClient.currentVideoPath;
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||
if (!videoPath) {
|
||||
this.deps.showOsdNotification('No video loaded');
|
||||
return false;
|
||||
}
|
||||
const fields: Record<string, string> = {};
|
||||
const errors: string[] = [];
|
||||
let miscInfoFilename: string | null = null;
|
||||
@@ -605,7 +611,9 @@ export class CardCreationService {
|
||||
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime);
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
|
||||
64
src/anki-integration/media-source.test.ts
Normal file
64
src/anki-integration/media-source.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
|
||||
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
|
||||
const result = await resolveMediaGenerationInputPath({
|
||||
currentVideoPath: '/tmp/video.mkv',
|
||||
});
|
||||
|
||||
assert.equal(result, '/tmp/video.mkv');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath prefers stream-open-filename for remote media', async () => {
|
||||
const requests: string[] = [];
|
||||
|
||||
const result = await resolveMediaGenerationInputPath({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async (name: string) => {
|
||||
requests.push(name);
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||
assert.deepEqual(requests, ['stream-open-filename']);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const audioResult = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
const videoResult = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
||||
const result = await resolveMediaGenerationInputPath({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => {
|
||||
throw new Error('property unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
|
||||
});
|
||||
84
src/anki-integration/media-source.ts
Normal file
84
src/anki-integration/media-source.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||
import type { MpvClient } from '../types';
|
||||
|
||||
export type MediaGenerationKind = 'audio' | 'video';
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||
return [...matches]
|
||||
.map((match) => trimToNonEmptyString(match[1]))
|
||||
.filter((value): value is string => value !== null);
|
||||
}
|
||||
|
||||
function classifyMediaUrl(url: string): MediaGenerationKind | null {
|
||||
try {
|
||||
const mime = new URL(url).searchParams.get('mime')?.toLowerCase() ?? '';
|
||||
if (mime.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (mime.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs and fall back to stream order.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePreferredUrlFromMpvEdlSource(
|
||||
source: string,
|
||||
kind: MediaGenerationKind,
|
||||
): string | null {
|
||||
const urls = extractUrlsFromMpvEdlSource(source);
|
||||
if (urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typedMatch = urls.find((url) => classifyMediaUrl(url) === kind);
|
||||
if (typedMatch) {
|
||||
return typedMatch;
|
||||
}
|
||||
|
||||
// mpv EDL sources usually list audio streams first and video streams last, so
|
||||
// when classifyMediaUrl cannot identify a typed URL we fall back to stream order.
|
||||
return kind === 'audio' ? urls[0] ?? null : urls[urls.length - 1] ?? null;
|
||||
}
|
||||
|
||||
export async function resolveMediaGenerationInputPath(
|
||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||
kind: MediaGenerationKind = 'video',
|
||||
): Promise<string | null> {
|
||||
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
||||
if (!currentVideoPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRemoteMediaPath(currentVideoPath) || !mpvClient?.requestProperty) {
|
||||
return currentVideoPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamOpenFilename = trimToNonEmptyString(
|
||||
await mpvClient.requestProperty('stream-open-filename'),
|
||||
);
|
||||
if (streamOpenFilename?.startsWith('edl://')) {
|
||||
return resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind) ?? streamOpenFilename;
|
||||
}
|
||||
if (streamOpenFilename) {
|
||||
return streamOpenFilename;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
||||
}
|
||||
|
||||
return currentVideoPath;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ function createRuntime(
|
||||
start: ({ host, port, upstreamUrl }) =>
|
||||
calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`),
|
||||
stop: () => calls.push('proxy:stop'),
|
||||
waitUntilReady: async () => undefined,
|
||||
}),
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
@@ -80,6 +81,44 @@ test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled',
|
||||
assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']);
|
||||
});
|
||||
|
||||
test('AnkiIntegrationRuntime waits for proxy readiness when proxy mode is enabled', async () => {
|
||||
let releaseReady!: () => void;
|
||||
const waitUntilReadyCalls: string[] = [];
|
||||
const readyPromise = new Promise<void>((resolve) => {
|
||||
releaseReady = resolve;
|
||||
});
|
||||
const { runtime } = createRuntime(
|
||||
{
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 9999,
|
||||
upstreamUrl: 'http://upstream:8765',
|
||||
},
|
||||
},
|
||||
{
|
||||
proxyServerFactory: () => ({
|
||||
start: () => undefined,
|
||||
stop: () => undefined,
|
||||
waitUntilReady: async () => {
|
||||
waitUntilReadyCalls.push('proxy:wait-until-ready');
|
||||
await readyPromise;
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
runtime.start();
|
||||
const waitPromise = runtime.waitUntilReady().then(() => {
|
||||
waitUntilReadyCalls.push('proxy:ready');
|
||||
});
|
||||
|
||||
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready']);
|
||||
releaseReady();
|
||||
await waitPromise;
|
||||
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready', 'proxy:ready']);
|
||||
});
|
||||
|
||||
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
|
||||
const { runtime, calls } = createRuntime({
|
||||
knownWords: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
export interface AnkiIntegrationRuntimeProxyServer {
|
||||
start(options: { host: string; port: number; upstreamUrl: string }): void;
|
||||
stop(): void;
|
||||
waitUntilReady(): Promise<void>;
|
||||
}
|
||||
|
||||
interface AnkiIntegrationRuntimeDeps {
|
||||
@@ -131,6 +132,13 @@ export class AnkiIntegrationRuntime {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
waitUntilReady(): Promise<void> {
|
||||
if (!this.started || !this.isProxyTransportEnabled()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.getOrCreateProxyServer().waitUntilReady();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
this.stop();
|
||||
|
||||
@@ -56,6 +56,20 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', ()
|
||||
assert.equal(shouldStartApp(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs captures youtube startup forwarding flags', () => {
|
||||
const args = parseArgs([
|
||||
'--youtube-play',
|
||||
'https://youtube.com/watch?v=abc',
|
||||
'--youtube-mode',
|
||||
'generate',
|
||||
]);
|
||||
|
||||
assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc');
|
||||
assert.equal(args.youtubeMode, 'generate');
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs handles jellyfin item listing controls', () => {
|
||||
const args = parseArgs([
|
||||
'--jellyfin-items',
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface CliArgs {
|
||||
start: boolean;
|
||||
launchMpv: boolean;
|
||||
launchMpvTargets: string[];
|
||||
youtubePlay?: string;
|
||||
youtubeMode?: 'download' | 'generate';
|
||||
stop: boolean;
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
@@ -79,6 +81,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
youtubePlay: undefined,
|
||||
youtubeMode: undefined,
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -140,7 +144,19 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
if (arg === '--background') args.background = true;
|
||||
else if (arg === '--start') args.start = true;
|
||||
else if (arg === '--launch-mpv') {
|
||||
else if (arg.startsWith('--youtube-play=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.youtubePlay = value;
|
||||
} else if (arg === '--youtube-play') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.youtubePlay = value;
|
||||
} else if (arg.startsWith('--youtube-mode=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value === 'download' || value === 'generate') args.youtubeMode = value;
|
||||
} else if (arg === '--youtube-mode') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value === 'download' || value === 'generate') args.youtubeMode = value;
|
||||
} else if (arg === '--launch-mpv') {
|
||||
args.launchMpv = true;
|
||||
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
|
||||
break;
|
||||
@@ -334,6 +350,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
return (
|
||||
args.background ||
|
||||
args.start ||
|
||||
Boolean(args.youtubePlay) ||
|
||||
args.launchMpv ||
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
@@ -385,6 +402,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
if (
|
||||
args.background ||
|
||||
args.start ||
|
||||
Boolean(args.youtubePlay) ||
|
||||
args.launchMpv ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
@@ -452,6 +470,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.jellyfinItems &&
|
||||
!args.jellyfinSubtitles &&
|
||||
!args.jellyfinPlay &&
|
||||
!args.youtubePlay &&
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.texthooker &&
|
||||
@@ -480,6 +499,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions
|
||||
args.openRuntimeOptions ||
|
||||
Boolean(args.youtubePlay)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1735,7 +1735,7 @@ test('accepts top-level ai config', () => {
|
||||
assert.equal(config.ai.requestTimeoutMs, 20000);
|
||||
});
|
||||
|
||||
test('accepts per-feature ai overrides for anki and youtube subtitle generation', () => {
|
||||
test('accepts per-feature ai overrides for anki and YouTube subtitles', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -2074,16 +2074,16 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"fixWithAi": false,? \/\/ Use shared AI provider to post-process whisper-generated YouTube subtitles\. Values: true \| false/,
|
||||
/"fixWithAi": false,? \/\/ Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"systemPrompt": "",? \/\/ Optional system prompt override for YouTube subtitle AI post-processing\./,
|
||||
/"systemPrompt": "",? \/\/ Optional system prompt override for legacy subtitle fallback post-processing; not used by default\./,
|
||||
);
|
||||
assert.doesNotMatch(output, /"mode": "automatic"/);
|
||||
assert.match(
|
||||
output,
|
||||
/"whisperThreads": 4,? \/\/ Thread count passed to whisper\.cpp subtitle generation runs\./,
|
||||
/"whisperThreads": 4,? \/\/ Legacy thread tuning for subtitle fallback tooling; not used by default\./,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -77,6 +77,7 @@ test('default keybindings include primary and secondary subtitle track cycling o
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
|
||||
});
|
||||
|
||||
test('default keybindings include fullscreen on F', () => {
|
||||
|
||||
@@ -369,43 +369,47 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
path: 'youtubeSubgen.whisperBin',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperBin,
|
||||
description: 'Path to whisper.cpp CLI used as fallback transcription engine.',
|
||||
description: 'Legacy compatibility path kept for external subtitle fallback tools; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.whisperModel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
|
||||
description: 'Path to whisper model used for fallback transcription.',
|
||||
description: 'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.whisperVadModel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperVadModel,
|
||||
description: 'Path to optional whisper VAD model used for subtitle generation.',
|
||||
description:
|
||||
'Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.whisperThreads',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperThreads,
|
||||
description: 'Thread count passed to whisper.cpp subtitle generation runs.',
|
||||
description: 'Legacy thread tuning for subtitle fallback tooling; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.fixWithAi',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.youtubeSubgen.fixWithAi,
|
||||
description: 'Use shared AI provider to post-process whisper-generated YouTube subtitles.',
|
||||
description:
|
||||
'Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.ai.model',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.ai.model,
|
||||
description: 'Optional model override for YouTube subtitle AI post-processing.',
|
||||
description:
|
||||
'Optional model override for legacy subtitle fallback post-processing; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.ai.systemPrompt',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.ai.systemPrompt,
|
||||
description: 'Optional system prompt override for YouTube subtitle AI post-processing.',
|
||||
description:
|
||||
'Optional system prompt override for legacy subtitle fallback post-processing; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.primarySubLanguages',
|
||||
|
||||
@@ -46,6 +46,7 @@ export const SPECIAL_COMMANDS = {
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
@@ -64,6 +65,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
key: 'Shift+BracketLeft',
|
||||
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
||||
},
|
||||
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
||||
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||
{ key: 'KeyQ', command: ['quit'] },
|
||||
|
||||
@@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'Secondary Subtitles',
|
||||
description: [
|
||||
'Dual subtitle track options.',
|
||||
'Used by subminer YouTube subtitle generation as secondary language preferences.',
|
||||
'Used by the YouTube subtitle loading flow as secondary language preferences.',
|
||||
],
|
||||
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
||||
key: 'secondarySub',
|
||||
@@ -130,8 +130,8 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
key: 'jimaku',
|
||||
},
|
||||
{
|
||||
title: 'YouTube Subtitle Generation',
|
||||
description: ['Defaults for SubMiner YouTube subtitle generation.'],
|
||||
title: 'YouTube Playback Settings',
|
||||
description: ['Defaults for SubMiner YouTube subtitle loading and languages.'],
|
||||
key: 'youtubeSubgen',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,6 +9,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
youtubePlay: undefined,
|
||||
youtubeMode: undefined,
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -184,6 +186,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('runJellyfinCommand');
|
||||
},
|
||||
runYoutubePlaybackFlow: async (request) => {
|
||||
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
|
||||
},
|
||||
printHelp: () => {
|
||||
calls.push('printHelp');
|
||||
},
|
||||
@@ -207,6 +212,58 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
return { deps, calls, osd };
|
||||
}
|
||||
|
||||
test('handleCliCommand starts youtube playback flow on initial launch', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
runYoutubePlaybackFlow: async (request) => {
|
||||
calls.push(`youtube:${request.url}:${request.mode}`);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'initializeOverlayRuntime',
|
||||
'youtube:https://youtube.com/watch?v=abc:generate',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handleCliCommand defaults youtube mode to download when omitted', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
runYoutubePlaybackFlow: async (request) => {
|
||||
calls.push(`youtube:${request.url}:${request.mode}`);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'initializeOverlayRuntime',
|
||||
'youtube:https://youtube.com/watch?v=abc:download',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
|
||||
const { deps, calls, osd } = createDeps({
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
throw new Error('yt failed');
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:')));
|
||||
assert.ok(osd.includes('YouTube playback failed: yt failed'));
|
||||
});
|
||||
|
||||
test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
|
||||
@@ -63,6 +63,11 @@ export interface CliCommandServiceDeps {
|
||||
}>;
|
||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
@@ -135,6 +140,7 @@ interface AnilistCliRuntime {
|
||||
interface AppCliRuntime {
|
||||
stop: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||
}
|
||||
|
||||
export interface CliCommandDepsRuntimeOptions {
|
||||
@@ -226,6 +232,7 @@ export function createCliCommandDepsRuntime(
|
||||
generateCharacterDictionary: options.dictionary.generate,
|
||||
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||
runJellyfinCommand: options.jellyfin.runCommand,
|
||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||
printHelp: options.ui.printHelp,
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
@@ -396,6 +403,19 @@ export function handleCliCommand(
|
||||
} else if (args.jellyfin) {
|
||||
deps.openJellyfinSetup();
|
||||
deps.log('Opened Jellyfin setup flow.');
|
||||
} else if (args.youtubePlay) {
|
||||
const youtubeUrl = args.youtubePlay;
|
||||
runAsyncWithOsd(
|
||||
() =>
|
||||
deps.runYoutubePlaybackFlow({
|
||||
url: youtubeUrl,
|
||||
mode: args.youtubeMode ?? 'download',
|
||||
source,
|
||||
}),
|
||||
deps,
|
||||
'runYoutubePlaybackFlow',
|
||||
'YouTube playback failed',
|
||||
);
|
||||
} else if (args.dictionary) {
|
||||
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||
deps.log('Generating character dictionary for current anime...');
|
||||
|
||||
@@ -37,6 +37,21 @@ async function waitForPendingAnimeMetadata(tracker: ImmersionTrackerService): Pr
|
||||
await privateApi.pendingAnimeMetadataUpdates?.get(videoId);
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 1_000,
|
||||
intervalMs = 10,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
assert.equal(predicate(), true);
|
||||
}
|
||||
|
||||
function makeMergedToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
surface: '',
|
||||
@@ -1269,6 +1284,40 @@ test('flushTelemetry checkpoints latest playback position on the active session
|
||||
}
|
||||
});
|
||||
|
||||
test('recordSubtitleLine advances session checkpoint progress when playback position is unavailable', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
tracker.handleMediaChange('https://stream.example.com/subtitle-progress.m3u8', 'Subtitle Progress');
|
||||
tracker.recordSubtitleLine('line one', 170, 185, [], null);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
db: DatabaseSync;
|
||||
sessionState: { sessionId: number } | null;
|
||||
flushTelemetry: (force?: boolean) => void;
|
||||
flushNow: () => void;
|
||||
};
|
||||
const sessionId = privateApi.sessionState?.sessionId;
|
||||
assert.ok(sessionId);
|
||||
|
||||
privateApi.flushTelemetry(true);
|
||||
privateApi.flushNow();
|
||||
|
||||
const row = privateApi.db
|
||||
.prepare('SELECT ended_media_ms FROM imm_sessions WHERE session_id = ?')
|
||||
.get(sessionId) as { ended_media_ms: number | null } | null;
|
||||
|
||||
assert.equal(row?.ended_media_ms, 185_000);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession ignores the currently active session and keeps new writes flushable', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -2297,6 +2346,565 @@ test('reassignAnimeAnilist preserves existing description when description is om
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMediaChange stores youtube metadata for new youtube sessions', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalPath = process.env.PATH;
|
||||
let fakeBinDir: string | null = null;
|
||||
|
||||
try {
|
||||
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-'));
|
||||
const ytDlpOutput =
|
||||
'{"id":"abc123","title":"Video Name","webpage_url":"https://www.youtube.com/watch?v=abc123","thumbnail":"https://i.ytimg.com/vi/abc123/hqdefault.jpg","channel_id":"UCcreator123","channel":"Creator Name","channel_url":"https://www.youtube.com/channel/UCcreator123","uploader_id":"@creator","uploader_url":"https://www.youtube.com/@creator","description":"Video description","channel_follower_count":12345,"thumbnails":[{"url":"https://i.ytimg.com/vi/abc123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/channel-avatar=s88"}]}';
|
||||
if (process.platform === 'win32') {
|
||||
const outputPath = path.join(fakeBinDir, 'output.json');
|
||||
fs.writeFileSync(outputPath, ytDlpOutput, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(fakeBinDir, 'yt-dlp.cmd'),
|
||||
'@echo off\r\ntype "%~dp0output.json"\r\n',
|
||||
'utf8',
|
||||
);
|
||||
} else {
|
||||
const scriptPath = path.join(fakeBinDir, 'yt-dlp');
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
`#!/bin/sh
|
||||
printf '%s\n' '${ytDlpOutput}'
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/oembed')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
thumbnail_url: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
};
|
||||
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
tracker.handleMediaChange('https://www.youtube.com/watch?v=abc123', 'Player Title');
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
await waitForCondition(
|
||||
() => {
|
||||
const stored = privateApi.db
|
||||
.prepare("SELECT 1 AS ready FROM imm_youtube_videos WHERE youtube_video_id = 'abc123'")
|
||||
.get() as { ready: number } | null;
|
||||
return stored?.ready === 1;
|
||||
},
|
||||
5_000,
|
||||
);
|
||||
const row = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
youtube_video_id AS youtubeVideoId,
|
||||
video_url AS videoUrl,
|
||||
video_title AS videoTitle,
|
||||
video_thumbnail_url AS videoThumbnailUrl,
|
||||
channel_id AS channelId,
|
||||
channel_name AS channelName,
|
||||
channel_url AS channelUrl,
|
||||
channel_thumbnail_url AS channelThumbnailUrl,
|
||||
uploader_id AS uploaderId,
|
||||
uploader_url AS uploaderUrl,
|
||||
description AS description
|
||||
FROM imm_youtube_videos
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
youtubeVideoId: string;
|
||||
videoUrl: string;
|
||||
videoTitle: string;
|
||||
videoThumbnailUrl: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
channelUrl: string;
|
||||
channelThumbnailUrl: string;
|
||||
uploaderId: string;
|
||||
uploaderUrl: string;
|
||||
description: string;
|
||||
} | null;
|
||||
const videoRow = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT canonical_title AS canonicalTitle
|
||||
FROM imm_videos
|
||||
WHERE video_id = 1
|
||||
`,
|
||||
)
|
||||
.get() as { canonicalTitle: string } | null;
|
||||
const animeRow = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.canonical_title AS canonicalTitle,
|
||||
v.parsed_title AS parsedTitle,
|
||||
v.parser_source AS parserSource
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
WHERE v.video_id = 1
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
canonicalTitle: string;
|
||||
parsedTitle: string | null;
|
||||
parserSource: string | null;
|
||||
} | null;
|
||||
|
||||
assert.ok(row);
|
||||
assert.ok(videoRow);
|
||||
assert.equal(row.youtubeVideoId, 'abc123');
|
||||
assert.equal(row.videoUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(row.videoTitle, 'Video Name');
|
||||
assert.equal(row.videoThumbnailUrl, 'https://i.ytimg.com/vi/abc123/hqdefault.jpg');
|
||||
assert.equal(row.channelId, 'UCcreator123');
|
||||
assert.equal(row.channelName, 'Creator Name');
|
||||
assert.equal(row.channelUrl, 'https://www.youtube.com/channel/UCcreator123');
|
||||
assert.equal(row.channelThumbnailUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
|
||||
assert.equal(row.uploaderId, '@creator');
|
||||
assert.equal(row.uploaderUrl, 'https://www.youtube.com/@creator');
|
||||
assert.equal(row.description, 'Video description');
|
||||
assert.equal(videoRow.canonicalTitle, 'Video Name');
|
||||
assert.equal(animeRow?.canonicalTitle, 'Creator Name');
|
||||
assert.equal(animeRow?.parsedTitle, 'Creator Name');
|
||||
assert.equal(animeRow?.parserSource, 'youtube');
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
globalThis.fetch = originalFetch;
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
if (fakeBinDir) {
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('getMediaLibrary lazily backfills missing youtube metadata for existing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
const originalPath = process.env.PATH;
|
||||
let fakeBinDir: string | null = null;
|
||||
|
||||
try {
|
||||
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-'));
|
||||
const ytDlpOutput =
|
||||
'{"id":"backfill123","title":"Backfilled Video Title","webpage_url":"https://www.youtube.com/watch?v=backfill123","thumbnail":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg","channel_id":"UCbackfill123","channel":"Backfill Creator","channel_url":"https://www.youtube.com/channel/UCbackfill123","uploader_id":"@backfill","uploader_url":"https://www.youtube.com/@backfill","description":"Backfilled description","thumbnails":[{"url":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/backfill-avatar=s88"}]}';
|
||||
if (process.platform === 'win32') {
|
||||
const outputPath = path.join(fakeBinDir, 'output.json');
|
||||
fs.writeFileSync(outputPath, ytDlpOutput, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(fakeBinDir, 'yt-dlp.cmd'),
|
||||
'@echo off\r\ntype "%~dp0output.json"\r\n',
|
||||
'utf8',
|
||||
);
|
||||
} else {
|
||||
const scriptPath = path.join(fakeBinDir, 'yt-dlp');
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
`#!/bin/sh
|
||||
printf '%s\n' '${ytDlpOutput}'
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const nowMs = Date.now();
|
||||
|
||||
privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_videos (
|
||||
video_key,
|
||||
canonical_title,
|
||||
source_type,
|
||||
source_path,
|
||||
source_url,
|
||||
duration_ms,
|
||||
file_size_bytes,
|
||||
codec_id,
|
||||
container_id,
|
||||
width_px,
|
||||
height_px,
|
||||
fps_x100,
|
||||
bitrate_kbps,
|
||||
audio_codec_id,
|
||||
hash_sha256,
|
||||
screenshot_path,
|
||||
metadata_json,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'remote:https://www.youtube.com/watch?v=backfill123',
|
||||
'watch?v=backfill123',
|
||||
2,
|
||||
null,
|
||||
'https://www.youtube.com/watch?v=backfill123',
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
nowMs,
|
||||
nowMs,
|
||||
);
|
||||
privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(1, 1, 5_000, 0, 0, 50, 0, nowMs, nowMs, nowMs, nowMs);
|
||||
|
||||
const before = await tracker.getMediaLibrary();
|
||||
assert.equal(before[0]?.channelName ?? null, null);
|
||||
|
||||
await waitForCondition(() => {
|
||||
const row = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
video_title AS videoTitle,
|
||||
channel_name AS channelName,
|
||||
channel_thumbnail_url AS channelThumbnailUrl
|
||||
FROM imm_youtube_videos
|
||||
WHERE video_id = 1
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
videoTitle: string | null;
|
||||
channelName: string | null;
|
||||
channelThumbnailUrl: string | null;
|
||||
} | null;
|
||||
return (
|
||||
row?.videoTitle === 'Backfilled Video Title' &&
|
||||
row.channelName === 'Backfill Creator' &&
|
||||
row.channelThumbnailUrl === 'https://yt3.googleusercontent.com/backfill-avatar=s88'
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
const after = await tracker.getMediaLibrary();
|
||||
assert.equal(after[0]?.videoTitle, 'Backfilled Video Title');
|
||||
assert.equal(after[0]?.channelName, 'Backfill Creator');
|
||||
assert.equal(
|
||||
after[0]?.channelThumbnailUrl,
|
||||
'https://yt3.googleusercontent.com/backfill-avatar=s88',
|
||||
);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
if (fakeBinDir) {
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const nowMs = Date.now();
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_anime (
|
||||
anime_id,
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 'watch v first', 'watch?v first', ${nowMs}, ${nowMs}),
|
||||
(2, 'watch v second', 'watch?v second', ${nowMs}, ${nowMs});
|
||||
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
anime_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
parsed_title,
|
||||
parser_source,
|
||||
source_type,
|
||||
source_path,
|
||||
source_url,
|
||||
duration_ms,
|
||||
file_size_bytes,
|
||||
codec_id,
|
||||
container_id,
|
||||
width_px,
|
||||
height_px,
|
||||
fps_x100,
|
||||
bitrate_kbps,
|
||||
audio_codec_id,
|
||||
hash_sha256,
|
||||
screenshot_path,
|
||||
metadata_json,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
1,
|
||||
'remote:https://www.youtube.com/watch?v=first',
|
||||
'watch?v first',
|
||||
'watch?v first',
|
||||
'fallback',
|
||||
2,
|
||||
NULL,
|
||||
'https://www.youtube.com/watch?v=first',
|
||||
0,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
),
|
||||
(
|
||||
2,
|
||||
2,
|
||||
'remote:https://www.youtube.com/watch?v=second',
|
||||
'watch?v second',
|
||||
'watch?v second',
|
||||
'fallback',
|
||||
2,
|
||||
NULL,
|
||||
'https://www.youtube.com/watch?v=second',
|
||||
0,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
);
|
||||
|
||||
INSERT INTO imm_youtube_videos (
|
||||
video_id,
|
||||
youtube_video_id,
|
||||
video_url,
|
||||
video_title,
|
||||
video_thumbnail_url,
|
||||
channel_id,
|
||||
channel_name,
|
||||
channel_url,
|
||||
channel_thumbnail_url,
|
||||
uploader_id,
|
||||
uploader_url,
|
||||
description,
|
||||
metadata_json,
|
||||
fetched_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
'first',
|
||||
'https://www.youtube.com/watch?v=first',
|
||||
'First Video',
|
||||
'https://i.ytimg.com/vi/first/hqdefault.jpg',
|
||||
'UCchannel1',
|
||||
'Shared Channel',
|
||||
'https://www.youtube.com/channel/UCchannel1',
|
||||
'https://yt3.googleusercontent.com/shared=s88',
|
||||
'@shared',
|
||||
'https://www.youtube.com/@shared',
|
||||
NULL,
|
||||
'{}',
|
||||
${nowMs},
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
),
|
||||
(
|
||||
2,
|
||||
'second',
|
||||
'https://www.youtube.com/watch?v=second',
|
||||
'Second Video',
|
||||
'https://i.ytimg.com/vi/second/hqdefault.jpg',
|
||||
'UCchannel1',
|
||||
'Shared Channel',
|
||||
'https://www.youtube.com/channel/UCchannel1',
|
||||
'https://yt3.googleusercontent.com/shared=s88',
|
||||
'@shared',
|
||||
'https://www.youtube.com/@shared',
|
||||
NULL,
|
||||
'{}',
|
||||
${nowMs},
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
);
|
||||
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
ended_at_ms,
|
||||
status,
|
||||
total_watched_ms,
|
||||
active_watched_ms,
|
||||
lines_seen,
|
||||
tokens_seen,
|
||||
cards_mined,
|
||||
lookup_count,
|
||||
lookup_hits,
|
||||
yomitan_lookup_count,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
'session-youtube-1',
|
||||
1,
|
||||
${nowMs - 70000},
|
||||
${nowMs - 10000},
|
||||
2,
|
||||
65000,
|
||||
60000,
|
||||
0,
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
),
|
||||
(
|
||||
2,
|
||||
'session-youtube-2',
|
||||
2,
|
||||
${nowMs - 50000},
|
||||
${nowMs - 5000},
|
||||
2,
|
||||
35000,
|
||||
30000,
|
||||
0,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
${nowMs},
|
||||
${nowMs}
|
||||
);
|
||||
|
||||
INSERT INTO imm_lifetime_anime (
|
||||
anime_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
episodes_started,
|
||||
episodes_completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 1, 60000, 0, 0, 100, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}),
|
||||
(2, 1, 30000, 0, 0, 50, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs});
|
||||
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 1, 60000, 0, 0, 100, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}),
|
||||
(2, 1, 30000, 0, 0, 50, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs});
|
||||
`);
|
||||
|
||||
const rows = await tracker.getAnimeLibrary();
|
||||
const sharedRows = rows.filter((row) => row.canonicalTitle === 'Shared Channel');
|
||||
|
||||
assert.equal(sharedRows.length, 1);
|
||||
assert.equal(sharedRows[0]?.episodeCount, 2);
|
||||
|
||||
const relinked = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT a.canonical_title AS canonicalTitle, COUNT(*) AS total
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
GROUP BY a.anime_id, a.canonical_title
|
||||
ORDER BY total DESC, a.anime_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ canonicalTitle: string; total: number }>;
|
||||
|
||||
assert.equal(relinked[0]?.canonicalTitle, 'Shared Channel');
|
||||
assert.equal(relinked[0]?.total, 2);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { createLogger } from '../../logger';
|
||||
import { MediaGenerator } from '../../media-generator';
|
||||
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
|
||||
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
|
||||
import {
|
||||
@@ -19,9 +20,11 @@ import {
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
linkVideoToAnimeRecord,
|
||||
linkYoutubeVideoToAnimeRecord,
|
||||
type TrackerPreparedStatements,
|
||||
updateVideoMetadataRecord,
|
||||
updateVideoTitleRecord,
|
||||
upsertYoutubeVideoMetadata,
|
||||
} from './immersion-tracker/storage';
|
||||
import {
|
||||
applySessionLifetimeSummary,
|
||||
@@ -153,6 +156,105 @@ import {
|
||||
import type { MergedToken } from '../../types';
|
||||
import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage';
|
||||
import { deriveStoredPartOfSpeech } from './tokenizer/part-of-speech';
|
||||
import { probeYoutubeVideoMetadata } from './youtube/metadata-probe';
|
||||
|
||||
const YOUTUBE_COVER_RETRY_MS = 5 * 60 * 1000;
|
||||
const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120;
|
||||
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
|
||||
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
|
||||
const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function isValidYouTubeVideoId(value: string | null): boolean {
|
||||
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
|
||||
}
|
||||
|
||||
function extractYouTubeVideoId(mediaUrl: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(mediaUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (
|
||||
host !== 'youtu.be' &&
|
||||
!host.endsWith('.youtu.be') &&
|
||||
!host.endsWith('youtube.com') &&
|
||||
!host.endsWith('youtube-nocookie.com')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
|
||||
const pathId = parsed.pathname.split('/').filter(Boolean)[0];
|
||||
return isValidYouTubeVideoId(pathId ?? null) ? (pathId as string) : null;
|
||||
}
|
||||
|
||||
const queryId = parsed.searchParams.get('v') ?? parsed.searchParams.get('vi') ?? null;
|
||||
if (isValidYouTubeVideoId(queryId)) {
|
||||
return queryId;
|
||||
}
|
||||
|
||||
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
||||
for (let i = 0; i < pathParts.length; i += 1) {
|
||||
const current = pathParts[i];
|
||||
const next = pathParts[i + 1];
|
||||
if (!current || !next) continue;
|
||||
if (
|
||||
current.toLowerCase() === 'shorts' ||
|
||||
current.toLowerCase() === 'embed' ||
|
||||
current.toLowerCase() === 'live' ||
|
||||
current.toLowerCase() === 'v'
|
||||
) {
|
||||
const candidate = decodeURIComponent(next);
|
||||
if (isValidYouTubeVideoId(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildYouTubeThumbnailUrls(videoId: string): string[] {
|
||||
return [
|
||||
`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
|
||||
`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
||||
`https://i.ytimg.com/vi/${videoId}/sddefault.jpg`,
|
||||
`https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
||||
`https://i.ytimg.com/vi/${videoId}/0.jpg`,
|
||||
`https://i.ytimg.com/vi/${videoId}/default.jpg`,
|
||||
];
|
||||
}
|
||||
|
||||
async function fetchYouTubeOEmbedThumbnail(mediaUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${YOUTUBE_OEMBED_ENDPOINT}?url=${encodeURIComponent(mediaUrl)}&format=json`);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const payload = (await response.json()) as { thumbnail_url?: unknown };
|
||||
const candidate = typeof payload.thumbnail_url === 'string' ? payload.thumbnail_url.trim() : '';
|
||||
return candidate || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && !contentType.toLowerCase().startsWith('image/')) {
|
||||
return null;
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
AnimeAnilistEntryRow,
|
||||
@@ -212,9 +314,11 @@ export class ImmersionTrackerService {
|
||||
private sessionState: SessionState | null = null;
|
||||
private currentVideoKey = '';
|
||||
private currentMediaPathOrUrl = '';
|
||||
private readonly mediaGenerator = new MediaGenerator();
|
||||
private readonly preparedStatements: TrackerPreparedStatements;
|
||||
private coverArtFetcher: CoverArtFetcher | null = null;
|
||||
private readonly pendingCoverFetches = new Map<number, Promise<boolean>>();
|
||||
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
||||
private readonly recordedSubtitleKeys = new Set<string>();
|
||||
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
||||
private readonly resolveLegacyVocabularyPos:
|
||||
@@ -433,11 +537,15 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async getMediaLibrary(): Promise<MediaLibraryRow[]> {
|
||||
return getMediaLibrary(this.db);
|
||||
const rows = getMediaLibrary(this.db);
|
||||
this.backfillYoutubeMetadataForLibrary();
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getMediaDetail(videoId: number): Promise<MediaDetailRow | null> {
|
||||
return getMediaDetail(this.db, videoId);
|
||||
const detail = getMediaDetail(this.db, videoId);
|
||||
this.backfillYoutubeMetadataForVideo(videoId);
|
||||
return detail;
|
||||
}
|
||||
|
||||
async getMediaSessions(videoId: number, limit = 100): Promise<SessionSummaryQueryRow[]> {
|
||||
@@ -453,10 +561,12 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async getAnimeLibrary(): Promise<AnimeLibraryRow[]> {
|
||||
this.relinkYoutubeAnimeLibrary();
|
||||
return getAnimeLibrary(this.db);
|
||||
}
|
||||
|
||||
async getAnimeDetail(animeId: number): Promise<AnimeDetailRow | null> {
|
||||
this.relinkYoutubeAnimeLibrary();
|
||||
return getAnimeDetail(this.db, animeId);
|
||||
}
|
||||
|
||||
@@ -647,6 +757,17 @@ export class ImmersionTrackerService {
|
||||
if (existing?.coverBlob) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const row = this.db
|
||||
.prepare('SELECT source_url AS sourceUrl FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { sourceUrl: string | null } | null;
|
||||
const sourceUrl = row?.sourceUrl?.trim() ?? '';
|
||||
const youtubeVideoId = sourceUrl ? extractYouTubeVideoId(sourceUrl) : null;
|
||||
if (youtubeVideoId) {
|
||||
const youtubePromise = this.ensureYouTubeCoverArt(videoId, sourceUrl, youtubeVideoId);
|
||||
return await youtubePromise;
|
||||
}
|
||||
|
||||
if (!this.coverArtFetcher) {
|
||||
return false;
|
||||
}
|
||||
@@ -677,6 +798,312 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureYouTubeCoverArt(videoId: number, sourceUrl: string, youtubeVideoId: string): Promise<boolean> {
|
||||
const existing = this.pendingCoverFetches.get(videoId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const promise = this.captureYouTubeCoverArt(videoId, sourceUrl, youtubeVideoId);
|
||||
this.pendingCoverFetches.set(videoId, promise);
|
||||
promise.finally(() => {
|
||||
this.pendingCoverFetches.delete(videoId);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async captureYouTubeCoverArt(
|
||||
videoId: number,
|
||||
sourceUrl: string,
|
||||
youtubeVideoId: string,
|
||||
): Promise<boolean> {
|
||||
if (this.isDestroyed) return false;
|
||||
const existing = await this.getCoverArt(videoId);
|
||||
if (existing?.coverBlob) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
existing?.coverUrl === null &&
|
||||
existing?.anilistId === null &&
|
||||
existing?.coverBlob === null &&
|
||||
Date.now() - existing.fetchedAtMs < YOUTUBE_COVER_RETRY_MS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let coverBlob: Buffer | null = null;
|
||||
let coverUrl: string | null = null;
|
||||
|
||||
const embedThumbnailUrl = await fetchYouTubeOEmbedThumbnail(sourceUrl);
|
||||
if (embedThumbnailUrl) {
|
||||
const embedBlob = await downloadImage(embedThumbnailUrl);
|
||||
if (embedBlob) {
|
||||
coverBlob = embedBlob;
|
||||
coverUrl = embedThumbnailUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!coverBlob) {
|
||||
for (const candidate of buildYouTubeThumbnailUrls(youtubeVideoId)) {
|
||||
const candidateBlob = await downloadImage(candidate);
|
||||
if (!candidateBlob) {
|
||||
continue;
|
||||
}
|
||||
coverBlob = candidateBlob;
|
||||
coverUrl = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!coverBlob) {
|
||||
const durationMs = getVideoDurationMs(this.db, videoId);
|
||||
const maxSeconds = durationMs > 0 ? Math.min(durationMs / 1000, YOUTUBE_SCREENSHOT_MAX_SECONDS) : null;
|
||||
const seekSecond = Math.random() * (maxSeconds ?? YOUTUBE_SCREENSHOT_MAX_SECONDS);
|
||||
try {
|
||||
coverBlob = await this.mediaGenerator.generateScreenshot(
|
||||
sourceUrl,
|
||||
seekSecond,
|
||||
{
|
||||
format: 'jpg',
|
||||
quality: 90,
|
||||
maxWidth: 640,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'cover-art: failed to generate YouTube screenshot for videoId=%d: %s',
|
||||
videoId,
|
||||
(error as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (coverBlob) {
|
||||
upsertCoverArt(this.db, videoId, {
|
||||
anilistId: existing?.anilistId ?? null,
|
||||
coverUrl,
|
||||
coverBlob,
|
||||
titleRomaji: existing?.titleRomaji ?? null,
|
||||
titleEnglish: existing?.titleEnglish ?? null,
|
||||
episodesTotal: existing?.episodesTotal ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const shouldCacheNoMatch =
|
||||
!existing || (existing.coverUrl === null && existing.anilistId === null);
|
||||
if (shouldCacheNoMatch) {
|
||||
upsertCoverArt(this.db, videoId, {
|
||||
anilistId: null,
|
||||
coverUrl: null,
|
||||
coverBlob: null,
|
||||
titleRomaji: existing?.titleRomaji ?? null,
|
||||
titleEnglish: existing?.titleEnglish ?? null,
|
||||
episodesTotal: existing?.episodesTotal ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private captureYoutubeMetadataAsync(videoId: number, sourceUrl: string): void {
|
||||
if (this.pendingYoutubeMetadataFetches.has(videoId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const metadata = await probeYoutubeVideoMetadata(sourceUrl);
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
upsertYoutubeVideoMetadata(this.db, videoId, metadata);
|
||||
linkYoutubeVideoToAnimeRecord(this.db, videoId, metadata);
|
||||
if (metadata.videoTitle?.trim()) {
|
||||
updateVideoTitleRecord(this.db, videoId, metadata.videoTitle.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
'youtube metadata capture skipped for videoId=%d: %s',
|
||||
videoId,
|
||||
(error as Error).message,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingYoutubeMetadataFetches.set(videoId, pending);
|
||||
pending.finally(() => {
|
||||
this.pendingYoutubeMetadataFetches.delete(videoId);
|
||||
});
|
||||
}
|
||||
|
||||
private backfillYoutubeMetadataForLibrary(): void {
|
||||
const candidate = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.video_id AS videoId,
|
||||
v.source_url AS sourceUrl
|
||||
FROM imm_videos v
|
||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
WHERE
|
||||
v.source_type = ?
|
||||
AND v.source_url IS NOT NULL
|
||||
AND (
|
||||
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||
)
|
||||
AND (
|
||||
yv.video_id IS NULL
|
||||
OR yv.video_title IS NULL
|
||||
OR yv.channel_name IS NULL
|
||||
OR yv.channel_thumbnail_url IS NULL
|
||||
)
|
||||
AND (
|
||||
yv.fetched_at_ms IS NULL
|
||||
OR yv.fetched_at_ms <= ?
|
||||
)
|
||||
ORDER BY lm.last_watched_ms DESC, v.video_id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(
|
||||
SOURCE_TYPE_REMOTE,
|
||||
Date.now() - YOUTUBE_METADATA_REFRESH_MS,
|
||||
) as { videoId: number; sourceUrl: string | null } | null;
|
||||
if (!candidate?.sourceUrl) {
|
||||
return;
|
||||
}
|
||||
this.captureYoutubeMetadataAsync(candidate.videoId, candidate.sourceUrl);
|
||||
}
|
||||
|
||||
private backfillYoutubeMetadataForVideo(videoId: number): void {
|
||||
const candidate = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.source_url AS sourceUrl
|
||||
FROM imm_videos v
|
||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
WHERE
|
||||
v.video_id = ?
|
||||
AND v.source_type = ?
|
||||
AND v.source_url IS NOT NULL
|
||||
AND (
|
||||
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||
)
|
||||
AND (
|
||||
yv.video_id IS NULL
|
||||
OR yv.video_title IS NULL
|
||||
OR yv.channel_name IS NULL
|
||||
OR yv.channel_thumbnail_url IS NULL
|
||||
)
|
||||
AND (
|
||||
yv.fetched_at_ms IS NULL
|
||||
OR yv.fetched_at_ms <= ?
|
||||
)
|
||||
`,
|
||||
)
|
||||
.get(
|
||||
videoId,
|
||||
SOURCE_TYPE_REMOTE,
|
||||
Date.now() - YOUTUBE_METADATA_REFRESH_MS,
|
||||
) as { sourceUrl: string | null } | null;
|
||||
if (!candidate?.sourceUrl) {
|
||||
return;
|
||||
}
|
||||
this.captureYoutubeMetadataAsync(videoId, candidate.sourceUrl);
|
||||
}
|
||||
|
||||
private relinkYoutubeAnimeLibrary(): void {
|
||||
const candidates = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.video_id AS videoId,
|
||||
yv.youtube_video_id AS youtubeVideoId,
|
||||
yv.video_url AS videoUrl,
|
||||
yv.video_title AS videoTitle,
|
||||
yv.video_thumbnail_url AS videoThumbnailUrl,
|
||||
yv.channel_id AS channelId,
|
||||
yv.channel_name AS channelName,
|
||||
yv.channel_url AS channelUrl,
|
||||
yv.channel_thumbnail_url AS channelThumbnailUrl,
|
||||
yv.uploader_id AS uploaderId,
|
||||
yv.uploader_url AS uploaderUrl,
|
||||
yv.description AS description,
|
||||
yv.metadata_json AS metadataJson
|
||||
FROM imm_videos v
|
||||
JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
WHERE
|
||||
v.source_type = ?
|
||||
AND v.source_url IS NOT NULL
|
||||
AND (
|
||||
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||
)
|
||||
AND yv.channel_name IS NOT NULL
|
||||
AND (
|
||||
v.anime_id IS NULL
|
||||
OR a.metadata_json IS NULL
|
||||
OR a.metadata_json NOT LIKE '%"source":"youtube-channel"%'
|
||||
OR a.canonical_title IS NULL
|
||||
OR TRIM(a.canonical_title) != TRIM(yv.channel_name)
|
||||
)
|
||||
ORDER BY lm.last_watched_ms DESC, v.video_id DESC
|
||||
`,
|
||||
)
|
||||
.all(SOURCE_TYPE_REMOTE) as Array<{
|
||||
videoId: number;
|
||||
youtubeVideoId: string | null;
|
||||
videoUrl: string | null;
|
||||
videoTitle: string | null;
|
||||
videoThumbnailUrl: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
channelUrl: string | null;
|
||||
channelThumbnailUrl: string | null;
|
||||
uploaderId: string | null;
|
||||
uploaderUrl: string | null;
|
||||
description: string | null;
|
||||
metadataJson: string | null;
|
||||
}>;
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.youtubeVideoId || !candidate.videoUrl) {
|
||||
continue;
|
||||
}
|
||||
linkYoutubeVideoToAnimeRecord(this.db, candidate.videoId, {
|
||||
youtubeVideoId: candidate.youtubeVideoId,
|
||||
videoUrl: candidate.videoUrl,
|
||||
videoTitle: candidate.videoTitle,
|
||||
videoThumbnailUrl: candidate.videoThumbnailUrl,
|
||||
channelId: candidate.channelId,
|
||||
channelName: candidate.channelName,
|
||||
channelUrl: candidate.channelUrl,
|
||||
channelThumbnailUrl: candidate.channelThumbnailUrl,
|
||||
uploaderId: candidate.uploaderId,
|
||||
uploaderUrl: candidate.uploaderUrl,
|
||||
description: candidate.description,
|
||||
metadataJson: candidate.metadataJson,
|
||||
});
|
||||
}
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
|
||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
@@ -721,7 +1148,14 @@ export class ImmersionTrackerService {
|
||||
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
||||
);
|
||||
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
const youtubeVideoId =
|
||||
sourceType === SOURCE_TYPE_REMOTE ? extractYouTubeVideoId(normalizedPath) : null;
|
||||
if (youtubeVideoId) {
|
||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||
} else {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||
}
|
||||
|
||||
@@ -749,6 +1183,7 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
const startMs = secToMs(startSec);
|
||||
const endMs = secToMs(endSec);
|
||||
const subtitleKey = `${startMs}:${cleaned}`;
|
||||
if (this.recordedSubtitleKeys.has(subtitleKey)) {
|
||||
return;
|
||||
@@ -762,6 +1197,9 @@ export class ImmersionTrackerService {
|
||||
this.sessionState.currentLineIndex += 1;
|
||||
this.sessionState.linesSeen += 1;
|
||||
this.sessionState.tokensSeen += tokenCount;
|
||||
if (this.sessionState.lastMediaMs === null || endMs > this.sessionState.lastMediaMs) {
|
||||
this.sessionState.lastMediaMs = endMs;
|
||||
}
|
||||
this.sessionState.pendingTelemetry = true;
|
||||
|
||||
const wordOccurrences = new Map<string, CountedWordOccurrence>();
|
||||
@@ -811,8 +1249,8 @@ export class ImmersionTrackerService {
|
||||
sessionId: this.sessionState.sessionId,
|
||||
videoId: this.sessionState.videoId,
|
||||
lineIndex: this.sessionState.currentLineIndex,
|
||||
segmentStartMs: secToMs(startSec),
|
||||
segmentEndMs: secToMs(endSec),
|
||||
segmentStartMs: startMs,
|
||||
segmentEndMs: endMs,
|
||||
text: cleaned,
|
||||
secondaryText: secondaryText ?? null,
|
||||
wordOccurrences: Array.from(wordOccurrences.values()),
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '../query.js';
|
||||
import {
|
||||
SOURCE_TYPE_LOCAL,
|
||||
SOURCE_TYPE_REMOTE,
|
||||
EVENT_CARD_MINED,
|
||||
EVENT_SUBTITLE_LINE,
|
||||
EVENT_YOMITAN_LOOKUP,
|
||||
@@ -279,6 +280,78 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio
|
||||
}
|
||||
});
|
||||
|
||||
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=zero123', {
|
||||
canonicalTitle: 'Zero Checkpoint Stream',
|
||||
sourcePath: null,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=zero123',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Zero Checkpoint Anime',
|
||||
canonicalTitle: 'Zero Checkpoint Anime',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: 'watch?v=zero123',
|
||||
parsedTitle: 'Zero Checkpoint Anime',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: '{"episode":1}',
|
||||
});
|
||||
db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(600_000, videoId);
|
||||
|
||||
const startedAtMs = 1_200_000;
|
||||
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
status = 2,
|
||||
ended_media_ms = 0,
|
||||
active_watched_ms = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(startedAtMs + 30_000, 180_000, startedAtMs + 30_000, sessionId);
|
||||
stmts.eventInsertStmt.run(
|
||||
sessionId,
|
||||
startedAtMs + 29_000,
|
||||
EVENT_SUBTITLE_LINE,
|
||||
1,
|
||||
170_000,
|
||||
185_000,
|
||||
4,
|
||||
0,
|
||||
'{"line":"stream progress"}',
|
||||
startedAtMs + 29_000,
|
||||
startedAtMs + 29_000,
|
||||
);
|
||||
|
||||
const [episode] = getAnimeEpisodes(db, animeId);
|
||||
assert.ok(episode);
|
||||
assert.equal(episode?.endedMediaMs, 185_000);
|
||||
assert.equal(episode?.durationMs, 600_000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -1956,6 +2029,100 @@ test('media library and detail queries read lifetime totals', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('media library and detail queries include joined youtube metadata when present', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const mediaOne = getOrCreateVideoRecord(db, 'yt:https://www.youtube.com/watch?v=abc123', {
|
||||
canonicalTitle: 'Local Fallback Title',
|
||||
sourcePath: null,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(mediaOne, 2, 6_000, 1, 5, 80, 0, 1_000, 9_000, 9_000, 9_000);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_youtube_videos (
|
||||
video_id,
|
||||
youtube_video_id,
|
||||
video_url,
|
||||
video_title,
|
||||
video_thumbnail_url,
|
||||
channel_id,
|
||||
channel_name,
|
||||
channel_url,
|
||||
channel_thumbnail_url,
|
||||
uploader_id,
|
||||
uploader_url,
|
||||
description,
|
||||
metadata_json,
|
||||
fetched_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
mediaOne,
|
||||
'abc123',
|
||||
'https://www.youtube.com/watch?v=abc123',
|
||||
'Tracked Video Title',
|
||||
'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||
'UCcreator123',
|
||||
'Creator Name',
|
||||
'https://www.youtube.com/channel/UCcreator123',
|
||||
'https://yt3.googleusercontent.com/channel-avatar=s88',
|
||||
'@creator',
|
||||
'https://www.youtube.com/@creator',
|
||||
'Video description',
|
||||
'{"source":"test"}',
|
||||
10_000,
|
||||
10_000,
|
||||
10_000,
|
||||
);
|
||||
|
||||
const library = getMediaLibrary(db);
|
||||
const detail = getMediaDetail(db, mediaOne);
|
||||
|
||||
assert.equal(library.length, 1);
|
||||
assert.equal(library[0]?.youtubeVideoId, 'abc123');
|
||||
assert.equal(library[0]?.videoTitle, 'Tracked Video Title');
|
||||
assert.equal(library[0]?.channelId, 'UCcreator123');
|
||||
assert.equal(library[0]?.channelName, 'Creator Name');
|
||||
assert.equal(library[0]?.channelUrl, 'https://www.youtube.com/channel/UCcreator123');
|
||||
assert.equal(detail?.youtubeVideoId, 'abc123');
|
||||
assert.equal(detail?.videoUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(detail?.videoThumbnailUrl, 'https://i.ytimg.com/vi/abc123/hqdefault.jpg');
|
||||
assert.equal(detail?.channelThumbnailUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
|
||||
assert.equal(detail?.uploaderId, '@creator');
|
||||
assert.equal(detail?.uploaderUrl, 'https://www.youtube.com/@creator');
|
||||
assert.equal(detail?.description, 'Video description');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -2679,3 +2846,200 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession removes zero-session media from library and trends', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Delete Me Anime',
|
||||
canonicalTitle: 'Delete Me Anime',
|
||||
anilistId: 404_404,
|
||||
titleRomaji: 'Delete Me Anime',
|
||||
titleEnglish: 'Delete Me Anime',
|
||||
titleNative: 'Delete Me Anime',
|
||||
metadataJson: null,
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/delete-last-session.mkv', {
|
||||
canonicalTitle: 'Delete Last Session',
|
||||
sourcePath: '/tmp/delete-last-session.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: 'Delete Last Session',
|
||||
parsedTitle: 'Delete Me Anime',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: '{"episode":1}',
|
||||
});
|
||||
|
||||
const startedAtMs = 9_000_000;
|
||||
const endedAtMs = startedAtMs + 120_000;
|
||||
const rollupDay = Math.floor(startedAtMs / 86_400_000);
|
||||
const rollupMonth = 197001;
|
||||
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
ended_media_ms = ?,
|
||||
total_watched_ms = ?,
|
||||
active_watched_ms = ?,
|
||||
lines_seen = ?,
|
||||
tokens_seen = ?,
|
||||
cards_mined = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(endedAtMs, 120000, 120000, 120000, 12, 120, 3, endedAtMs, sessionId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_applied_sessions (
|
||||
session_id,
|
||||
applied_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?)
|
||||
`,
|
||||
).run(sessionId, endedAtMs, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(videoId, 1, 120_000, 3, 12, 120, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_anime (
|
||||
anime_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
episodes_started,
|
||||
episodes_completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(animeId, 1, 120000, 3, 12, 120, 1, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_lifetime_global
|
||||
SET
|
||||
total_sessions = 1,
|
||||
total_active_ms = 120000,
|
||||
total_cards = 3,
|
||||
active_days = 1,
|
||||
episodes_started = 1,
|
||||
episodes_completed = 0,
|
||||
anime_completed = 0,
|
||||
last_rebuilt_ms = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day,
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_min,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
total_cards,
|
||||
cards_per_hour,
|
||||
tokens_per_min,
|
||||
lookup_hit_rate,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(rollupDay, videoId, 1, 2, 12, 120, 3, 90, 60, null, endedAtMs, endedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month,
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_min,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
total_cards,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(rollupMonth, videoId, 1, 2, 12, 120, 3, endedAtMs, endedAtMs);
|
||||
|
||||
deleteSession(db, sessionId);
|
||||
|
||||
assert.deepEqual(getMediaLibrary(db), []);
|
||||
assert.equal(getMediaDetail(db, videoId) ?? null, null);
|
||||
assert.deepEqual(getAnimeLibrary(db), []);
|
||||
assert.equal(getAnimeDetail(db, animeId) ?? null, null);
|
||||
|
||||
const trends = getTrendsDashboard(db, 'all', 'day');
|
||||
assert.deepEqual(trends.activity.watchTime, []);
|
||||
assert.deepEqual(trends.activity.sessions, []);
|
||||
|
||||
const dailyRollups = getDailyRollups(db, 30);
|
||||
const monthlyRollups = getMonthlyRollups(db, 30);
|
||||
assert.deepEqual(dailyRollups, []);
|
||||
assert.deepEqual(monthlyRollups, []);
|
||||
|
||||
const lifetimeMediaCount = Number(
|
||||
(
|
||||
db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?').get(
|
||||
videoId,
|
||||
) as { total: number }
|
||||
).total,
|
||||
);
|
||||
const lifetimeAnimeCount = Number(
|
||||
(
|
||||
db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime WHERE anime_id = ?').get(
|
||||
animeId,
|
||||
) as { total: number }
|
||||
).total,
|
||||
);
|
||||
const appliedSessionCount = Number(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions WHERE session_id = ?')
|
||||
.get(sessionId) as { total: number }
|
||||
).total,
|
||||
);
|
||||
|
||||
assert.equal(lifetimeMediaCount, 0);
|
||||
assert.equal(lifetimeAnimeCount, 0);
|
||||
assert.equal(appliedSessionCount, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,6 +134,49 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
||||
).run(nowMs, nowMs);
|
||||
}
|
||||
|
||||
function rebuildLifetimeSummariesInternal(
|
||||
db: DatabaseSync,
|
||||
rebuiltAtMs: number,
|
||||
): LifetimeRebuildSummary {
|
||||
const sessions = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
session_id AS sessionId,
|
||||
video_id AS videoId,
|
||||
started_at_ms AS startedAtMs,
|
||||
ended_at_ms AS endedAtMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
tokens_seen AS tokensSeen,
|
||||
cards_mined AS cardsMined,
|
||||
lookup_count AS lookupCount,
|
||||
lookup_hits AS lookupHits,
|
||||
yomitan_lookup_count AS yomitanLookupCount,
|
||||
pause_count AS pauseCount,
|
||||
pause_ms AS pauseMs,
|
||||
seek_forward_count AS seekForwardCount,
|
||||
seek_backward_count AS seekBackwardCount,
|
||||
media_buffer_events AS mediaBufferEvents
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
ORDER BY started_at_ms ASC, session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as RetainedSessionRow[];
|
||||
|
||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||
for (const session of sessions) {
|
||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
||||
}
|
||||
|
||||
return {
|
||||
appliedSessions: sessions.length,
|
||||
rebuiltAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
@@ -482,50 +525,22 @@ export function applySessionLifetimeSummary(
|
||||
|
||||
export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary {
|
||||
const rebuiltAtMs = Date.now();
|
||||
const sessions = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
session_id AS sessionId,
|
||||
video_id AS videoId,
|
||||
started_at_ms AS startedAtMs,
|
||||
ended_at_ms AS endedAtMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
tokens_seen AS tokensSeen,
|
||||
cards_mined AS cardsMined,
|
||||
lookup_count AS lookupCount,
|
||||
lookup_hits AS lookupHits,
|
||||
yomitan_lookup_count AS yomitanLookupCount,
|
||||
pause_count AS pauseCount,
|
||||
pause_ms AS pauseMs,
|
||||
seek_forward_count AS seekForwardCount,
|
||||
seek_backward_count AS seekBackwardCount,
|
||||
media_buffer_events AS mediaBufferEvents
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
ORDER BY started_at_ms ASC, session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as RetainedSessionRow[];
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||
for (const session of sessions) {
|
||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
||||
}
|
||||
const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs);
|
||||
db.exec('COMMIT');
|
||||
return summary;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appliedSessions: sessions.length,
|
||||
rebuiltAtMs,
|
||||
};
|
||||
export function rebuildLifetimeSummariesInTransaction(
|
||||
db: DatabaseSync,
|
||||
rebuiltAtMs = Date.now(),
|
||||
): LifetimeRebuildSummary {
|
||||
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
|
||||
}
|
||||
|
||||
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
|
||||
|
||||
@@ -113,6 +113,14 @@ function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number): void {
|
||||
).run(ROLLUP_STATE_KEY, sampleMs);
|
||||
}
|
||||
|
||||
function resetRollups(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
DELETE FROM imm_daily_rollups;
|
||||
DELETE FROM imm_monthly_rollups;
|
||||
`);
|
||||
setLastRollupSampleMs(db, ZERO_ID);
|
||||
}
|
||||
|
||||
function upsertDailyRollupsForGroups(
|
||||
db: DatabaseSync,
|
||||
groups: Array<{ rollupDay: number; videoId: number }>,
|
||||
@@ -281,8 +289,20 @@ function dedupeGroups<T extends { rollupDay?: number; rollupMonth?: number; vide
|
||||
}
|
||||
|
||||
export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): void {
|
||||
if (forceRebuild) {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupNowMs = Date.now();
|
||||
const lastRollupSampleMs = forceRebuild ? ZERO_ID : getLastRollupSampleMs(db);
|
||||
const lastRollupSampleMs = getLastRollupSampleMs(db);
|
||||
|
||||
const maxSampleRow = db
|
||||
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||
@@ -324,6 +344,41 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
|
||||
}
|
||||
}
|
||||
|
||||
export function rebuildRollupsInTransaction(db: DatabaseSync): void {
|
||||
const rollupNowMs = Date.now();
|
||||
const maxSampleRow = db
|
||||
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||
.get() as unknown as RollupTelemetryResult | null;
|
||||
|
||||
resetRollups(db);
|
||||
if (!maxSampleRow?.maxSampleMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID);
|
||||
if (affectedGroups.length === 0) {
|
||||
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
|
||||
return;
|
||||
}
|
||||
|
||||
const dailyGroups = dedupeGroups(
|
||||
affectedGroups.map((group) => ({
|
||||
rollupDay: group.rollupDay,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
const monthlyGroups = dedupeGroups(
|
||||
affectedGroups.map((group) => ({
|
||||
rollupMonth: group.rollupMonth,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
|
||||
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
|
||||
}
|
||||
|
||||
export function runOptimizeMaintenance(db: DatabaseSync): void {
|
||||
db.exec('PRAGMA optimize');
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import type {
|
||||
VocabularyStatsRow,
|
||||
} from './types';
|
||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { rebuildRollupsInTransaction } from './maintenance';
|
||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||
@@ -1746,7 +1748,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
||||
v.duration_ms AS durationMs,
|
||||
(
|
||||
SELECT COALESCE(
|
||||
s_recent.ended_media_ms,
|
||||
NULLIF(s_recent.ended_media_ms, 0),
|
||||
(
|
||||
SELECT MAX(line.segment_end_ms)
|
||||
FROM imm_subtitle_lines line
|
||||
@@ -1817,6 +1819,17 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
|
||||
yv.youtube_video_id AS youtubeVideoId,
|
||||
yv.video_url AS videoUrl,
|
||||
yv.video_title AS videoTitle,
|
||||
yv.video_thumbnail_url AS videoThumbnailUrl,
|
||||
yv.channel_id AS channelId,
|
||||
yv.channel_name AS channelName,
|
||||
yv.channel_url AS channelUrl,
|
||||
yv.channel_thumbnail_url AS channelThumbnailUrl,
|
||||
yv.uploader_id AS uploaderId,
|
||||
yv.uploader_url AS uploaderUrl,
|
||||
yv.description AS description,
|
||||
CASE
|
||||
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
|
||||
ELSE 0
|
||||
@@ -1824,6 +1837,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
||||
FROM imm_videos v
|
||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id
|
||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
ORDER BY lm.last_watched_ms DESC
|
||||
`,
|
||||
)
|
||||
@@ -1846,9 +1860,21 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
|
||||
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
||||
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
|
||||
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount
|
||||
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
||||
yv.youtube_video_id AS youtubeVideoId,
|
||||
yv.video_url AS videoUrl,
|
||||
yv.video_title AS videoTitle,
|
||||
yv.video_thumbnail_url AS videoThumbnailUrl,
|
||||
yv.channel_id AS channelId,
|
||||
yv.channel_name AS channelName,
|
||||
yv.channel_url AS channelUrl,
|
||||
yv.channel_thumbnail_url AS channelThumbnailUrl,
|
||||
yv.uploader_id AS uploaderId,
|
||||
yv.uploader_url AS uploaderUrl,
|
||||
yv.description AS description
|
||||
FROM imm_videos v
|
||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||
WHERE v.video_id = ?
|
||||
@@ -2443,6 +2469,8 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -2459,6 +2487,8 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -2495,6 +2525,8 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
|
||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
|
||||
@@ -15,8 +15,14 @@ import {
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
linkVideoToAnimeRecord,
|
||||
linkYoutubeVideoToAnimeRecord,
|
||||
} from './storage';
|
||||
import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types';
|
||||
import {
|
||||
EVENT_SUBTITLE_LINE,
|
||||
SESSION_STATUS_ENDED,
|
||||
SOURCE_TYPE_LOCAL,
|
||||
SOURCE_TYPE_REMOTE,
|
||||
} from './types';
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
|
||||
@@ -106,6 +112,7 @@ test('ensureSchema creates immersion core tables', () => {
|
||||
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
||||
assert.ok(tableNames.has('imm_rollup_state'));
|
||||
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
||||
assert.ok(tableNames.has('imm_youtube_videos'));
|
||||
|
||||
const videoColumns = new Set(
|
||||
(
|
||||
@@ -146,6 +153,114 @@ test('ensureSchema creates immersion core tables', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds youtube metadata table to existing schema version 15 databases', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE imm_schema_version (
|
||||
schema_version INTEGER PRIMARY KEY,
|
||||
applied_at_ms INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (15, 1000);
|
||||
|
||||
CREATE TABLE imm_rollup_state(
|
||||
state_key TEXT PRIMARY KEY,
|
||||
state_value INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 123);
|
||||
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
normalized_title_key TEXT NOT NULL UNIQUE,
|
||||
canonical_title TEXT NOT NULL,
|
||||
anilist_id INTEGER UNIQUE,
|
||||
title_romaji TEXT,
|
||||
title_english TEXT,
|
||||
title_native TEXT,
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_key TEXT NOT NULL UNIQUE,
|
||||
anime_id INTEGER,
|
||||
canonical_title TEXT NOT NULL,
|
||||
source_type INTEGER NOT NULL,
|
||||
source_path TEXT,
|
||||
source_url TEXT,
|
||||
parsed_basename TEXT,
|
||||
parsed_title TEXT,
|
||||
parsed_season INTEGER,
|
||||
parsed_episode INTEGER,
|
||||
parser_source TEXT,
|
||||
parser_confidence REAL,
|
||||
parse_metadata_json TEXT,
|
||||
watched INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||
codec_id INTEGER, container_id INTEGER,
|
||||
width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
|
||||
bitrate_kbps INTEGER, audio_codec_id INTEGER,
|
||||
hash_sha256 TEXT, screenshot_path TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
|
||||
ensureSchema(db);
|
||||
|
||||
const tables = new Set(
|
||||
(
|
||||
db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`).all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
assert.ok(tables.has('imm_youtube_videos'));
|
||||
|
||||
const columns = new Set(
|
||||
(
|
||||
db.prepare('PRAGMA table_info(imm_youtube_videos)').all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
columns,
|
||||
new Set([
|
||||
'video_id',
|
||||
'youtube_video_id',
|
||||
'video_url',
|
||||
'video_title',
|
||||
'video_thumbnail_url',
|
||||
'channel_id',
|
||||
'channel_name',
|
||||
'channel_url',
|
||||
'channel_thumbnail_url',
|
||||
'uploader_id',
|
||||
'uploader_url',
|
||||
'description',
|
||||
'metadata_json',
|
||||
'fetched_at_ms',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema creates large-history performance indexes', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -169,6 +284,8 @@ test('ensureSchema creates large-history performance indexes', () => {
|
||||
assert.ok(indexNames.has('idx_kanji_frequency'));
|
||||
assert.ok(indexNames.has('idx_media_art_anilist_id'));
|
||||
assert.ok(indexNames.has('idx_media_art_cover_url'));
|
||||
assert.ok(indexNames.has('idx_youtube_videos_channel_id'));
|
||||
assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id'));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
@@ -706,6 +823,123 @@ test('anime rows are reused by normalized parsed title and upgraded with AniList
|
||||
}
|
||||
});
|
||||
|
||||
test('youtube videos can be regrouped under a shared channel anime identity', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const firstVideoId = getOrCreateVideoRecord(
|
||||
db,
|
||||
'remote:https://www.youtube.com/watch?v=video-1',
|
||||
{
|
||||
canonicalTitle: 'watch?v video-1',
|
||||
sourcePath: null,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=video-1',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const secondVideoId = getOrCreateVideoRecord(
|
||||
db,
|
||||
'remote:https://www.youtube.com/watch?v=video-2',
|
||||
{
|
||||
canonicalTitle: 'watch?v video-2',
|
||||
sourcePath: null,
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=video-2',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
|
||||
const firstAnimeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'watch?v video-1',
|
||||
canonicalTitle: 'watch?v video-1',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, firstVideoId, {
|
||||
animeId: firstAnimeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: 'watch?v video-1',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 0.2,
|
||||
parseMetadataJson: '{"source":"fallback"}',
|
||||
});
|
||||
|
||||
const secondAnimeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'watch?v video-2',
|
||||
canonicalTitle: 'watch?v video-2',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, secondVideoId, {
|
||||
animeId: secondAnimeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: 'watch?v video-2',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 0.2,
|
||||
parseMetadataJson: '{"source":"fallback"}',
|
||||
});
|
||||
|
||||
linkYoutubeVideoToAnimeRecord(db, firstVideoId, {
|
||||
youtubeVideoId: 'video-1',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=video-1',
|
||||
videoTitle: 'Video One',
|
||||
videoThumbnailUrl: 'https://i.ytimg.com/vi/video-1/hqdefault.jpg',
|
||||
channelId: 'UC123',
|
||||
channelName: 'Channel Name',
|
||||
channelUrl: 'https://www.youtube.com/channel/UC123',
|
||||
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
|
||||
uploaderId: '@channelname',
|
||||
uploaderUrl: 'https://www.youtube.com/@channelname',
|
||||
description: null,
|
||||
metadataJson: '{"id":"video-1"}',
|
||||
});
|
||||
linkYoutubeVideoToAnimeRecord(db, secondVideoId, {
|
||||
youtubeVideoId: 'video-2',
|
||||
videoUrl: 'https://www.youtube.com/watch?v=video-2',
|
||||
videoTitle: 'Video Two',
|
||||
videoThumbnailUrl: 'https://i.ytimg.com/vi/video-2/hqdefault.jpg',
|
||||
channelId: 'UC123',
|
||||
channelName: 'Channel Name',
|
||||
channelUrl: 'https://www.youtube.com/channel/UC123',
|
||||
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
|
||||
uploaderId: '@channelname',
|
||||
uploaderUrl: 'https://www.youtube.com/@channelname',
|
||||
description: null,
|
||||
metadataJson: '{"id":"video-2"}',
|
||||
});
|
||||
|
||||
const animeRows = db.prepare('SELECT anime_id, canonical_title FROM imm_anime').all() as Array<{
|
||||
anime_id: number;
|
||||
canonical_title: string;
|
||||
}>;
|
||||
const videoRows = db
|
||||
.prepare('SELECT video_id, anime_id, parsed_title FROM imm_videos ORDER BY video_id ASC')
|
||||
.all() as Array<{ video_id: number; anime_id: number | null; parsed_title: string | null }>;
|
||||
|
||||
const channelAnimeRows = animeRows.filter((row) => row.canonical_title === 'Channel Name');
|
||||
assert.equal(channelAnimeRows.length, 1);
|
||||
assert.equal(videoRows[0]?.anime_id, channelAnimeRows[0]?.anime_id);
|
||||
assert.equal(videoRows[1]?.anime_id, channelAnimeRows[0]?.anime_id);
|
||||
assert.equal(videoRows[0]?.parsed_title, 'Channel Name');
|
||||
assert.equal(videoRows[1]?.parsed_title, 'Channel Name');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('start/finalize session updates ended_at and status', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
import type { QueuedWrite, VideoMetadata } from './types';
|
||||
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||
|
||||
export interface TrackerPreparedStatements {
|
||||
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
@@ -39,6 +39,41 @@ export interface VideoAnimeLinkInput {
|
||||
parseMetadataJson: string | null;
|
||||
}
|
||||
|
||||
function buildYoutubeChannelAnimeIdentity(metadata: YoutubeVideoMetadata): {
|
||||
parsedTitle: string;
|
||||
canonicalTitle: string;
|
||||
metadataJson: string;
|
||||
} | null {
|
||||
const channelId = metadata.channelId?.trim() || null;
|
||||
const channelUrl = metadata.channelUrl?.trim() || null;
|
||||
const channelName = metadata.channelName?.trim() || null;
|
||||
const uploaderId = metadata.uploaderId?.trim() || null;
|
||||
const videoTitle = metadata.videoTitle?.trim() || null;
|
||||
|
||||
const parsedTitle = channelId
|
||||
? `youtube-channel:${channelId}`
|
||||
: channelUrl
|
||||
? `youtube-channel-url:${channelUrl}`
|
||||
: channelName
|
||||
? `youtube-channel-name:${channelName}`
|
||||
: null;
|
||||
if (!parsedTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parsedTitle,
|
||||
canonicalTitle: channelName || uploaderId || videoTitle || parsedTitle,
|
||||
metadataJson: JSON.stringify({
|
||||
source: 'youtube-channel',
|
||||
channelId,
|
||||
channelUrl,
|
||||
channelName,
|
||||
uploaderId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const COVER_BLOB_REFERENCE_PREFIX = '__subminer_cover_blob_ref__:';
|
||||
const WAL_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
@@ -439,6 +474,38 @@ export function linkVideoToAnimeRecord(
|
||||
);
|
||||
}
|
||||
|
||||
export function linkYoutubeVideoToAnimeRecord(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
metadata: YoutubeVideoMetadata,
|
||||
): number | null {
|
||||
const identity = buildYoutubeChannelAnimeIdentity(metadata);
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: identity.parsedTitle,
|
||||
canonicalTitle: identity.canonicalTitle,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: identity.metadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: identity.canonicalTitle,
|
||||
parsedSeason: null,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'youtube',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: identity.metadataJson,
|
||||
});
|
||||
return animeId;
|
||||
}
|
||||
|
||||
function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
|
||||
addColumnIfMissing(db, 'imm_videos', 'anime_id', 'INTEGER REFERENCES imm_anime(anime_id)');
|
||||
addColumnIfMissing(db, 'imm_videos', 'parsed_basename', 'TEXT');
|
||||
@@ -743,6 +810,27 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_youtube_videos(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
youtube_video_id TEXT NOT NULL,
|
||||
video_url TEXT NOT NULL,
|
||||
video_title TEXT,
|
||||
video_thumbnail_url TEXT,
|
||||
channel_id TEXT,
|
||||
channel_name TEXT,
|
||||
channel_url TEXT,
|
||||
channel_thumbnail_url TEXT,
|
||||
uploader_id TEXT,
|
||||
uploader_url TEXT,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
fetched_at_ms INTEGER NOT NULL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
@@ -1134,6 +1222,14 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_media_art_cover_url
|
||||
ON imm_media_art(cover_url)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_youtube_videos_channel_id
|
||||
ON imm_youtube_videos(channel_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_youtube_videos_youtube_video_id
|
||||
ON imm_youtube_videos(youtube_video_id)
|
||||
`);
|
||||
|
||||
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
|
||||
db.exec('DELETE FROM imm_daily_rollups');
|
||||
@@ -1506,3 +1602,65 @@ export function updateVideoTitleRecord(
|
||||
`,
|
||||
).run(canonicalTitle, Date.now(), videoId);
|
||||
}
|
||||
|
||||
export function upsertYoutubeVideoMetadata(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
metadata: YoutubeVideoMetadata,
|
||||
): void {
|
||||
const nowMs = Date.now();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_youtube_videos (
|
||||
video_id,
|
||||
youtube_video_id,
|
||||
video_url,
|
||||
video_title,
|
||||
video_thumbnail_url,
|
||||
channel_id,
|
||||
channel_name,
|
||||
channel_url,
|
||||
channel_thumbnail_url,
|
||||
uploader_id,
|
||||
uploader_url,
|
||||
description,
|
||||
metadata_json,
|
||||
fetched_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(video_id) DO UPDATE SET
|
||||
youtube_video_id = excluded.youtube_video_id,
|
||||
video_url = excluded.video_url,
|
||||
video_title = excluded.video_title,
|
||||
video_thumbnail_url = excluded.video_thumbnail_url,
|
||||
channel_id = excluded.channel_id,
|
||||
channel_name = excluded.channel_name,
|
||||
channel_url = excluded.channel_url,
|
||||
channel_thumbnail_url = excluded.channel_thumbnail_url,
|
||||
uploader_id = excluded.uploader_id,
|
||||
uploader_url = excluded.uploader_url,
|
||||
description = excluded.description,
|
||||
metadata_json = excluded.metadata_json,
|
||||
fetched_at_ms = excluded.fetched_at_ms,
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||
`,
|
||||
).run(
|
||||
videoId,
|
||||
metadata.youtubeVideoId,
|
||||
metadata.videoUrl,
|
||||
metadata.videoTitle ?? null,
|
||||
metadata.videoThumbnailUrl ?? null,
|
||||
metadata.channelId ?? null,
|
||||
metadata.channelName ?? null,
|
||||
metadata.channelUrl ?? null,
|
||||
metadata.channelThumbnailUrl ?? null,
|
||||
metadata.uploaderId ?? null,
|
||||
metadata.uploaderUrl ?? null,
|
||||
metadata.description ?? null,
|
||||
metadata.metadataJson ?? null,
|
||||
nowMs,
|
||||
nowMs,
|
||||
nowMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SCHEMA_VERSION = 15;
|
||||
export const SCHEMA_VERSION = 16;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
@@ -420,6 +420,17 @@ export interface MediaLibraryRow {
|
||||
totalTokensSeen: number;
|
||||
lastWatchedMs: number;
|
||||
hasCoverArt: number;
|
||||
youtubeVideoId: string | null;
|
||||
videoUrl: string | null;
|
||||
videoTitle: string | null;
|
||||
videoThumbnailUrl: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
channelUrl: string | null;
|
||||
channelThumbnailUrl: string | null;
|
||||
uploaderId: string | null;
|
||||
uploaderUrl: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface MediaDetailRow {
|
||||
@@ -434,6 +445,32 @@ export interface MediaDetailRow {
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
youtubeVideoId: string | null;
|
||||
videoUrl: string | null;
|
||||
videoTitle: string | null;
|
||||
videoThumbnailUrl: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
channelUrl: string | null;
|
||||
channelThumbnailUrl: string | null;
|
||||
uploaderId: string | null;
|
||||
uploaderUrl: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface YoutubeVideoMetadata {
|
||||
youtubeVideoId: string;
|
||||
videoUrl: string;
|
||||
videoTitle: string | null;
|
||||
videoThumbnailUrl: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
channelUrl: string | null;
|
||||
channelThumbnailUrl: string | null;
|
||||
uploaderId: string | null;
|
||||
uploaderUrl: string | null;
|
||||
description: string | null;
|
||||
metadataJson: string | null;
|
||||
}
|
||||
|
||||
export interface AnimeLibraryRow {
|
||||
|
||||
@@ -79,7 +79,10 @@ export {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
isTabInputForMpvForwarding,
|
||||
} from './overlay-window-input';
|
||||
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
export {
|
||||
initializeOverlayAnkiIntegration,
|
||||
initializeOverlayRuntime,
|
||||
} from './overlay-runtime-init';
|
||||
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||
export {
|
||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
|
||||
@@ -15,6 +15,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
},
|
||||
triggerSubsyncFromConfig: () => {
|
||||
calls.push('subsync');
|
||||
@@ -22,6 +23,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
openRuntimeOptionsPalette: () => {
|
||||
calls.push('runtime-options');
|
||||
},
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
},
|
||||
runtimeOptionsCycle: () => ({ ok: true }),
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
@@ -98,6 +102,14 @@ test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command',
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__youtube-picker-open'], options);
|
||||
assert.deepEqual(calls, ['youtube-picker']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
isMpvConnected: () => false,
|
||||
|
||||
@@ -14,9 +14,11 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
PLAY_NEXT_SUBTITLE: string;
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||
YOUTUBE_PICKER_OPEN: string;
|
||||
};
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
@@ -90,6 +92,11 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
|
||||
void options.openYoutubeTrackPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
|
||||
|
||||
@@ -144,6 +144,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
immersionTracker: null,
|
||||
...overrides,
|
||||
};
|
||||
@@ -236,6 +237,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
return { ok: true, message: 'done' };
|
||||
},
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
});
|
||||
|
||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||
@@ -305,6 +307,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
@@ -611,6 +614,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
@@ -677,6 +681,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
@@ -746,6 +751,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
SubtitlePosition,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
} from '../../types';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
parseRuntimeOptionValue,
|
||||
parseSubtitlePosition,
|
||||
parseSubsyncManualRunRequest,
|
||||
parseYoutubePickerResolveRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
|
||||
const { BrowserWindow, ipcMain } = electron;
|
||||
@@ -61,6 +64,7 @@ export interface IpcServiceDeps {
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -163,6 +167,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -225,6 +230,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
mainWindow.focus();
|
||||
},
|
||||
runSubsyncManual: options.runSubsyncManual,
|
||||
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||
getRuntimeOptions: options.getRuntimeOptions,
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
@@ -285,6 +291,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.onOverlayModalOpened(parsedModal);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.youtubePickerResolve, async (_event: unknown, request: unknown) => {
|
||||
const parsedRequest = parseYoutubePickerResolveRequest(request);
|
||||
if (!parsedRequest) {
|
||||
return { ok: false, message: 'Invalid YouTube picker resolve payload' };
|
||||
}
|
||||
return await deps.onYoutubePickerResolve(parsedRequest);
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||
deps.openYomitanSettings();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
|
||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||
let createdIntegrations = 0;
|
||||
@@ -109,6 +109,136 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration can initialize Anki transport after overlay runtime already exists', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
initializeOverlayAnkiIntegration({
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: (args) => {
|
||||
createdIntegrations += 1;
|
||||
assert.equal(args.config.enabled, true);
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 11,
|
||||
deleteNoteId: 12,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(createdIntegrations, 1);
|
||||
assert.equal(startedIntegrations, 1);
|
||||
assert.equal(setIntegrationCalls, 1);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration returns false when integration already exists', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
const result = initializeOverlayAnkiIntegration({
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: true } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
getAnkiIntegration: () => ({}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 11,
|
||||
deleteNoteId: 12,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(createdIntegrations, 0);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 0);
|
||||
});
|
||||
|
||||
test('initializeOverlayAnkiIntegration returns false when ankiConnect is disabled', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
let setIntegrationCalls = 0;
|
||||
|
||||
const result = initializeOverlayAnkiIntegration({
|
||||
getResolvedConfig: () => ({
|
||||
ankiConnect: { enabled: false } as never,
|
||||
}),
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
getMpvClient: () => ({
|
||||
send: () => {},
|
||||
}),
|
||||
getRuntimeOptionsManager: () => ({
|
||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||
}),
|
||||
createAnkiIntegration: () => {
|
||||
createdIntegrations += 1;
|
||||
return {
|
||||
start: () => {
|
||||
startedIntegrations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
setAnkiIntegration: () => {
|
||||
setIntegrationCalls += 1;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 11,
|
||||
deleteNoteId: 12,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(createdIntegrations, 0);
|
||||
assert.equal(startedIntegrations, 0);
|
||||
assert.equal(setIntegrationCalls, 0);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
|
||||
@@ -47,6 +47,24 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
}
|
||||
|
||||
export function initializeOverlayRuntime(options: {
|
||||
getMpvSocketPath: () => string;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => {
|
||||
send?: (payload: { command: string[] }) => void;
|
||||
} | null;
|
||||
getRuntimeOptionsManager: () => {
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
backendOverride: string | null;
|
||||
createMainWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
@@ -60,23 +78,6 @@ export function initializeOverlayRuntime(options: {
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getMpvSocketPath: () => string;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => {
|
||||
send?: (payload: { command: string[] }) => void;
|
||||
} | null;
|
||||
getRuntimeOptionsManager: () => {
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): void {
|
||||
options.createMainWindow();
|
||||
options.registerGlobalShortcuts();
|
||||
@@ -112,35 +113,64 @@ export function initializeOverlayRuntime(options: {
|
||||
windowTracker.start();
|
||||
}
|
||||
|
||||
initializeOverlayAnkiIntegration(options);
|
||||
|
||||
options.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
export function initializeOverlayAnkiIntegration(options: {
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => {
|
||||
send?: (payload: { command: string[] }) => void;
|
||||
} | null;
|
||||
getRuntimeOptionsManager: () => {
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): boolean {
|
||||
if (options.getAnkiIntegration?.()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = options.getResolvedConfig();
|
||||
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
||||
const mpvClient = options.getMpvClient();
|
||||
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
||||
|
||||
if (
|
||||
config.ankiConnect?.enabled === true &&
|
||||
subtitleTimingTracker &&
|
||||
mpvClient &&
|
||||
runtimeOptionsManager
|
||||
config.ankiConnect?.enabled !== true ||
|
||||
!subtitleTimingTracker ||
|
||||
!mpvClient ||
|
||||
!runtimeOptionsManager
|
||||
) {
|
||||
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
|
||||
config.ankiConnect,
|
||||
);
|
||||
const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration;
|
||||
const integration = createAnkiIntegration({
|
||||
config: effectiveAnkiConfig,
|
||||
aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai),
|
||||
subtitleTimingTracker,
|
||||
mpvClient,
|
||||
showDesktopNotification: options.showDesktopNotification,
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
});
|
||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||
integration.start();
|
||||
}
|
||||
options.setAnkiIntegration(integration);
|
||||
return false;
|
||||
}
|
||||
|
||||
options.updateVisibleOverlayVisibility();
|
||||
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
|
||||
config.ankiConnect,
|
||||
);
|
||||
const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration;
|
||||
const integration = createAnkiIntegration({
|
||||
config: effectiveAnkiConfig,
|
||||
aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai),
|
||||
subtitleTimingTracker,
|
||||
mpvClient,
|
||||
showDesktopNotification: options.showDesktopNotification,
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
});
|
||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||
integration.start();
|
||||
}
|
||||
options.setAnkiIntegration(integration);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,44 @@ test('Windows visible overlay stays click-through and does not steal focus while
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('visible overlay stays hidden while a modal window is active', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
modalActive: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS tracked visible overlay stays visible without passively stealing focus', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WindowGeometry } from '../../types';
|
||||
|
||||
export function updateVisibleOverlayVisibility(args: {
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
forceMousePassthrough?: boolean;
|
||||
mainWindow: BrowserWindow | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
@@ -28,6 +29,12 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
const mainWindow = args.mainWindow;
|
||||
|
||||
if (args.modalActive) {
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
if (args.isWindowsPlatform || forceMousePassthrough) {
|
||||
|
||||
@@ -194,3 +194,167 @@ test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI sta
|
||||
'run-headless-command',
|
||||
]);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldRunHeadlessInitialCommand: () => true,
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'bootstrap',
|
||||
'reload-config',
|
||||
'init-runtime-options',
|
||||
'create-mpv',
|
||||
'subtitle-timing',
|
||||
'load-yomitan',
|
||||
'init-overlay',
|
||||
'handle-initial-args',
|
||||
]);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
texthooker: { launchAtStartup: false },
|
||||
}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {
|
||||
calls.push('subtitle-ws');
|
||||
},
|
||||
startAnnotationWebsocket: () => {
|
||||
calls.push('annotation-ws');
|
||||
},
|
||||
startTexthooker: () => {
|
||||
calls.push('texthooker');
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.ok(calls.indexOf('load-yomitan') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||
});
|
||||
|
||||
@@ -194,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
} else {
|
||||
deps.createMpvClient();
|
||||
deps.createSubtitleTimingTracker();
|
||||
await deps.loadYomitanExtension();
|
||||
deps.initializeOverlayRuntime();
|
||||
deps.handleInitialArgs();
|
||||
}
|
||||
@@ -290,13 +291,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
if (deps.texthookerOnlyMode) {
|
||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||
await deps.loadYomitanExtension();
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.initializeOverlayRuntime();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
await deps.loadYomitanExtension();
|
||||
}
|
||||
|
||||
await deps.loadYomitanExtension();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
|
||||
18
src/core/services/youtube/generate.ts
Normal file
18
src/core/services/youtube/generate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { YoutubeTrackOption } from './track-probe';
|
||||
import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
|
||||
|
||||
export async function acquireYoutubeSubtitleTrack(input: {
|
||||
targetUrl: string;
|
||||
outputDir: string;
|
||||
track: YoutubeTrackOption;
|
||||
}): Promise<{ path: string }> {
|
||||
return await downloadYoutubeSubtitleTrack(input);
|
||||
}
|
||||
|
||||
export async function acquireYoutubeSubtitleTracks(input: {
|
||||
targetUrl: string;
|
||||
outputDir: string;
|
||||
tracks: YoutubeTrackOption[];
|
||||
}): Promise<Map<string, string>> {
|
||||
return await downloadYoutubeSubtitleTracks(input);
|
||||
}
|
||||
1
src/core/services/youtube/kinds.ts
Normal file
1
src/core/services/youtube/kinds.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type YoutubeTrackKind = 'manual' | 'auto';
|
||||
41
src/core/services/youtube/labels.ts
Normal file
41
src/core/services/youtube/labels.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { YoutubeTrackKind } from './kinds';
|
||||
|
||||
export type { YoutubeTrackKind };
|
||||
|
||||
export function normalizeYoutubeLangCode(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, '');
|
||||
}
|
||||
|
||||
export function isJapaneseYoutubeLang(value: string): boolean {
|
||||
const normalized = normalizeYoutubeLangCode(value);
|
||||
return (
|
||||
normalized === 'ja' ||
|
||||
normalized === 'jp' ||
|
||||
normalized === 'jpn' ||
|
||||
normalized === 'japanese' ||
|
||||
normalized.startsWith('ja-') ||
|
||||
normalized.startsWith('jp-')
|
||||
);
|
||||
}
|
||||
|
||||
export function isEnglishYoutubeLang(value: string): boolean {
|
||||
const normalized = normalizeYoutubeLangCode(value);
|
||||
return (
|
||||
normalized === 'en' ||
|
||||
normalized === 'eng' ||
|
||||
normalized === 'english' ||
|
||||
normalized === 'enus' ||
|
||||
normalized === 'en-us' ||
|
||||
normalized.startsWith('en-')
|
||||
);
|
||||
}
|
||||
|
||||
export function formatYoutubeTrackLabel(input: {
|
||||
language: string;
|
||||
kind: YoutubeTrackKind;
|
||||
title?: string;
|
||||
}): string {
|
||||
const language = input.language.trim() || 'unknown';
|
||||
const base = input.title?.trim() || language;
|
||||
return `${base} (${input.kind})`;
|
||||
}
|
||||
89
src/core/services/youtube/metadata-probe.test.ts
Normal file
89
src/core/services/youtube/metadata-probe.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { probeYoutubeVideoMetadata } from './metadata-probe';
|
||||
|
||||
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-metadata-probe-'));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/usr/bin/env node
|
||||
process.stdout.write(${JSON.stringify(payload)});
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(scriptPath, 0o755);
|
||||
}
|
||||
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
|
||||
}
|
||||
|
||||
function makeHangingFakeYtDlpScript(dir: string): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/usr/bin/env node
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(scriptPath, 0o755);
|
||||
}
|
||||
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<T> {
|
||||
return await withTempDir(async (root) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeFakeYtDlpScript(binDir, payload);
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function withHangingFakeYtDlp<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await withTempDir(async (root) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeHangingFakeYtDlpScript(binDir);
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('probeYoutubeVideoMetadata returns null on malformed yt-dlp JSON', async () => {
|
||||
await withFakeYtDlp('not-json', async () => {
|
||||
const result = await probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'probeYoutubeVideoMetadata times out when yt-dlp hangs',
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
await withHangingFakeYtDlp(async () => {
|
||||
await assert.rejects(
|
||||
probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123'),
|
||||
/timed out after 15000ms/,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
122
src/core/services/youtube/metadata-probe.ts
Normal file
122
src/core/services/youtube/metadata-probe.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
||||
|
||||
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
type YtDlpThumbnail = {
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type YtDlpYoutubeMetadata = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
webpage_url?: string;
|
||||
thumbnail?: string;
|
||||
thumbnails?: YtDlpThumbnail[];
|
||||
channel_id?: string;
|
||||
channel?: string;
|
||||
channel_url?: string;
|
||||
uploader_id?: string;
|
||||
uploader_url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
function runCapture(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = YOUTUBE_METADATA_PROBE_TIMEOUT_MS,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
proc.stdout.setEncoding('utf8');
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
proc.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string | null {
|
||||
if (!Array.isArray(thumbnails)) return null;
|
||||
for (const thumbnail of thumbnails) {
|
||||
const candidate = thumbnail.url?.trim();
|
||||
if (!candidate) continue;
|
||||
if (candidate.includes('/vi/')) continue;
|
||||
if (
|
||||
typeof thumbnail.width === 'number' &&
|
||||
typeof thumbnail.height === 'number' &&
|
||||
thumbnail.width > 0 &&
|
||||
thumbnail.height > 0
|
||||
) {
|
||||
const ratio = thumbnail.width / thumbnail.height;
|
||||
if (ratio >= 0.8 && ratio <= 1.25) {
|
||||
return candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (candidate.includes('yt3.googleusercontent.com')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function probeYoutubeVideoMetadata(
|
||||
targetUrl: string,
|
||||
): Promise<YoutubeVideoMetadata | null> {
|
||||
const { stdout } = await runCapture('yt-dlp', [
|
||||
'--dump-single-json',
|
||||
'--no-warnings',
|
||||
'--skip-download',
|
||||
targetUrl,
|
||||
]);
|
||||
let info: YtDlpYoutubeMetadata;
|
||||
try {
|
||||
info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const youtubeVideoId = info.id?.trim();
|
||||
const videoUrl = info.webpage_url?.trim() || targetUrl.trim();
|
||||
if (!youtubeVideoId || !videoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
youtubeVideoId,
|
||||
videoUrl,
|
||||
videoTitle: info.title?.trim() || null,
|
||||
videoThumbnailUrl: info.thumbnail?.trim() || null,
|
||||
channelId: info.channel_id?.trim() || null,
|
||||
channelName: info.channel?.trim() || null,
|
||||
channelUrl: info.channel_url?.trim() || null,
|
||||
channelThumbnailUrl: pickChannelThumbnail(info.thumbnails),
|
||||
uploaderId: info.uploader_id?.trim() || null,
|
||||
uploaderUrl: info.uploader_url?.trim() || null,
|
||||
description: info.description?.trim() || null,
|
||||
metadataJson: JSON.stringify(info),
|
||||
};
|
||||
}
|
||||
29
src/core/services/youtube/retime.test.ts
Normal file
29
src/core/services/youtube/retime.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { retimeYoutubeSubtitle } from './retime';
|
||||
|
||||
test('retimeYoutubeSubtitle uses the downloaded subtitle path as-is', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-retime-'));
|
||||
try {
|
||||
const primaryPath = path.join(root, 'primary.vtt');
|
||||
const referencePath = path.join(root, 'reference.vtt');
|
||||
fs.writeFileSync(primaryPath, 'WEBVTT\n', 'utf8');
|
||||
fs.writeFileSync(referencePath, 'WEBVTT\n', 'utf8');
|
||||
|
||||
const result = await retimeYoutubeSubtitle({
|
||||
primaryPath,
|
||||
secondaryPath: referencePath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.strategy, 'none');
|
||||
assert.equal(result.path, primaryPath);
|
||||
assert.equal(result.message, 'Using downloaded subtitle as-is (no automatic retime enabled)');
|
||||
assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
11
src/core/services/youtube/retime.ts
Normal file
11
src/core/services/youtube/retime.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export async function retimeYoutubeSubtitle(input: {
|
||||
primaryPath: string;
|
||||
secondaryPath: string | null;
|
||||
}): Promise<{ ok: boolean; path: string; strategy: 'none' | 'alass' | 'ffsubsync'; message: string }> {
|
||||
return {
|
||||
ok: true,
|
||||
path: input.primaryPath,
|
||||
strategy: 'none',
|
||||
message: `Using downloaded subtitle as-is${input.secondaryPath ? ' (no automatic retime enabled)' : ''}`,
|
||||
};
|
||||
}
|
||||
75
src/core/services/youtube/timedtext.test.ts
Normal file
75
src/core/services/youtube/timedtext.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { convertYoutubeTimedTextToVtt, normalizeYoutubeAutoVtt } from './timedtext';
|
||||
|
||||
test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => {
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
'<timedtext><body><p t="0" d="1000">� � A</p></body></timedtext>',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
['WEBVTT', '', '00:00:00.000 --> 00:00:01.000', '� � A', ''].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overlap rows', () => {
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext><body>',
|
||||
'<p t="0" d="2000">今日は</p>',
|
||||
'<p t="1000" d="0">今日はいい天気ですね</p>',
|
||||
'<p t="1000" d="2000">今日はいい天気ですね</p>',
|
||||
'</body></timedtext>',
|
||||
].join(''),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:00.000 --> 00:00:00.999',
|
||||
'今日は',
|
||||
'',
|
||||
'00:00:01.000 --> 00:00:03.000',
|
||||
'いい天気ですね',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
|
||||
const result = normalizeYoutubeAutoVtt(
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:01.000 --> 00:00:02.000',
|
||||
'今日は',
|
||||
'',
|
||||
'00:00:02.000 --> 00:00:03.000',
|
||||
'今日はいい天気ですね',
|
||||
'',
|
||||
'00:00:03.000 --> 00:00:04.000',
|
||||
'今日はいい天気ですね本当に',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:01.000 --> 00:00:02.000',
|
||||
'今日は',
|
||||
'',
|
||||
'00:00:02.000 --> 00:00:03.000',
|
||||
'いい天気ですね',
|
||||
'',
|
||||
'00:00:03.000 --> 00:00:04.000',
|
||||
'本当に',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
166
src/core/services/youtube/timedtext.ts
Normal file
166
src/core/services/youtube/timedtext.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
interface YoutubeTimedTextRow {
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
||||
|
||||
function decodeNumericEntity(match: string, codePoint: number): string {
|
||||
if (
|
||||
!Number.isInteger(codePoint) ||
|
||||
codePoint < 0 ||
|
||||
codePoint > 0x10ffff ||
|
||||
(codePoint >= 0xd800 && codePoint <= 0xdfff)
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (match, codePoint) =>
|
||||
decodeNumericEntity(match, Number(codePoint)),
|
||||
)
|
||||
.replace(/&#x([0-9a-f]+);/gi, (match, codePoint) =>
|
||||
decodeNumericEntity(match, Number.parseInt(codePoint, 16)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseAttributeMap(raw: string): Map<string, string> {
|
||||
const attrs = new Map<string, string>();
|
||||
for (const match of raw.matchAll(/([a-zA-Z0-9:_-]+)="([^"]*)"/g)) {
|
||||
attrs.set(match[1]!, match[2]!);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
||||
const rows: YoutubeTimedTextRow[] = [];
|
||||
|
||||
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const startMs = Number(attrs.get('t'));
|
||||
const durationMs = Number(attrs.get('d'));
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inner = (match[2] ?? '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '');
|
||||
const text = decodeHtmlEntities(inner).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({ startMs, durationMs, text });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function formatVttTimestamp(ms: number): string {
|
||||
const totalMs = Math.max(0, Math.floor(ms));
|
||||
const hours = Math.floor(totalMs / 3_600_000);
|
||||
const minutes = Math.floor((totalMs % 3_600_000) / 60_000);
|
||||
const seconds = Math.floor((totalMs % 60_000) / 1_000);
|
||||
const millis = totalMs % 1_000;
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return YOUTUBE_TIMEDTEXT_EXTENSIONS.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
const rows = extractYoutubeTimedTextRows(xml);
|
||||
if (rows.length === 0) {
|
||||
return 'WEBVTT\n';
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
let previousText = '';
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const row = rows[index]!;
|
||||
const nextRow = rows[index + 1];
|
||||
const unclampedEnd = row.startMs + row.durationMs;
|
||||
const clampedEnd =
|
||||
nextRow && unclampedEnd > nextRow.startMs
|
||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||
: unclampedEnd;
|
||||
if (clampedEnd <= row.startMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text =
|
||||
previousText && row.text.startsWith(previousText)
|
||||
? row.text.slice(previousText.length).trimStart()
|
||||
: row.text;
|
||||
previousText = row.text;
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
blocks.push(`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`);
|
||||
}
|
||||
|
||||
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function normalizeRollingCaptionText(text: string, previousText: string): string {
|
||||
if (!previousText || !text.startsWith(previousText)) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(previousText.length).trimStart();
|
||||
}
|
||||
|
||||
export function normalizeYoutubeAutoVtt(content: string): string {
|
||||
const normalizedContent = content.replace(/\r\n?/g, '\n');
|
||||
const blocks = normalizedContent.split(/\n{2,}/);
|
||||
if (blocks.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let previousText = '';
|
||||
let changed = false;
|
||||
const normalizedBlocks = blocks.map((block) => {
|
||||
if (!block.includes('-->')) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const lines = block.split('\n');
|
||||
const timingLineIndex = lines.findIndex((line) => line.includes('-->'));
|
||||
if (timingLineIndex < 0 || timingLineIndex === lines.length - 1) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const textLines = lines.slice(timingLineIndex + 1);
|
||||
const originalText = textLines.join('\n').trim();
|
||||
if (!originalText) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const normalizedText = normalizeRollingCaptionText(originalText, previousText);
|
||||
previousText = originalText;
|
||||
if (!normalizedText || normalizedText === originalText) {
|
||||
return block;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
return [...lines.slice(0, timingLineIndex + 1), normalizedText].join('\n');
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return content;
|
||||
}
|
||||
return `${normalizedBlocks.join('\n\n')}\n`;
|
||||
}
|
||||
570
src/core/services/youtube/track-download.test.ts
Normal file
570
src/core/services/youtube/track-download.test.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
|
||||
|
||||
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-download-'));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeFakeYtDlpScript(dir: string): string {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
let outputTemplate = '';
|
||||
const wantsAutoSubs = args.includes('--write-auto-subs');
|
||||
const wantsManualSubs = args.includes('--write-subs');
|
||||
const subLangIndex = args.indexOf('--sub-langs');
|
||||
const subLang = subLangIndex >= 0 ? args[subLangIndex + 1] || '' : '';
|
||||
const subLangs = subLang ? subLang.split(',').filter(Boolean) : [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (args[i] === '-o' && typeof args[i + 1] === 'string') {
|
||||
outputTemplate = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.YTDLP_EXPECT_AUTO_SUBS === '1' && !wantsAutoSubs) {
|
||||
process.exit(2);
|
||||
}
|
||||
if (process.env.YTDLP_EXPECT_MANUAL_SUBS === '1' && !wantsManualSubs) {
|
||||
process.exit(3);
|
||||
}
|
||||
if (process.env.YTDLP_EXPECT_SUB_LANG && subLang !== process.env.YTDLP_EXPECT_SUB_LANG) {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
const prefix = outputTemplate.replace(/\.%\([^)]+\)s$/, '');
|
||||
if (!prefix) {
|
||||
process.exit(1);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(prefix), { recursive: true });
|
||||
|
||||
if (process.env.YTDLP_FAKE_MODE === 'multi') {
|
||||
for (const lang of subLangs) {
|
||||
fs.writeFileSync(\`\${prefix}.\${lang}.vtt\`, 'WEBVTT\\n');
|
||||
}
|
||||
} else if (process.env.YTDLP_FAKE_MODE === 'rolling-auto') {
|
||||
fs.writeFileSync(
|
||||
\`\${prefix}.vtt\`,
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:01.000 --> 00:00:02.000',
|
||||
'今日は',
|
||||
'',
|
||||
'00:00:02.000 --> 00:00:03.000',
|
||||
'今日はいい天気ですね',
|
||||
'',
|
||||
'00:00:03.000 --> 00:00:04.000',
|
||||
'今日はいい天気ですね本当に',
|
||||
'',
|
||||
].join('\\n'),
|
||||
);
|
||||
} else if (process.env.YTDLP_FAKE_MODE === 'multi-primary-only-fail') {
|
||||
const primaryLang = subLangs[0];
|
||||
if (primaryLang) {
|
||||
fs.writeFileSync(\`\${prefix}.\${primaryLang}.vtt\`, 'WEBVTT\\n');
|
||||
}
|
||||
process.stderr.write("ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n");
|
||||
process.exit(1);
|
||||
} else if (process.env.YTDLP_FAKE_MODE === 'both') {
|
||||
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||
fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp');
|
||||
} else if (process.env.YTDLP_FAKE_MODE === 'webp-only') {
|
||||
fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp');
|
||||
} else {
|
||||
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||
}
|
||||
process.exit(0);
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
fs.chmodSync(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
async function withFakeYtDlp<T>(
|
||||
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
|
||||
fn: (dir: string, binDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await withTempDir(async (root) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeFakeYtDlpScript(binDir);
|
||||
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
process.env.YTDLP_FAKE_MODE = mode;
|
||||
try {
|
||||
return await fn(root, binDir);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
delete process.env.YTDLP_FAKE_MODE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function withFakeYtDlpExpectations<T>(
|
||||
expectations: Partial<Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>>,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = {
|
||||
YTDLP_EXPECT_AUTO_SUBS: process.env.YTDLP_EXPECT_AUTO_SUBS,
|
||||
YTDLP_EXPECT_MANUAL_SUBS: process.env.YTDLP_EXPECT_MANUAL_SUBS,
|
||||
YTDLP_EXPECT_SUB_LANG: process.env.YTDLP_EXPECT_SUB_LANG,
|
||||
};
|
||||
Object.assign(process.env, expectations);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withStubFetch<T>(
|
||||
handler: (url: string) => Promise<Response> | Response,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
return await handler(url);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifacts', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlp('both', async (root) => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(path.extname(result.path), '.vtt');
|
||||
assert.match(path.basename(result.path), /^auto-ja-orig\./);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlp('webp-only', async (root) => {
|
||||
const outputDir = path.join(root, 'out');
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outputDir, 'auto-ja.vtt'), 'stale subtitle');
|
||||
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir,
|
||||
track: {
|
||||
id: 'auto:ja',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
},
|
||||
}),
|
||||
/No subtitle file was downloaded/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack uses auto subtitle flags and raw source language for auto tracks', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlp('both', async (root) => {
|
||||
await withFakeYtDlpExpectations(
|
||||
{
|
||||
YTDLP_EXPECT_AUTO_SUBS: '1',
|
||||
YTDLP_EXPECT_SUB_LANG: 'ja-orig',
|
||||
},
|
||||
async () => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(path.extname(result.path), '.vtt');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlp('both', async (root) => {
|
||||
await withFakeYtDlpExpectations(
|
||||
{
|
||||
YTDLP_EXPECT_MANUAL_SUBS: '1',
|
||||
YTDLP_EXPECT_SUB_LANG: 'ja',
|
||||
},
|
||||
async () => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'manual:ja',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja',
|
||||
kind: 'manual',
|
||||
label: 'Japanese (manual)',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(path.extname(result.path), '.vtt');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack normalizes rolling auto-caption vtt output from yt-dlp', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlp('rolling-auto', async (root) => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(result.path, 'utf8'),
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:01.000 --> 00:00:02.000',
|
||||
'今日は',
|
||||
'',
|
||||
'00:00:02.000 --> 00:00:03.000',
|
||||
'いい天気ですね',
|
||||
'',
|
||||
'00:00:03.000 --> 00:00:04.000',
|
||||
'本当に',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack prefers direct download URL when available', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
await withStubFetch(
|
||||
async (url) => {
|
||||
assert.equal(url, 'https://example.com/subs/ja.vtt');
|
||||
return new Response('WEBVTT\n', { status: 200 });
|
||||
},
|
||||
async () => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||
fileExtension: 'vtt',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
|
||||
assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
await withStubFetch(
|
||||
async () => new Response('WEBVTT\n', { status: 200 }),
|
||||
async () => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'auto:../../ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: '../ja-orig/../../evil',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||
fileExtension: 'vtt',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(path.dirname(result.path), path.join(root, 'out'));
|
||||
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig-evil.vtt');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
await withStubFetch(
|
||||
async (url) => {
|
||||
assert.equal(url, 'https://example.com/subs/ja.srv3');
|
||||
return new Response(
|
||||
[
|
||||
'<timedtext><body>',
|
||||
'<p t="1000" d="2500">今日は</p>',
|
||||
'<p t="2000" d="2500">今日はいい天気ですね</p>',
|
||||
'<p t="3500" d="2500">今日はいい天気ですね本当に</p>',
|
||||
'</body></timedtext>',
|
||||
].join(''),
|
||||
{ status: 200 },
|
||||
);
|
||||
},
|
||||
async () => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
downloadUrl: 'https://example.com/subs/ja.srv3',
|
||||
fileExtension: 'srv3',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
|
||||
assert.equal(
|
||||
fs.readFileSync(result.path, 'utf8'),
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:01.000 --> 00:00:01.999',
|
||||
'今日は',
|
||||
'',
|
||||
'00:00:02.000 --> 00:00:03.499',
|
||||
'いい天気ですね',
|
||||
'',
|
||||
'00:00:03.500 --> 00:00:06.000',
|
||||
'本当に',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTracks downloads primary and secondary in one invocation', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlp('multi', async (root) => {
|
||||
const outputDir = path.join(root, 'out');
|
||||
const result = await downloadYoutubeSubtitleTracks({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir,
|
||||
tracks: [
|
||||
{
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
},
|
||||
{
|
||||
id: 'auto:en',
|
||||
language: 'en',
|
||||
sourceLanguage: 'en',
|
||||
kind: 'auto',
|
||||
label: 'English (auto)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||
assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTracks preserves successfully downloaded primary file on partial failure', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlp('multi-primary-only-fail', async (root) => {
|
||||
const outputDir = path.join(root, 'out');
|
||||
const result = await downloadYoutubeSubtitleTracks({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir,
|
||||
tracks: [
|
||||
{
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
},
|
||||
{
|
||||
id: 'auto:en',
|
||||
language: 'en',
|
||||
sourceLanguage: 'en',
|
||||
kind: 'auto',
|
||||
label: 'English (auto)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||
assert.equal(result.has('auto:en'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTracks prefers direct download URLs when available', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const seen: string[] = [];
|
||||
await withStubFetch(
|
||||
async (url) => {
|
||||
seen.push(url);
|
||||
return new Response(`WEBVTT\n${url}\n`, { status: 200 });
|
||||
},
|
||||
async () => {
|
||||
const result = await downloadYoutubeSubtitleTracks({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
tracks: [
|
||||
{
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||
fileExtension: 'vtt',
|
||||
},
|
||||
{
|
||||
id: 'auto:en',
|
||||
language: 'en',
|
||||
sourceLanguage: 'en',
|
||||
kind: 'auto',
|
||||
label: 'English (auto)',
|
||||
downloadUrl: 'https://example.com/subs/en.vtt',
|
||||
fileExtension: 'vtt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [
|
||||
'https://example.com/subs/ja.vtt',
|
||||
'https://example.com/subs/en.vtt',
|
||||
]);
|
||||
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||
assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTracks keeps duplicate source-language direct downloads distinct', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const seen: string[] = [];
|
||||
await withStubFetch(
|
||||
async (url) => {
|
||||
seen.push(url);
|
||||
return new Response(`WEBVTT\n${url}\n`, { status: 200 });
|
||||
},
|
||||
async () => {
|
||||
const result = await downloadYoutubeSubtitleTracks({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
tracks: [
|
||||
{
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
downloadUrl: 'https://example.com/subs/ja-auto.vtt',
|
||||
fileExtension: 'vtt',
|
||||
},
|
||||
{
|
||||
id: 'manual:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'manual',
|
||||
label: 'Japanese (manual)',
|
||||
downloadUrl: 'https://example.com/subs/ja-manual.vtt',
|
||||
fileExtension: 'vtt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [
|
||||
'https://example.com/subs/ja-auto.vtt',
|
||||
'https://example.com/subs/ja-manual.vtt',
|
||||
]);
|
||||
assert.notEqual(result.get('auto:ja-orig'), result.get('manual:ja-orig'));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
315
src/core/services/youtube/track-download.ts
Normal file
315
src/core/services/youtube/track-download.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { YoutubeTrackOption } from './track-probe';
|
||||
import {
|
||||
convertYoutubeTimedTextToVtt,
|
||||
isYoutubeTimedTextExtension,
|
||||
normalizeYoutubeAutoVtt,
|
||||
} from './timedtext';
|
||||
|
||||
const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
||||
const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
|
||||
const YOUTUBE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
||||
|
||||
function sanitizeFilenameSegment(value: string): string {
|
||||
const sanitized = value.trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/-+/g, '-');
|
||||
return sanitized.replace(/^-+|-+$/g, '') || 'unknown';
|
||||
}
|
||||
|
||||
function createFetchTimeoutSignal(timeoutMs: number): AbortSignal | undefined {
|
||||
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runCapture(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = YOUTUBE_DOWNLOAD_TIMEOUT_MS,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
proc.stdout.setEncoding('utf8');
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
proc.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runCaptureDetailed(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = YOUTUBE_DOWNLOAD_TIMEOUT_MS,
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
proc.stdout.setEncoding('utf8');
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
proc.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code: code ?? 1 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pickLatestSubtitleFile(dir: string, prefix: string): string | null {
|
||||
const entries = fs.readdirSync(dir).map((name) => path.join(dir, name));
|
||||
const candidates = entries.filter((candidate) => {
|
||||
const basename = path.basename(candidate);
|
||||
const ext = path.extname(basename).toLowerCase();
|
||||
return basename.startsWith(prefix) && YOUTUBE_SUBTITLE_EXTENSIONS.has(ext);
|
||||
});
|
||||
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function pickLatestSubtitleFileForLanguage(
|
||||
dir: string,
|
||||
prefix: string,
|
||||
sourceLanguage: string,
|
||||
): string | null {
|
||||
const entries = fs.readdirSync(dir).map((name) => path.join(dir, name));
|
||||
const candidates = entries.filter((candidate) => {
|
||||
const basename = path.basename(candidate);
|
||||
const ext = path.extname(basename).toLowerCase();
|
||||
return (
|
||||
basename.startsWith(`${prefix}.`) &&
|
||||
basename.includes(`.${sourceLanguage}.`) &&
|
||||
YOUTUBE_SUBTITLE_EXTENSIONS.has(ext)
|
||||
);
|
||||
});
|
||||
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function buildDownloadArgs(input: {
|
||||
targetUrl: string;
|
||||
outputTemplate: string;
|
||||
sourceLanguages: string[];
|
||||
includeAutoSubs: boolean;
|
||||
includeManualSubs: boolean;
|
||||
}): string[] {
|
||||
const args = ['--skip-download', '--no-warnings'];
|
||||
if (input.includeAutoSubs) {
|
||||
args.push('--write-auto-subs');
|
||||
}
|
||||
if (input.includeManualSubs) {
|
||||
args.push('--write-subs');
|
||||
}
|
||||
args.push(
|
||||
'--sub-format',
|
||||
'srt/vtt/best',
|
||||
'--sub-langs',
|
||||
input.sourceLanguages.join(','),
|
||||
'-o',
|
||||
input.outputTemplate,
|
||||
input.targetUrl,
|
||||
);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function downloadSubtitleFromUrl(input: {
|
||||
outputDir: string;
|
||||
prefix: string;
|
||||
track: YoutubeTrackOption;
|
||||
}): Promise<{ path: string }> {
|
||||
if (!input.track.downloadUrl) {
|
||||
throw new Error(`No direct subtitle URL available for ${input.track.sourceLanguage}`);
|
||||
}
|
||||
const ext = (input.track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, '');
|
||||
const safeExt = isYoutubeTimedTextExtension(ext)
|
||||
? 'vtt'
|
||||
: YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`)
|
||||
? ext
|
||||
: 'vtt';
|
||||
const safeSourceLanguage = sanitizeFilenameSegment(input.track.sourceLanguage);
|
||||
const targetPath = path.join(
|
||||
input.outputDir,
|
||||
`${input.prefix}.${safeSourceLanguage}.${safeExt}`,
|
||||
);
|
||||
const response = await fetch(input.track.downloadUrl, {
|
||||
signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`);
|
||||
}
|
||||
const body = await response.text();
|
||||
const normalizedBody = isYoutubeTimedTextExtension(ext)
|
||||
? convertYoutubeTimedTextToVtt(body)
|
||||
: input.track.kind === 'auto' && safeExt === 'vtt'
|
||||
? normalizeYoutubeAutoVtt(body)
|
||||
: body;
|
||||
fs.writeFileSync(targetPath, normalizedBody, 'utf8');
|
||||
return { path: targetPath };
|
||||
}
|
||||
|
||||
function canDownloadSubtitleFromUrl(track: YoutubeTrackOption): boolean {
|
||||
if (!track.downloadUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ext = (track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, '');
|
||||
return isYoutubeTimedTextExtension(ext) || YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`);
|
||||
}
|
||||
|
||||
function normalizeDownloadedAutoSubtitle(pathname: string, track: YoutubeTrackOption): void {
|
||||
if (track.kind !== 'auto' || path.extname(pathname).toLowerCase() !== '.vtt') {
|
||||
return;
|
||||
}
|
||||
const content = fs.readFileSync(pathname, 'utf8');
|
||||
const normalized = normalizeYoutubeAutoVtt(content);
|
||||
if (normalized !== content) {
|
||||
fs.writeFileSync(pathname, normalized, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadYoutubeSubtitleTrack(input: {
|
||||
targetUrl: string;
|
||||
outputDir: string;
|
||||
track: YoutubeTrackOption;
|
||||
}): Promise<{ path: string }> {
|
||||
fs.mkdirSync(input.outputDir, { recursive: true });
|
||||
const prefix = input.track.id.replace(/[^a-z0-9_-]+/gi, '-');
|
||||
for (const name of fs.readdirSync(input.outputDir)) {
|
||||
if (name.startsWith(prefix)) {
|
||||
try {
|
||||
fs.rmSync(path.join(input.outputDir, name), { force: true });
|
||||
} catch {
|
||||
// ignore stale files
|
||||
}
|
||||
}
|
||||
}
|
||||
if (canDownloadSubtitleFromUrl(input.track)) {
|
||||
return await downloadSubtitleFromUrl({
|
||||
outputDir: input.outputDir,
|
||||
prefix,
|
||||
track: input.track,
|
||||
});
|
||||
}
|
||||
const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
|
||||
const args = [
|
||||
...buildDownloadArgs({
|
||||
targetUrl: input.targetUrl,
|
||||
outputTemplate,
|
||||
sourceLanguages: [input.track.sourceLanguage],
|
||||
includeAutoSubs: input.track.kind === 'auto',
|
||||
includeManualSubs: input.track.kind === 'manual',
|
||||
}),
|
||||
];
|
||||
|
||||
await runCapture('yt-dlp', args);
|
||||
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
||||
if (!subtitlePath) {
|
||||
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
||||
}
|
||||
normalizeDownloadedAutoSubtitle(subtitlePath, input.track);
|
||||
return { path: subtitlePath };
|
||||
}
|
||||
|
||||
export async function downloadYoutubeSubtitleTracks(input: {
|
||||
targetUrl: string;
|
||||
outputDir: string;
|
||||
tracks: YoutubeTrackOption[];
|
||||
}): Promise<Map<string, string>> {
|
||||
fs.mkdirSync(input.outputDir, { recursive: true });
|
||||
const hasDuplicateSourceLanguages =
|
||||
new Set(input.tracks.map((track) => track.sourceLanguage)).size !== input.tracks.length;
|
||||
for (const name of fs.readdirSync(input.outputDir)) {
|
||||
if (name.startsWith(`${YOUTUBE_BATCH_PREFIX}.`)) {
|
||||
try {
|
||||
fs.rmSync(path.join(input.outputDir, name), { force: true });
|
||||
} catch {
|
||||
// ignore stale files
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasDuplicateSourceLanguages || input.tracks.every(canDownloadSubtitleFromUrl)) {
|
||||
const results = new Map<string, string>();
|
||||
for (const track of input.tracks) {
|
||||
const download = await downloadSubtitleFromUrl({
|
||||
outputDir: input.outputDir,
|
||||
prefix: track.id.replace(/[^a-z0-9_-]+/gi, '-'),
|
||||
track,
|
||||
});
|
||||
results.set(track.id, download.path);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`);
|
||||
const includeAutoSubs = input.tracks.some((track) => track.kind === 'auto');
|
||||
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
||||
|
||||
const result = await runCaptureDetailed(
|
||||
'yt-dlp',
|
||||
buildDownloadArgs({
|
||||
targetUrl: input.targetUrl,
|
||||
outputTemplate,
|
||||
sourceLanguages: input.tracks.map((track) => track.sourceLanguage),
|
||||
includeAutoSubs,
|
||||
includeManualSubs,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = new Map<string, string>();
|
||||
for (const track of input.tracks) {
|
||||
const subtitlePath = pickLatestSubtitleFileForLanguage(
|
||||
input.outputDir,
|
||||
YOUTUBE_BATCH_PREFIX,
|
||||
track.sourceLanguage,
|
||||
);
|
||||
if (subtitlePath) {
|
||||
normalizeDownloadedAutoSubtitle(subtitlePath, track);
|
||||
results.set(track.id, subtitlePath);
|
||||
}
|
||||
}
|
||||
if (results.size > 0) {
|
||||
return results;
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || `yt-dlp exited with status ${result.code}`);
|
||||
}
|
||||
throw new Error(
|
||||
`No subtitle file was downloaded for ${input.tracks.map((track) => track.sourceLanguage).join(',')}`,
|
||||
);
|
||||
}
|
||||
99
src/core/services/youtube/track-probe.test.ts
Normal file
99
src/core/services/youtube/track-probe.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { probeYoutubeTracks } from './track-probe';
|
||||
|
||||
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-probe-'));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
const script = rawScript
|
||||
? stdoutBody
|
||||
: `#!/usr/bin/env node
|
||||
process.stdout.write(${JSON.stringify(stdoutBody)});
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(scriptPath, 0o755);
|
||||
}
|
||||
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function withFakeYtDlp<T>(
|
||||
payload: unknown,
|
||||
fn: () => Promise<T>,
|
||||
options: { rawScript?: boolean } = {},
|
||||
): Promise<T> {
|
||||
return await withTempDir(async (root) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async () => {
|
||||
await withFakeYtDlp(
|
||||
{
|
||||
id: 'abc123',
|
||||
title: 'Example',
|
||||
automatic_captions: {
|
||||
'ja-orig': [
|
||||
{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese auto' },
|
||||
{ ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese auto' },
|
||||
],
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(result.videoId, 'abc123');
|
||||
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srv3');
|
||||
assert.equal(result.tracks[0]?.fileExtension, 'srv3');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
||||
await withFakeYtDlp(
|
||||
{
|
||||
id: 'abc123',
|
||||
title: 'Example',
|
||||
subtitles: {
|
||||
ja: [
|
||||
{ ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese manual' },
|
||||
{ ext: 'srt', url: 'https://example.com/ja.srt', name: 'Japanese manual' },
|
||||
],
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srt');
|
||||
assert.equal(result.tracks[0]?.fileExtension, 'srt');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('probeYoutubeTracks reports malformed yt-dlp JSON with context', async () => {
|
||||
await withFakeYtDlp('not-json', async () => {
|
||||
await assert.rejects(
|
||||
async () => await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123'),
|
||||
/Failed to parse yt-dlp output as JSON/,
|
||||
);
|
||||
});
|
||||
});
|
||||
136
src/core/services/youtube/track-probe.ts
Normal file
136
src/core/services/youtube/track-probe.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { YoutubeTrackOption } from '../../../types';
|
||||
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||
|
||||
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type YoutubeTrackProbeResult = {
|
||||
videoId: string;
|
||||
title: string;
|
||||
tracks: YoutubeTrackOption[];
|
||||
};
|
||||
|
||||
type YtDlpSubtitleEntry = Array<{ ext?: string; name?: string; url?: string }>;
|
||||
|
||||
type YtDlpInfo = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
subtitles?: Record<string, YtDlpSubtitleEntry>;
|
||||
automatic_captions?: Record<string, YtDlpSubtitleEntry>;
|
||||
};
|
||||
|
||||
function runCapture(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = YOUTUBE_TRACK_PROBE_TIMEOUT_MS,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
proc.stdout.setEncoding('utf8');
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
proc.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function choosePreferredFormat(
|
||||
formats: YtDlpSubtitleEntry,
|
||||
kind: YoutubeTrackKind,
|
||||
): { ext: string; url: string; title?: string } | null {
|
||||
const preferredOrder =
|
||||
kind === 'auto'
|
||||
? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3']
|
||||
: ['srt', 'vtt', 'srv3', 'srv2', 'srv1', 'ttml', 'json3'];
|
||||
for (const ext of preferredOrder) {
|
||||
const match = formats.find(
|
||||
(format) => typeof format.url === 'string' && format.url && format.ext === ext,
|
||||
);
|
||||
if (match?.url) {
|
||||
return { ext, url: match.url, title: match.name?.trim() || undefined };
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = formats.find((format) => typeof format.url === 'string' && format.url);
|
||||
if (!fallback?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ext: fallback.ext?.trim() || 'vtt',
|
||||
url: fallback.url,
|
||||
title: fallback.name?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind: YoutubeTrackKind) {
|
||||
const tracks: YoutubeTrackOption[] = [];
|
||||
if (!entries) return tracks;
|
||||
for (const [language, formats] of Object.entries(entries)) {
|
||||
if (!Array.isArray(formats) || formats.length === 0) continue;
|
||||
const preferredFormat = choosePreferredFormat(formats, kind);
|
||||
if (!preferredFormat) continue;
|
||||
const sourceLanguage = language.trim() || language;
|
||||
const normalizedLanguage = normalizeYoutubeLangCode(sourceLanguage) || sourceLanguage;
|
||||
const title = preferredFormat.title;
|
||||
tracks.push({
|
||||
id: `${kind}:${sourceLanguage}`,
|
||||
language: normalizedLanguage,
|
||||
sourceLanguage,
|
||||
kind,
|
||||
title,
|
||||
label: formatYoutubeTrackLabel({ language: normalizedLanguage, kind, title }),
|
||||
downloadUrl: preferredFormat.url,
|
||||
fileExtension: preferredFormat.ext,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
export type { YoutubeTrackOption };
|
||||
|
||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||
}
|
||||
let info: YtDlpInfo;
|
||||
try {
|
||||
info = JSON.parse(trimmedStdout) as YtDlpInfo;
|
||||
} catch (error) {
|
||||
const snippet = trimmedStdout.slice(0, 200);
|
||||
throw new Error(
|
||||
`Failed to parse yt-dlp output as JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}${snippet ? `; stdout=${snippet}` : ''}`,
|
||||
);
|
||||
}
|
||||
const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')];
|
||||
return {
|
||||
videoId: info.id || '',
|
||||
title: info.title || '',
|
||||
tracks,
|
||||
};
|
||||
}
|
||||
63
src/core/services/youtube/track-selection.ts
Normal file
63
src/core/services/youtube/track-selection.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { isEnglishYoutubeLang, isJapaneseYoutubeLang } from './labels';
|
||||
import type { YoutubeTrackOption } from './track-probe';
|
||||
|
||||
function pickTrack(
|
||||
tracks: YoutubeTrackOption[],
|
||||
matcher: (value: string) => boolean,
|
||||
excludeId?: string,
|
||||
): YoutubeTrackOption | null {
|
||||
const matching = tracks.filter((track) => matcher(track.language) && track.id !== excludeId);
|
||||
return matching[0] ?? null;
|
||||
}
|
||||
|
||||
export function chooseDefaultYoutubeTrackIds(
|
||||
tracks: YoutubeTrackOption[],
|
||||
): { primaryTrackId: string | null; secondaryTrackId: string | null } {
|
||||
const primary =
|
||||
pickTrack(
|
||||
tracks.filter((track) => track.kind === 'manual'),
|
||||
isJapaneseYoutubeLang,
|
||||
) ||
|
||||
pickTrack(
|
||||
tracks.filter((track) => track.kind === 'auto'),
|
||||
isJapaneseYoutubeLang,
|
||||
) ||
|
||||
tracks.find((track) => track.kind === 'manual') ||
|
||||
tracks[0] ||
|
||||
null;
|
||||
|
||||
const secondary =
|
||||
pickTrack(
|
||||
tracks.filter((track) => track.kind === 'manual'),
|
||||
isEnglishYoutubeLang,
|
||||
primary?.id ?? undefined,
|
||||
) ||
|
||||
pickTrack(
|
||||
tracks.filter((track) => track.kind === 'auto'),
|
||||
isEnglishYoutubeLang,
|
||||
primary?.id ?? undefined,
|
||||
) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
primaryTrackId: primary?.id ?? null,
|
||||
secondaryTrackId: secondary?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeYoutubeTrackSelection(input: {
|
||||
primaryTrackId: string | null;
|
||||
secondaryTrackId: string | null;
|
||||
}): {
|
||||
primaryTrackId: string | null;
|
||||
secondaryTrackId: string | null;
|
||||
} {
|
||||
if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) {
|
||||
return {
|
||||
primaryTrackId: input.primaryTrackId,
|
||||
secondaryTrackId: null,
|
||||
};
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
`app-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -36,7 +36,7 @@ test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
`app-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { appendLogLine, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath } from './shared/log-files';
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type LogLevelSource = 'cli' | 'config';
|
||||
@@ -112,15 +110,11 @@ function safeStringify(value: unknown): string {
|
||||
}
|
||||
|
||||
function resolveLogFilePath(): string {
|
||||
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
|
||||
const envPath = process.env.SUBMINER_APP_LOG?.trim();
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
return resolveDefaultLogFilePath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: process.env.APPDATA,
|
||||
});
|
||||
return resolveDefaultLogFilePath();
|
||||
}
|
||||
|
||||
export function resolveDefaultLogFilePath(options?: {
|
||||
@@ -128,27 +122,11 @@ export function resolveDefaultLogFilePath(options?: {
|
||||
homeDir?: string;
|
||||
appDataDir?: string;
|
||||
}): string {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const baseDir =
|
||||
platform === 'win32'
|
||||
? path.join(
|
||||
options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'),
|
||||
'SubMiner',
|
||||
)
|
||||
: path.join(homeDir, '.config', 'SubMiner');
|
||||
return path.join(baseDir, 'logs', `SubMiner-${date}.log`);
|
||||
return resolveSharedDefaultLogFilePath('app', options);
|
||||
}
|
||||
|
||||
function appendToLogFile(line: string): void {
|
||||
try {
|
||||
const logPath = resolveLogFilePath();
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.appendFileSync(logPath, `${line}\n`, { encoding: 'utf8' });
|
||||
} catch {
|
||||
// never break runtime due to logging sink failures
|
||||
}
|
||||
appendLogLine(resolveLogFilePath(), line);
|
||||
}
|
||||
|
||||
function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void {
|
||||
|
||||
368
src/main.ts
368
src/main.ts
@@ -113,6 +113,7 @@ import {
|
||||
} from './cli/args';
|
||||
import type { CliArgs, CliCommandSource } from './cli/args';
|
||||
import { printHelp } from './cli/help';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningDialogDetails,
|
||||
@@ -279,6 +280,7 @@ import {
|
||||
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
||||
hasMpvWebsocketPlugin,
|
||||
importYomitanDictionaryFromZip,
|
||||
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||
jellyfinTicksToSecondsRuntime,
|
||||
listJellyfinItemsRuntime,
|
||||
@@ -309,12 +311,23 @@ import {
|
||||
upsertYomitanDictionarySettings,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
} from './core/services';
|
||||
import {
|
||||
acquireYoutubeSubtitleTrack,
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from './core/services/youtube/generate';
|
||||
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
import { startStatsServer } from './core/services/stats-server';
|
||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
} from './main/runtime/first-run-setup-service';
|
||||
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||
import {
|
||||
clearYoutubePrimarySubtitleNotificationTimer,
|
||||
createYoutubePrimarySubtitleNotificationRuntime,
|
||||
} from './main/runtime/youtube-primary-subtitle-notification';
|
||||
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
|
||||
import {
|
||||
buildFirstRunSetupHtml,
|
||||
@@ -332,6 +345,7 @@ import {
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './main/runtime/windows-mpv-shortcuts';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||
import {
|
||||
@@ -384,6 +398,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import type { OverlayHostedModal } from './shared/ipc/contracts';
|
||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||
import {
|
||||
@@ -402,6 +417,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
import {
|
||||
@@ -442,7 +458,7 @@ import {
|
||||
resolveSubtitleSourcePath,
|
||||
} from './main/runtime/subtitle-prefetch-source';
|
||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||
import { codecToExtension } from './subsync/utils';
|
||||
import { codecToExtension, getSubsyncConfig } from './subsync/utils';
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||
@@ -743,6 +759,7 @@ process.on('SIGTERM', () => {
|
||||
const overlayManager = createOverlayManager();
|
||||
let overlayModalInputExclusive = false;
|
||||
let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {};
|
||||
let syncOverlayVisibilityForModal: () => void = () => {};
|
||||
|
||||
const handleModalInputStateChange = (isActive: boolean): void => {
|
||||
if (overlayModalInputExclusive === isActive) return;
|
||||
@@ -759,6 +776,7 @@ const handleModalInputStateChange = (isActive: boolean): void => {
|
||||
}
|
||||
}
|
||||
syncOverlayShortcutsForModal(isActive);
|
||||
syncOverlayVisibilityForModal();
|
||||
};
|
||||
|
||||
const buildOverlayContentMeasurementStoreMainDepsHandler =
|
||||
@@ -787,6 +805,181 @@ const appState = createAppState({
|
||||
mpvSocketPath: getDefaultSocketPath(),
|
||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||
});
|
||||
const startBackgroundWarmupsIfAllowed = (): void => {
|
||||
startBackgroundWarmups();
|
||||
};
|
||||
const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
|
||||
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
|
||||
acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input),
|
||||
retimeYoutubePrimaryTrack: async ({ primaryTrack, primaryPath, secondaryTrack, secondaryPath }) => {
|
||||
if (primaryTrack.kind !== 'auto') {
|
||||
return primaryPath;
|
||||
}
|
||||
const result = await retimeYoutubeSubtitle({
|
||||
primaryPath,
|
||||
secondaryPath: secondaryTrack ? secondaryPath : null,
|
||||
});
|
||||
logger.info(`Using YouTube subtitle path: ${result.path} (${result.strategy})`);
|
||||
return result.path;
|
||||
},
|
||||
openPicker: async (payload) => {
|
||||
return await openYoutubeTrackPicker(
|
||||
{
|
||||
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions),
|
||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
},
|
||||
payload,
|
||||
);
|
||||
},
|
||||
pauseMpv: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
|
||||
},
|
||||
resumeMpv: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
|
||||
},
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
requestMpvProperty: async (name: string) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return null;
|
||||
return await client.requestProperty(name);
|
||||
},
|
||||
refreshCurrentSubtitle: (text: string) => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
},
|
||||
refreshSubtitleSidebarSource: async (sourcePath: string) => {
|
||||
await refreshSubtitleSidebarFromSource(sourcePath);
|
||||
},
|
||||
startTokenizationWarmups: async () => {
|
||||
await startTokenizationWarmups();
|
||||
},
|
||||
waitForTokenizationReady: async () => {
|
||||
await currentMediaTokenizationGate.waitUntilReady(
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||
);
|
||||
},
|
||||
waitForAnkiReady: async () => {
|
||||
const integration = appState.ankiIntegration;
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
integration.waitUntilReady(),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
'Continuing YouTube playback before AnkiConnect integration reported ready:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
},
|
||||
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
waitForPlaybackWindowReady: async () => {
|
||||
const deadline = Date.now() + 4000;
|
||||
let stableGeometry: WindowGeometry | null = null;
|
||||
let stableSinceMs = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const tracker = appState.windowTracker;
|
||||
const trackerGeometry = tracker?.getGeometry() ?? null;
|
||||
const mediaPath =
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
|
||||
const trackerFocused = tracker?.isTargetWindowFocused() ?? false;
|
||||
if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) {
|
||||
if (!geometryMatches(stableGeometry, trackerGeometry)) {
|
||||
stableGeometry = trackerGeometry;
|
||||
stableSinceMs = Date.now();
|
||||
} else if (Date.now() - stableSinceMs >= 200) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
stableGeometry = null;
|
||||
stableSinceMs = 0;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
logger.warn(
|
||||
'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.',
|
||||
);
|
||||
},
|
||||
waitForOverlayGeometryReady: async () => {
|
||||
const deadline = Date.now() + 4000;
|
||||
while (Date.now() < deadline) {
|
||||
const tracker = appState.windowTracker;
|
||||
const trackerGeometry = tracker?.getGeometry() ?? null;
|
||||
if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
logger.warn('Timed out waiting for overlay geometry to match tracked playback window.');
|
||||
},
|
||||
focusOverlayWindow: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
if (!mainWindow.isFocused()) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
if (!mainWindow.webContents.isFocused()) {
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
||||
warn: (message: string) => logger.warn(message),
|
||||
log: (message: string) => logger.info(message),
|
||||
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
||||
});
|
||||
|
||||
async function runYoutubePlaybackFlowMain(request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}): Promise<void> {
|
||||
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true);
|
||||
try {
|
||||
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
||||
const launchResult = launchWindowsMpv(
|
||||
[request.url],
|
||||
createWindowsMpvLaunchDeps({
|
||||
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||
}),
|
||||
[
|
||||
'--pause=yes',
|
||||
'--sub-auto=no',
|
||||
'--sid=no',
|
||||
'--secondary-sid=no',
|
||||
'--script-opts=subminer-auto_start_pause_until_ready=no',
|
||||
`--input-ipc-server=${appState.mpvSocketPath}`,
|
||||
],
|
||||
);
|
||||
if (!launchResult.ok) {
|
||||
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||
}
|
||||
}
|
||||
if (!appState.mpvClient?.connected) {
|
||||
appState.mpvClient?.connect();
|
||||
}
|
||||
await youtubeFlowRuntime.runYoutubePlaybackFlow({
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
});
|
||||
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
||||
} finally {
|
||||
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(false);
|
||||
}
|
||||
}
|
||||
|
||||
let firstRunSetupMessage: string | null = null;
|
||||
const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||
resolveWindowsMpvShortcutPaths({
|
||||
@@ -1040,6 +1233,49 @@ const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
|
||||
const startupOsdSequencer = createStartupOsdSequencer({
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
});
|
||||
const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtubeSubgen.primarySubLanguages,
|
||||
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
|
||||
});
|
||||
|
||||
function isYoutubePlaybackActiveNow(): boolean {
|
||||
return isYoutubePlaybackActive(
|
||||
appState.currentMediaPath,
|
||||
appState.mpvClient?.currentVideoPath ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function reportYoutubeSubtitleFailure(message: string): void {
|
||||
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
if (type === 'osd' || type === 'both') {
|
||||
showMpvOsd(message);
|
||||
}
|
||||
if (type === 'system' || type === 'both') {
|
||||
try {
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
} catch {
|
||||
logger.warn(`Unable to show desktop notification: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
|
||||
if (youtubeFlowRuntime.hasActiveSession()) {
|
||||
showMpvOsd('YouTube subtitle flow already in progress.');
|
||||
return;
|
||||
}
|
||||
const currentMediaPath =
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
|
||||
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
|
||||
showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
|
||||
return;
|
||||
}
|
||||
await youtubeFlowRuntime.openManualPicker({
|
||||
url: currentMediaPath,
|
||||
});
|
||||
}
|
||||
|
||||
function maybeSignalPluginAutoplayReady(
|
||||
payload: SubtitleData,
|
||||
@@ -1215,6 +1451,18 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
},
|
||||
});
|
||||
|
||||
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
|
||||
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
||||
if (!normalizedSourcePath) {
|
||||
return;
|
||||
}
|
||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||
normalizedSourcePath,
|
||||
lastObservedTimePos,
|
||||
normalizedSourcePath,
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected) {
|
||||
@@ -1548,7 +1796,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
getConfig: () => {
|
||||
const config = getResolvedConfig().anilist.characterDictionary;
|
||||
return {
|
||||
enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||
enabled:
|
||||
config.enabled &&
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||
!isYoutubePlaybackActiveNow(),
|
||||
maxLoaded: config.maxLoaded,
|
||||
profileScope: config.profileScope,
|
||||
};
|
||||
@@ -1656,6 +1907,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getModalActive: () => overlayModalInputExclusive,
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
@@ -1717,6 +1969,9 @@ const buildRestorePreviousSecondarySubVisibilityMainDepsHandler =
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
});
|
||||
syncOverlayVisibilityForModal = () => {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
};
|
||||
const restorePreviousSecondarySubVisibilityMainDeps =
|
||||
buildRestorePreviousSecondarySubVisibilityMainDepsHandler();
|
||||
const restorePreviousSecondarySubVisibilityHandler =
|
||||
@@ -3064,7 +3319,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
await prewarmSubtitleDictionaries();
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
startBackgroundWarmups();
|
||||
startBackgroundWarmupsIfAllowed();
|
||||
},
|
||||
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
@@ -3173,39 +3428,7 @@ void initializeDiscordPresenceService();
|
||||
const handleCliCommand = createCliCommandRuntimeHandler({
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
if (appState.subtitlePosition === null) {
|
||||
loadSubtitlePosition();
|
||||
}
|
||||
if (appState.keybindings.length === 0) {
|
||||
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||
}
|
||||
if (!appState.mpvClient) {
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
}
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||
() => configService.getConfig().ankiConnect,
|
||||
{
|
||||
applyAnkiPatch: (patch) => {
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
},
|
||||
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
|
||||
onOptionsChanged: () => {
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
broadcastRuntimeOptionsChanged();
|
||||
refreshOverlayShortcuts();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!appState.subtitleTimingTracker) {
|
||||
appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
||||
}
|
||||
},
|
||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||
setTexthookerOnlyMode: (enabled) => {
|
||||
appState.texthookerOnlyMode = enabled;
|
||||
},
|
||||
@@ -3218,6 +3441,40 @@ const handleCliCommand = createCliCommandRuntimeHandler({
|
||||
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
});
|
||||
|
||||
function ensureOverlayStartupPrereqs(): void {
|
||||
if (appState.subtitlePosition === null) {
|
||||
loadSubtitlePosition();
|
||||
}
|
||||
if (appState.keybindings.length === 0) {
|
||||
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||
}
|
||||
if (!appState.mpvClient) {
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
}
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||
() => configService.getConfig().ankiConnect,
|
||||
{
|
||||
applyAnkiPatch: (patch) => {
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
},
|
||||
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
|
||||
onOptionsChanged: () => {
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
broadcastRuntimeOptionsChanged();
|
||||
refreshOverlayShortcuts();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!appState.subtitleTimingTracker) {
|
||||
appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
||||
}
|
||||
}
|
||||
|
||||
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => appState.initialArgs,
|
||||
isBackgroundMode: () => appState.backgroundMode,
|
||||
@@ -3227,6 +3484,10 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
|
||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
handleCliCommand: (args, source) => handleCliCommand(args, source),
|
||||
});
|
||||
@@ -3242,6 +3503,7 @@ const {
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups,
|
||||
startTokenizationWarmups,
|
||||
isTokenizationWarmupReady,
|
||||
} = composeMpvRuntimeHandlers<
|
||||
MpvIpcClient,
|
||||
@@ -3285,6 +3547,7 @@ const {
|
||||
startupOsdSequencer.reset();
|
||||
clearScheduledSubtitlePrefetchRefresh();
|
||||
subtitlePrefetchInitController.cancelPendingInit();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
// Delay slightly to allow MPV's track-list to be populated.
|
||||
@@ -3321,7 +3584,7 @@ const {
|
||||
);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
||||
return;
|
||||
}
|
||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
@@ -3342,11 +3605,13 @@ const {
|
||||
}
|
||||
lastObservedTimePos = time;
|
||||
},
|
||||
onSubtitleTrackChange: () => {
|
||||
onSubtitleTrackChange: (sid) => {
|
||||
scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
onSubtitleTrackListChange: () => {
|
||||
onSubtitleTrackListChange: (trackList) => {
|
||||
scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
updateSubtitleRenderMetrics: (patch) => {
|
||||
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
|
||||
@@ -3416,7 +3681,8 @@ const {
|
||||
),
|
||||
getCharacterDictionaryEnabled: () =>
|
||||
getResolvedConfig().anilist.characterDictionary.enabled &&
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||
!isYoutubePlaybackActiveNow(),
|
||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
@@ -3513,7 +3779,19 @@ const {
|
||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||
|
||||
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
if (connected) {
|
||||
return;
|
||||
}
|
||||
if (!youtubeFlowRuntime.hasActiveSession()) {
|
||||
return;
|
||||
}
|
||||
youtubeFlowRuntime.cancelActivePicker();
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
||||
overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
|
||||
@@ -3546,6 +3824,11 @@ function getCurrentOverlayGeometry(): WindowGeometry {
|
||||
return getOverlayGeometryFallback();
|
||||
}
|
||||
|
||||
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
|
||||
if (!a || !b) return false;
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
|
||||
function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||
lastOverlayWindowGeometry = geometry;
|
||||
overlayManager.setOverlayWindowBounds(geometry);
|
||||
@@ -4146,6 +4429,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: {
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||
@@ -4189,6 +4473,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
onOverlayModalOpened: (modal) => {
|
||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
@@ -4403,6 +4688,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||
runStatsCliCommand(argsFromCommand, source),
|
||||
runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -105,6 +106,11 @@ function createCliCommandDepsFromContext(
|
||||
runStatsCommand: context.runStatsCommand,
|
||||
runCommand: context.runJellyfinCommand,
|
||||
},
|
||||
app: {
|
||||
stop: context.stopApp,
|
||||
hasMainWindow: context.hasMainWindow,
|
||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
@@ -112,10 +118,6 @@ function createCliCommandDepsFromContext(
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
printHelp: context.printHelp,
|
||||
},
|
||||
app: {
|
||||
stop: context.stopApp,
|
||||
hasMainWindow: context.hasMainWindow,
|
||||
},
|
||||
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
|
||||
schedule: context.schedule,
|
||||
log: context.log,
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||
@@ -166,6 +167,11 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand'];
|
||||
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
||||
};
|
||||
app: {
|
||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
@@ -173,10 +179,6 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||
};
|
||||
app: {
|
||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||
};
|
||||
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
|
||||
schedule: CliCommandDepsRuntimeOptions['schedule'];
|
||||
log: CliCommandDepsRuntimeOptions['log'];
|
||||
@@ -189,6 +191,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle'];
|
||||
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
|
||||
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
@@ -207,6 +210,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||
@@ -324,6 +328,11 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
runStatsCommand: params.jellyfin.runStatsCommand,
|
||||
runCommand: params.jellyfin.runCommand,
|
||||
},
|
||||
app: {
|
||||
stop: params.app.stop,
|
||||
hasMainWindow: params.app.hasMainWindow,
|
||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
@@ -331,10 +340,6 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
},
|
||||
app: {
|
||||
stop: params.app.stop,
|
||||
hasMainWindow: params.app.hasMainWindow,
|
||||
},
|
||||
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
|
||||
schedule: params.schedule,
|
||||
log: params.log,
|
||||
@@ -350,6 +355,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
specialCommands: params.specialCommands,
|
||||
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
|
||||
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
|
||||
@@ -12,6 +12,7 @@ type MpvPropertyClientLike = {
|
||||
export interface MpvCommandFromIpcRuntimeDeps {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
@@ -33,6 +34,7 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
specialCommands: SPECIAL_COMMANDS,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
|
||||
@@ -275,6 +275,82 @@ test('sendToActiveOverlayWindow prefers visible main overlay window for modal op
|
||||
assert.deepEqual(mainWindow.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow can prefer modal window even when main overlay is visible', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(mainWindow.sent, []);
|
||||
assert.deepEqual(modalWindow.sent, [['youtube:picker-open', { sessionId: 'yt-1' }]]);
|
||||
});
|
||||
|
||||
test('modal window path makes visible main overlay click-through until modal closes', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
test('modal window path hides visible main overlay until modal closes', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.getHideCount(), 1);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.getShowCount(), 1);
|
||||
assert.equal(mainWindow.isVisible(), true);
|
||||
});
|
||||
|
||||
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||
const window = createMockWindow();
|
||||
const state: boolean[] = [];
|
||||
@@ -430,3 +506,33 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
|
||||
runtime.notifyOverlayModalOpened('jimaku');
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => null,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
});
|
||||
const pending = runtime.waitForModalOpen('youtube-track-picker', 1000);
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
|
||||
assert.equal(await pending, true);
|
||||
});
|
||||
|
||||
test('waitForModalOpen resolves false on timeout', async () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => null,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
|
||||
});
|
||||
|
||||
@@ -16,11 +16,15 @@ export interface OverlayModalRuntime {
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
}
|
||||
|
||||
@@ -33,7 +37,10 @@ export function createOverlayModalRuntimeService(
|
||||
options: OverlayModalRuntimeOptions = {},
|
||||
): OverlayModalRuntime {
|
||||
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
||||
const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>();
|
||||
let modalActive = false;
|
||||
let mainWindowMousePassthroughForcedByModal = false;
|
||||
let mainWindowHiddenByModal = false;
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -163,6 +170,54 @@ export function createOverlayModalRuntimeService(
|
||||
pendingModalWindowReveal = null;
|
||||
};
|
||||
|
||||
const setMainWindowMousePassthroughForModal = (enabled: boolean): void => {
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
return;
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
mainWindowMousePassthroughForcedByModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mainWindowMousePassthroughForcedByModal) {
|
||||
return;
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
};
|
||||
|
||||
const setMainWindowVisibilityForModal = (hidden: boolean): void => {
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
mainWindowHiddenByModal = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindowHiddenByModal = false;
|
||||
return;
|
||||
}
|
||||
mainWindow.hide();
|
||||
mainWindowHiddenByModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mainWindowHiddenByModal) {
|
||||
return;
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindowHiddenByModal = false;
|
||||
};
|
||||
|
||||
const scheduleModalWindowReveal = (window: BrowserWindow): void => {
|
||||
pendingModalWindowReveal = window;
|
||||
if (pendingModalWindowRevealTimeout !== null) {
|
||||
@@ -182,9 +237,13 @@ export function createOverlayModalRuntimeService(
|
||||
const sendToActiveOverlayWindow = (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
): boolean => {
|
||||
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
||||
const preferModalWindow = runtimeOptions?.preferModalWindow === true;
|
||||
|
||||
const sendNow = (window: BrowserWindow): void => {
|
||||
ensureModalWindowInteractive(window);
|
||||
@@ -198,7 +257,7 @@ export function createOverlayModalRuntimeService(
|
||||
if (restoreOnModalClose) {
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
const mainWindow = getTargetOverlayWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||
sendOrQueueForWindow(mainWindow, (window) => {
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
@@ -255,6 +314,8 @@ export function createOverlayModalRuntimeService(
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
notifyModalStateChange(false);
|
||||
setMainWindowMousePassthroughForModal(false);
|
||||
setMainWindowVisibilityForModal(false);
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
modalWindow.hide();
|
||||
}
|
||||
@@ -263,6 +324,11 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
|
||||
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
||||
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||
modalOpenWaiters.delete(modal);
|
||||
for (const resolve of waiters) {
|
||||
resolve(true);
|
||||
}
|
||||
notifyModalStateChange(true);
|
||||
const targetWindow = getActiveOverlayWindowForModalInput();
|
||||
clearPendingModalWindowReveal();
|
||||
@@ -270,6 +336,12 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
||||
setMainWindowMousePassthroughForModal(true);
|
||||
setMainWindowVisibilityForModal(true);
|
||||
}
|
||||
|
||||
if (targetWindow.isVisible()) {
|
||||
targetWindow.setIgnoreMouseEvents(false);
|
||||
elevateModalWindow(targetWindow);
|
||||
@@ -285,11 +357,34 @@ export function createOverlayModalRuntimeService(
|
||||
showModalWindow(targetWindow);
|
||||
};
|
||||
|
||||
const waitForModalOpen = async (
|
||||
modal: OverlayHostedModal,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> =>
|
||||
await new Promise<boolean>((resolve) => {
|
||||
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||
const finish = (opened: boolean): void => {
|
||||
clearTimeout(timeout);
|
||||
resolve(opened);
|
||||
};
|
||||
waiters.push(finish);
|
||||
modalOpenWaiters.set(modal, waiters);
|
||||
const timeout = setTimeout(() => {
|
||||
const current = modalOpenWaiters.get(modal) ?? [];
|
||||
modalOpenWaiters.set(
|
||||
modal,
|
||||
current.filter((candidate) => candidate !== finish),
|
||||
);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return {
|
||||
sendToActiveOverlayWindow,
|
||||
openRuntimeOptionsPalette,
|
||||
handleOverlayModalClosed,
|
||||
notifyOverlayModalOpened,
|
||||
waitForModalOpen,
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
|
||||
|
||||
export interface OverlayVisibilityRuntimeDeps {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalActive: () => boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
@@ -37,6 +38,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough: deps.getForceMousePassthrough(),
|
||||
mainWindow: deps.getMainWindow(),
|
||||
windowTracker: deps.getWindowTracker(),
|
||||
|
||||
@@ -68,3 +68,32 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
});
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
});
|
||||
|
||||
test('ensureAnilistMediaGuess skips youtube playback urls', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: 'https://www.youtube.com/watch?v=abc123',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
let calls = 0;
|
||||
const ensureGuess = createEnsureAnilistMediaGuessHandler({
|
||||
getState: () => state,
|
||||
setState: (next) => {
|
||||
state = next;
|
||||
},
|
||||
resolveMediaPathForJimaku: (value) => value,
|
||||
getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
getCurrentMediaTitle: () => 'Video',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
calls += 1;
|
||||
return { title: 'Show', season: null, episode: 1, source: 'guessit' };
|
||||
},
|
||||
});
|
||||
|
||||
const guess = await ensureGuess('https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(guess, null);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(state.mediaGuess, null);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
export type AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: string | null;
|
||||
@@ -26,6 +27,9 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
}
|
||||
if (isYoutubeMediaPath(mediaKey)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
|
||||
return state.mediaDurationSec;
|
||||
}
|
||||
@@ -73,6 +77,9 @@ export function createEnsureAnilistMediaGuessHandler(deps: {
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
}
|
||||
if (isYoutubeMediaPath(mediaKey)) {
|
||||
return null;
|
||||
}
|
||||
if (state.mediaGuess) {
|
||||
return state.mediaGuess;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ test('get current anilist media key trims and normalizes empty path', () => {
|
||||
assert.equal(getEmptyKey(), null);
|
||||
});
|
||||
|
||||
test('get current anilist media key skips youtube playback urls', () => {
|
||||
const getYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => ' https://www.youtube.com/watch?v=abc123 ',
|
||||
});
|
||||
const getShortYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => 'https://youtu.be/abc123',
|
||||
});
|
||||
|
||||
assert.equal(getYoutubeKey(), null);
|
||||
assert.equal(getShortYoutubeKey(), null);
|
||||
});
|
||||
|
||||
test('reset anilist media tracking clears duration/guess/probe state', () => {
|
||||
let mediaKey: string | null = 'old';
|
||||
let mediaDurationSec: number | null = 123;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
export function createGetCurrentAnilistMediaKeyHandler(deps: {
|
||||
getCurrentMediaPath: () => string | null;
|
||||
}) {
|
||||
return (): string | null => {
|
||||
const mediaPath = deps.getCurrentMediaPath()?.trim();
|
||||
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
|
||||
if (!mediaPath || mediaPath.length === 0 || isYoutubeMediaPath(mediaPath)) {
|
||||
return null;
|
||||
}
|
||||
return mediaPath;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -76,3 +76,52 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
|
||||
assert.ok(calls.includes('inflight:true'));
|
||||
assert.ok(calls.includes('inflight:false'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => {
|
||||
calls.push('probe');
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => {
|
||||
calls.push('guess');
|
||||
return { title: 'Show', season: null, episode: 1 };
|
||||
},
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => {
|
||||
calls.push('process-retry');
|
||||
return { ok: true, message: 'noop' };
|
||||
},
|
||||
refreshAnilistClientSecretState: async () => {
|
||||
calls.push('refresh-token');
|
||||
return 'token';
|
||||
},
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type AnilistGuess = {
|
||||
title: string;
|
||||
episode: number | null;
|
||||
@@ -130,6 +132,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
if (!mediaKey || !deps.hasMpvClient()) {
|
||||
return;
|
||||
}
|
||||
if (isYoutubeMediaPath(mediaKey)) {
|
||||
return;
|
||||
}
|
||||
if (deps.getTrackedMediaKey() !== mediaKey) {
|
||||
deps.resetTrackedMedia(mediaKey);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
|
||||
@@ -36,6 +36,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -83,6 +84,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
|
||||
@@ -63,6 +63,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
}),
|
||||
runStatsCommand: async () => {},
|
||||
runJellyfinCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
|
||||
@@ -84,7 +84,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -95,6 +96,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
deps.generateCharacterDictionary(targetPath),
|
||||
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
|
||||
@@ -50,6 +50,7 @@ function createDeps() {
|
||||
}),
|
||||
runStatsCommand: async () => {},
|
||||
runJellyfinCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
|
||||
@@ -41,6 +41,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -95,6 +96,7 @@ export function createCliCommandContext(
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
|
||||
@@ -33,3 +33,28 @@ test('cli command runtime handler applies precheck and forwards command with con
|
||||
'cli:initial:ctx',
|
||||
]);
|
||||
});
|
||||
|
||||
test('cli command runtime handler prepares overlay prerequisites before overlay runtime commands', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createCliCommandRuntimeHandler({
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
createCliCommandContext: () => {
|
||||
calls.push('context');
|
||||
return { id: 'ctx' };
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
|
||||
calls.push(`cli:${source}:${context.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
handler({ settings: true } as never);
|
||||
|
||||
assert.deepEqual(calls, ['prereqs', 'context', 'cli:initial:ctx']);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,12 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
|
||||
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
|
||||
handleTexthookerOnlyModeTransitionHandler(args);
|
||||
if (
|
||||
!deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() &&
|
||||
deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayRuntime(args)
|
||||
) {
|
||||
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
||||
}
|
||||
const cliContext = deps.createCliCommandContext();
|
||||
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
mpvCommandMainDeps: {
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
@@ -67,6 +68,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
ankiJimakuDeps: {
|
||||
patchAnkiConnectEnabled: () => {},
|
||||
|
||||
@@ -56,6 +56,57 @@ test('createImmersionTrackerStartupHandler skips when disabled', () => {
|
||||
assert.equal(tracker, 'unchanged');
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler skips when env disables session tracking', () => {
|
||||
const calls: string[] = [];
|
||||
const originalEnv = process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
|
||||
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = '1';
|
||||
|
||||
try {
|
||||
let tracker: unknown = 'unchanged';
|
||||
const handler = createImmersionTrackerStartupHandler({
|
||||
getResolvedConfig: () => {
|
||||
calls.push('getResolvedConfig');
|
||||
return makeConfig();
|
||||
},
|
||||
getConfiguredDbPath: () => {
|
||||
calls.push('getConfiguredDbPath');
|
||||
return '/tmp/subminer.db';
|
||||
},
|
||||
createTrackerService: () => {
|
||||
calls.push('createTrackerService');
|
||||
return {};
|
||||
},
|
||||
setTracker: (nextTracker) => {
|
||||
tracker = nextTracker;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
|
||||
assert.equal(calls.includes('getResolvedConfig'), false);
|
||||
assert.equal(calls.includes('getConfiguredDbPath'), false);
|
||||
assert.equal(calls.includes('createTrackerService'), false);
|
||||
assert.equal(calls.includes('seedTracker'), false);
|
||||
assert.equal(tracker, 'unchanged');
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'info:Immersion tracking disabled for this session by SUBMINER_DISABLE_IMMERSION_TRACKING=1.',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
|
||||
} else {
|
||||
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => {
|
||||
const calls: string[] = [];
|
||||
const trackerInstance = { kind: 'tracker' };
|
||||
|
||||
@@ -23,6 +23,8 @@ type ImmersionTrackingConfig = {
|
||||
|
||||
type ImmersionTrackerPolicy = Omit<ImmersionTrackingPolicy, 'enabled'>;
|
||||
|
||||
const DISABLE_IMMERSION_TRACKING_SESSION_ENV = 'SUBMINER_DISABLE_IMMERSION_TRACKING';
|
||||
|
||||
type ImmersionTrackerServiceParams = {
|
||||
dbPath: string;
|
||||
policy: ImmersionTrackerPolicy;
|
||||
@@ -49,7 +51,16 @@ export type ImmersionTrackerStartupDeps = {
|
||||
export function createImmersionTrackerStartupHandler(
|
||||
deps: ImmersionTrackerStartupDeps,
|
||||
): () => void {
|
||||
const isSessionTrackingDisabled = process.env[DISABLE_IMMERSION_TRACKING_SESSION_ENV] === '1';
|
||||
|
||||
return () => {
|
||||
if (isSessionTrackingDisabled) {
|
||||
deps.logInfo(
|
||||
`Immersion tracking disabled for this session by ${DISABLE_IMMERSION_TRACKING_SESSION_ENV}=1.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = deps.getResolvedConfig();
|
||||
if (config.immersionTracking?.enabled === false) {
|
||||
deps.logInfo('Immersion tracking disabled in config');
|
||||
|
||||
@@ -13,6 +13,10 @@ test('initial args handler no-ops without initial args', () => {
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => {},
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {
|
||||
handled = true;
|
||||
@@ -36,6 +40,10 @@ test('initial args handler ensures tray in background mode', () => {
|
||||
isTexthookerOnlyMode: () => true,
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => {},
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {},
|
||||
});
|
||||
@@ -61,6 +69,10 @@ test('initial args handler auto-connects mpv when needed', () => {
|
||||
connectCalls += 1;
|
||||
},
|
||||
}),
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => {},
|
||||
logInfo: () => {
|
||||
logged = true;
|
||||
},
|
||||
@@ -83,6 +95,14 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
seenSources.push('prereqs');
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => {
|
||||
seenSources.push('init-overlay');
|
||||
},
|
||||
logInfo: () => {},
|
||||
handleCliCommand: (_args, source) => {
|
||||
seenSources.push(source);
|
||||
@@ -93,6 +113,37 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
assert.deepEqual(seenSources, ['initial']);
|
||||
});
|
||||
|
||||
test('initial args handler bootstraps overlay before initial overlay-runtime commands', () => {
|
||||
const calls: string[] = [];
|
||||
const args = { settings: true } as never;
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => args,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args,
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('prereqs');
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
logInfo: () => {},
|
||||
handleCliCommand: (_args, source) => {
|
||||
calls.push(`cli:${source}`);
|
||||
},
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
|
||||
assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']);
|
||||
});
|
||||
|
||||
test('initial args handler can ensure tray outside background mode when requested', () => {
|
||||
let ensuredTray = false;
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
@@ -106,6 +157,10 @@ test('initial args handler can ensure tray outside background mode when requeste
|
||||
isTexthookerOnlyMode: () => true,
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => {},
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {},
|
||||
});
|
||||
@@ -133,6 +188,10 @@ test('initial args handler skips tray and mpv auto-connect for headless refresh'
|
||||
connectCalls += 1;
|
||||
},
|
||||
}),
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => {},
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {},
|
||||
});
|
||||
|
||||
@@ -14,6 +14,10 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntime: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
handleCliCommand: (args: CliArgs, source: 'initial') => void;
|
||||
}) {
|
||||
@@ -39,6 +43,13 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
mpvClient.connect();
|
||||
}
|
||||
|
||||
if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) {
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
}
|
||||
|
||||
deps.handleCliCommand(initialArgs, 'initial');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
getMpvClient: () => mpvClient,
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
})();
|
||||
@@ -26,9 +30,13 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||
assert.equal(deps.hasImmersionTracker(), true);
|
||||
assert.equal(deps.getMpvClient(), mpvClient);
|
||||
assert.equal(deps.commandNeedsOverlayRuntime(args), true);
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), false);
|
||||
|
||||
deps.ensureTray();
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.initializeOverlayRuntime();
|
||||
deps.logInfo('x');
|
||||
deps.handleCliCommand(args, 'initial');
|
||||
assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']);
|
||||
assert.deepEqual(calls, ['ensure-tray', 'prereqs', 'init-overlay', 'info:x', 'cli:initial']);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
getMpvClient: () => { connected: boolean; connect: () => void } | null;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntime: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
handleCliCommand: (args: CliArgs, source: 'initial') => void;
|
||||
}) {
|
||||
@@ -21,6 +25,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
||||
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source),
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ test('initial args runtime handler composes main deps and runs initial command f
|
||||
connected: false,
|
||||
connect: () => calls.push('connect'),
|
||||
}),
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
});
|
||||
@@ -44,6 +48,10 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
|
||||
connected: false,
|
||||
connect: () => calls.push('connect'),
|
||||
}),
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
});
|
||||
@@ -67,6 +75,10 @@ test('initial args runtime handler skips tray and mpv auto-connect for headless
|
||||
connected: false,
|
||||
connect: () => calls.push('connect'),
|
||||
}),
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
||||
buildMpvCommandDeps: () => ({
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
|
||||
@@ -10,6 +10,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
||||
const deps = {
|
||||
triggerSubsyncFromConfig: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
|
||||
@@ -7,6 +7,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({
|
||||
triggerSubsyncFromConfig: () => calls.push('subsync'),
|
||||
openRuntimeOptionsPalette: () => calls.push('palette'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
@@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
|
||||
deps.triggerSubsyncFromConfig();
|
||||
deps.openRuntimeOptionsPalette();
|
||||
void deps.openYoutubeTrackPicker();
|
||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||
deps.showMpvOsd('hello');
|
||||
deps.replayCurrentSubtitle();
|
||||
@@ -34,6 +38,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
assert.deepEqual(calls, [
|
||||
'subsync',
|
||||
'palette',
|
||||
'youtube-picker',
|
||||
'osd:hello',
|
||||
'replay',
|
||||
'next',
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
return (): MpvCommandFromIpcRuntimeDeps => ({
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
|
||||
@@ -26,7 +26,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
calls.push('post-watch');
|
||||
},
|
||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||
|
||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
|
||||
@@ -116,3 +116,45 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||
});
|
||||
|
||||
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
|
||||
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: null,
|
||||
immersionTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
deps.setCurrentSubText('sub');
|
||||
assert.equal(typeof deps.setCurrentSubText, 'function');
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
|
||||
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||
getMainWindow: () => mainWindow,
|
||||
getModalActive: () => true,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getForceMousePassthrough: () => true,
|
||||
getWindowTracker: () => tracker,
|
||||
@@ -32,6 +33,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
})();
|
||||
|
||||
assert.equal(deps.getMainWindow(), mainWindow);
|
||||
assert.equal(deps.getModalActive(), true);
|
||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getForceMousePassthrough(), true);
|
||||
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
) {
|
||||
return (): OverlayVisibilityRuntimeDeps => ({
|
||||
getMainWindow: () => deps.getMainWindow(),
|
||||
getModalActive: () => deps.getModalActive(),
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
|
||||
@@ -33,13 +33,17 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
|
||||
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
|
||||
export function buildWindowsMpvLaunchArgs(
|
||||
targets: string[],
|
||||
extraArgs: string[] = [],
|
||||
): string[] {
|
||||
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
|
||||
}
|
||||
|
||||
export function launchWindowsMpv(
|
||||
targets: string[],
|
||||
deps: WindowsMpvLaunchDeps,
|
||||
extraArgs: string[] = [],
|
||||
): { ok: boolean; mpvPath: string } {
|
||||
const mpvPath = resolveWindowsMpvPath(deps);
|
||||
if (!mpvPath) {
|
||||
@@ -51,7 +55,7 @@ export function launchWindowsMpv(
|
||||
}
|
||||
|
||||
try {
|
||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
|
||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
|
||||
return { ok: true, mpvPath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
1145
src/main/runtime/youtube-flow.test.ts
Normal file
1145
src/main/runtime/youtube-flow.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
896
src/main/runtime/youtube-flow.ts
Normal file
896
src/main/runtime/youtube-flow.ts
Normal file
@@ -0,0 +1,896 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
YoutubePickerOpenPayload,
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
} from '../../types';
|
||||
import type {
|
||||
YoutubeTrackOption,
|
||||
YoutubeTrackProbeResult,
|
||||
} from '../../core/services/youtube/track-probe';
|
||||
import {
|
||||
chooseDefaultYoutubeTrackIds,
|
||||
normalizeYoutubeTrackSelection,
|
||||
} from '../../core/services/youtube/track-selection';
|
||||
import {
|
||||
acquireYoutubeSubtitleTrack,
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from '../../core/services/youtube/generate';
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
|
||||
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
||||
type YoutubeFlowMode = 'download' | 'generate';
|
||||
|
||||
type YoutubeFlowDeps = {
|
||||
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
||||
acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack;
|
||||
acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks;
|
||||
retimeYoutubePrimaryTrack: (input: {
|
||||
targetUrl: string;
|
||||
primaryTrack: YoutubeTrackOption;
|
||||
primaryPath: string;
|
||||
secondaryTrack: YoutubeTrackOption | null;
|
||||
secondaryPath: string | null;
|
||||
}) => Promise<string>;
|
||||
openPicker: YoutubeFlowOpenPicker;
|
||||
pauseMpv: () => void;
|
||||
resumeMpv: () => void;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
waitForTokenizationReady: () => Promise<void>;
|
||||
waitForAnkiReady: () => Promise<void>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
waitForPlaybackWindowReady: () => Promise<void>;
|
||||
waitForOverlayGeometryReady: () => Promise<void>;
|
||||
focusOverlayWindow: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
reportSubtitleFailure: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
log: (message: string) => void;
|
||||
getYoutubeOutputDir: () => string;
|
||||
};
|
||||
|
||||
type YoutubeFlowSession = {
|
||||
sessionId: string;
|
||||
resolve: (request: YoutubePickerResolveRequest) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
|
||||
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
|
||||
|
||||
function createSessionId(): string {
|
||||
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function getTrackById(tracks: YoutubeTrackOption[], id: string | null): YoutubeTrackOption | null {
|
||||
if (!id) return null;
|
||||
return tracks.find((track) => track.id === id) ?? null;
|
||||
}
|
||||
|
||||
function normalizeOutputPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || path.join(os.tmpdir(), 'subminer-youtube-subs');
|
||||
}
|
||||
|
||||
function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
|
||||
const frames = ['|', '/', '-', '\\'];
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let frame = 0;
|
||||
|
||||
const stop = (): void => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const setMessage = (message: string): void => {
|
||||
stop();
|
||||
frame = 0;
|
||||
showMpvOsd(message);
|
||||
timer = setInterval(() => {
|
||||
showMpvOsd(`${message} ${frames[frame % frames.length]}`);
|
||||
frame += 1;
|
||||
}, 180);
|
||||
};
|
||||
|
||||
return {
|
||||
setMessage,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
|
||||
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
|
||||
deps.resumeMpv();
|
||||
}
|
||||
|
||||
function suppressYoutubeSubtitleState(deps: YoutubeFlowDeps): void {
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
|
||||
}
|
||||
|
||||
function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void {
|
||||
deps.focusOverlayWindow();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function ensureSubtitleTrackSelection(input: {
|
||||
deps: YoutubeFlowDeps;
|
||||
property: 'sid' | 'secondary-sid';
|
||||
targetId: number;
|
||||
}): Promise<void> {
|
||||
input.deps.sendMpvCommand(['set_property', input.property, input.targetId]);
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const currentId = parseTrackId(await input.deps.requestMpvProperty(input.property));
|
||||
if (currentId === input.targetId) {
|
||||
return;
|
||||
}
|
||||
await input.deps.wait(100);
|
||||
input.deps.sendMpvCommand(['set_property', input.property, input.targetId]);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTrackListEntry(track: Record<string, unknown>): {
|
||||
id: number | null;
|
||||
lang: string;
|
||||
title: string;
|
||||
external: boolean;
|
||||
externalFilename: string | null;
|
||||
} {
|
||||
const externalFilenameRaw =
|
||||
typeof track['external-filename'] === 'string'
|
||||
? track['external-filename']
|
||||
: typeof track.external_filename === 'string'
|
||||
? track.external_filename
|
||||
: '';
|
||||
const externalFilename = externalFilenameRaw.trim()
|
||||
? resolveSubtitleSourcePath(externalFilenameRaw.trim())
|
||||
: null;
|
||||
return {
|
||||
id: parseTrackId(track.id),
|
||||
lang: String(track.lang || '').trim(),
|
||||
title: String(track.title || '').trim(),
|
||||
external: track.external === true,
|
||||
externalFilename,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesTitleBasename(title: string, basename: string): boolean {
|
||||
const normalizedTitle = title.trim();
|
||||
return normalizedTitle.length > 0 && path.basename(normalizedTitle) === basename;
|
||||
}
|
||||
|
||||
function isLikelyTranslatedYoutubeTrack(entry: { lang: string; title: string }): boolean {
|
||||
const normalizedTitle = entry.title.trim().toLowerCase();
|
||||
if (normalizedTitle.includes(' from ')) {
|
||||
return true;
|
||||
}
|
||||
return /-[a-z]{2,}(?:-[a-z0-9]+)?$/i.test(entry.lang.trim());
|
||||
}
|
||||
|
||||
function matchExistingManualYoutubeTrackId(
|
||||
trackListRaw: unknown,
|
||||
trackOption: YoutubeTrackOption,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
if (!Array.isArray(trackListRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
|
||||
const expectedLanguages = new Set(
|
||||
[trackOption.language, trackOption.sourceLanguage]
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
const tracks = trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
|
||||
)
|
||||
.filter((track) => track.type === 'sub')
|
||||
.map(normalizeTrackListEntry)
|
||||
.filter((track) => track.external && track.id !== null && track.id !== excludeId)
|
||||
.filter((track) => !isLikelyTranslatedYoutubeTrack(track));
|
||||
|
||||
const exactTitleMatch = tracks.find(
|
||||
(track) =>
|
||||
expectedTitle.length > 0 &&
|
||||
track.title.trim().toLowerCase() === expectedTitle &&
|
||||
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||
);
|
||||
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
|
||||
return exactTitleMatch.id;
|
||||
}
|
||||
|
||||
if (expectedTitle.length === 0) {
|
||||
const languageOnlyMatch = tracks.find((track) =>
|
||||
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||
);
|
||||
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
|
||||
return languageOnlyMatch.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchExistingYoutubeTrackId(
|
||||
trackListRaw: unknown,
|
||||
trackOption: YoutubeTrackOption,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
if (!Array.isArray(trackListRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedTitle = trackOption.title?.trim().toLowerCase() || '';
|
||||
const expectedLanguages = new Set(
|
||||
[trackOption.language, trackOption.sourceLanguage]
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
const tracks = trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
|
||||
)
|
||||
.filter((track) => track.type === 'sub')
|
||||
.map(normalizeTrackListEntry)
|
||||
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
|
||||
|
||||
const exactTitleMatch = tracks.find(
|
||||
(track) =>
|
||||
expectedTitle.length > 0 &&
|
||||
track.title.trim().toLowerCase() === expectedTitle &&
|
||||
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||
);
|
||||
if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) {
|
||||
return exactTitleMatch.id;
|
||||
}
|
||||
|
||||
if (expectedTitle.length === 0) {
|
||||
const languageOnlyMatch = tracks.find((track) =>
|
||||
expectedLanguages.has(track.lang.trim().toLowerCase()),
|
||||
);
|
||||
if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) {
|
||||
return languageOnlyMatch.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchExternalTrackId(
|
||||
trackListRaw: unknown,
|
||||
filePath: string,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
if (!Array.isArray(trackListRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFilePath = resolveSubtitleSourcePath(filePath);
|
||||
const basename = path.basename(normalizedFilePath);
|
||||
const externalTracks = trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
|
||||
)
|
||||
.filter((track) => track.type === 'sub')
|
||||
.map(normalizeTrackListEntry)
|
||||
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
|
||||
|
||||
const exactPathMatch = externalTracks.find(
|
||||
(track) => track.externalFilename === normalizedFilePath,
|
||||
);
|
||||
if (exactPathMatch?.id !== null && exactPathMatch?.id !== undefined) {
|
||||
return exactPathMatch.id;
|
||||
}
|
||||
|
||||
const basenameMatch = externalTracks.find(
|
||||
(track) => track.externalFilename && path.basename(track.externalFilename) === basename,
|
||||
);
|
||||
if (basenameMatch?.id !== null && basenameMatch?.id !== undefined) {
|
||||
return basenameMatch.id;
|
||||
}
|
||||
|
||||
const titleMatch = externalTracks.find((track) => matchesTitleBasename(track.title, basename));
|
||||
if (titleMatch?.id !== null && titleMatch?.id !== undefined) {
|
||||
return titleMatch.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function injectDownloadedSubtitles(
|
||||
deps: YoutubeFlowDeps,
|
||||
primarySelection: {
|
||||
track: YoutubeTrackOption;
|
||||
existingTrackId: number | null;
|
||||
injectedPath: string | null;
|
||||
},
|
||||
secondaryTrack: YoutubeTrackOption | null,
|
||||
secondarySelection: {
|
||||
existingTrackId: number | null;
|
||||
injectedPath: string | null;
|
||||
} | null,
|
||||
): Promise<boolean> {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||
if (primarySelection.injectedPath) {
|
||||
deps.sendMpvCommand([
|
||||
'sub-add',
|
||||
primarySelection.injectedPath,
|
||||
'select',
|
||||
path.basename(primarySelection.injectedPath),
|
||||
primarySelection.track.sourceLanguage,
|
||||
]);
|
||||
}
|
||||
if (secondarySelection?.injectedPath && secondaryTrack) {
|
||||
deps.sendMpvCommand([
|
||||
'sub-add',
|
||||
secondarySelection.injectedPath,
|
||||
'cached',
|
||||
path.basename(secondarySelection.injectedPath),
|
||||
secondaryTrack.sourceLanguage,
|
||||
]);
|
||||
}
|
||||
|
||||
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
|
||||
let primaryTrackId: number | null = primarySelection.existingTrackId;
|
||||
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
|
||||
await deps.wait(attempt === 0 ? 150 : 100);
|
||||
trackListRaw = await deps.requestMpvProperty('track-list');
|
||||
}
|
||||
if (primaryTrackId === null && primarySelection.injectedPath) {
|
||||
primaryTrackId = matchExternalTrackId(trackListRaw, primarySelection.injectedPath);
|
||||
}
|
||||
if (secondarySelection?.injectedPath && secondaryTrack && secondaryTrackId === null) {
|
||||
secondaryTrackId = matchExternalTrackId(
|
||||
trackListRaw,
|
||||
secondarySelection.injectedPath,
|
||||
primaryTrackId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
primaryTrackId !== null &&
|
||||
(!secondaryTrack || secondarySelection === null || secondaryTrackId !== null)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryTrackId !== null) {
|
||||
await ensureSubtitleTrackSelection({
|
||||
deps,
|
||||
property: 'sid',
|
||||
targetId: primaryTrackId,
|
||||
});
|
||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']);
|
||||
} else {
|
||||
deps.warn(
|
||||
`Unable to bind downloaded primary subtitle track in mpv: ${
|
||||
primarySelection.injectedPath ? path.basename(primarySelection.injectedPath) : primarySelection.track.label
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (secondaryTrack && secondarySelection) {
|
||||
if (secondaryTrackId !== null) {
|
||||
await ensureSubtitleTrackSelection({
|
||||
deps,
|
||||
property: 'secondary-sid',
|
||||
targetId: secondaryTrackId,
|
||||
});
|
||||
} else {
|
||||
deps.warn(
|
||||
`Unable to bind downloaded secondary subtitle track in mpv: ${
|
||||
secondarySelection.injectedPath
|
||||
? path.basename(secondarySelection.injectedPath)
|
||||
: secondaryTrack.label
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryTrackId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentSubText = await deps.requestMpvProperty('sub-text');
|
||||
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
|
||||
deps.refreshCurrentSubtitle(currentSubText);
|
||||
}
|
||||
|
||||
deps.showMpvOsd(
|
||||
secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
let activeSession: YoutubeFlowSession | null = null;
|
||||
|
||||
const acquireSelectedTracks = async (input: {
|
||||
targetUrl: string;
|
||||
outputDir: string;
|
||||
primaryTrack: YoutubeTrackOption;
|
||||
secondaryTrack: YoutubeTrackOption | null;
|
||||
secondaryFailureLabel: string;
|
||||
}): Promise<{ primaryPath: string; secondaryPath: string | null }> => {
|
||||
if (!input.secondaryTrack) {
|
||||
const primaryPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
return { primaryPath, secondaryPath: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const batchResult = await deps.acquireYoutubeSubtitleTracks({
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
tracks: [input.primaryTrack, input.secondaryTrack],
|
||||
});
|
||||
const primaryPath = batchResult.get(input.primaryTrack.id) ?? null;
|
||||
const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null;
|
||||
if (primaryPath) {
|
||||
if (secondaryPath) {
|
||||
return { primaryPath, secondaryPath };
|
||||
}
|
||||
|
||||
deps.log(
|
||||
`${
|
||||
input.secondaryFailureLabel
|
||||
}: No subtitle file was downloaded for ${input.secondaryTrack.sourceLanguage}; retrying secondary separately after delay.`,
|
||||
);
|
||||
await deps.wait(YOUTUBE_SECONDARY_RETRY_DELAY_MS);
|
||||
try {
|
||||
const retriedSecondaryPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
track: input.secondaryTrack,
|
||||
})
|
||||
).path;
|
||||
return { primaryPath, secondaryPath: retriedSecondaryPath };
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`${input.secondaryFailureLabel}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return { primaryPath, secondaryPath: null };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to primary-only recovery
|
||||
}
|
||||
|
||||
try {
|
||||
const primaryPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.targetUrl,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
return { primaryPath, secondaryPath: null };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveActivePicker = async (
|
||||
request: YoutubePickerResolveRequest,
|
||||
): Promise<YoutubePickerResolveResult> => {
|
||||
if (!activeSession || activeSession.sessionId !== request.sessionId) {
|
||||
return { ok: false, message: 'No active YouTube subtitle picker session.' };
|
||||
}
|
||||
activeSession.resolve(request);
|
||||
return { ok: true, message: 'Picker selection accepted.' };
|
||||
};
|
||||
|
||||
const cancelActivePicker = (): boolean => {
|
||||
if (!activeSession) {
|
||||
return false;
|
||||
}
|
||||
activeSession.resolve({
|
||||
sessionId: activeSession.sessionId,
|
||||
action: 'continue-without-subtitles',
|
||||
primaryTrackId: null,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const createPickerSelectionPromise = (sessionId: string): Promise<YoutubePickerResolveRequest> =>
|
||||
new Promise<YoutubePickerResolveRequest>((resolve, reject) => {
|
||||
activeSession = { sessionId, resolve, reject };
|
||||
}).finally(() => {
|
||||
activeSession = null;
|
||||
});
|
||||
|
||||
const reportPrimarySubtitleFailure = (): void => {
|
||||
deps.reportSubtitleFailure(
|
||||
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
|
||||
);
|
||||
};
|
||||
|
||||
const buildOpenPayload = (
|
||||
input: {
|
||||
url: string;
|
||||
},
|
||||
probe: YoutubeTrackProbeResult,
|
||||
): YoutubePickerOpenPayload => {
|
||||
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
|
||||
return {
|
||||
sessionId: createSessionId(),
|
||||
url: input.url,
|
||||
tracks: probe.tracks,
|
||||
defaultPrimaryTrackId: defaults.primaryTrackId,
|
||||
defaultSecondaryTrackId: defaults.secondaryTrackId,
|
||||
hasTracks: probe.tracks.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const loadTracksIntoMpv = async (input: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
outputDir: string;
|
||||
primaryTrack: YoutubeTrackOption;
|
||||
secondaryTrack: YoutubeTrackOption | null;
|
||||
secondaryFailureLabel: string;
|
||||
tokenizationWarmupPromise?: Promise<void>;
|
||||
showDownloadProgress: boolean;
|
||||
}): Promise<boolean> => {
|
||||
const osdProgress = input.showDownloadProgress
|
||||
? createYoutubeFlowOsdProgress(deps.showMpvOsd)
|
||||
: null;
|
||||
if (osdProgress) {
|
||||
osdProgress.setMessage('Downloading subtitles...');
|
||||
}
|
||||
try {
|
||||
let initialTrackListRaw: unknown = null;
|
||||
let existingPrimaryTrackId: number | null = null;
|
||||
let existingSecondaryTrackId: number | null = null;
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
await deps.wait(attempt === 1 ? 150 : 100);
|
||||
}
|
||||
initialTrackListRaw = await deps.requestMpvProperty('track-list');
|
||||
existingPrimaryTrackId =
|
||||
input.primaryTrack.kind === 'manual'
|
||||
? matchExistingManualYoutubeTrackId(initialTrackListRaw, input.primaryTrack)
|
||||
: null;
|
||||
existingSecondaryTrackId =
|
||||
input.secondaryTrack?.kind === 'manual'
|
||||
? matchExistingManualYoutubeTrackId(
|
||||
initialTrackListRaw,
|
||||
input.secondaryTrack,
|
||||
existingPrimaryTrackId,
|
||||
)
|
||||
: null;
|
||||
const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null;
|
||||
const secondaryReady =
|
||||
!input.secondaryTrack ||
|
||||
input.secondaryTrack.kind !== 'manual' ||
|
||||
existingSecondaryTrackId !== null;
|
||||
if (primaryReady && secondaryReady) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let primarySidebarPath: string;
|
||||
let primaryInjectedPath: string | null = null;
|
||||
let secondaryInjectedPath: string | null = null;
|
||||
|
||||
if (existingPrimaryTrackId !== null) {
|
||||
primarySidebarPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||
primaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
track: input.primaryTrack,
|
||||
})
|
||||
).path;
|
||||
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
|
||||
targetUrl: input.url,
|
||||
primaryTrack: input.primaryTrack,
|
||||
primaryPath: primaryInjectedPath,
|
||||
secondaryTrack: input.secondaryTrack,
|
||||
secondaryPath: null,
|
||||
});
|
||||
primaryInjectedPath = primarySidebarPath;
|
||||
} else {
|
||||
const acquired = await acquireSelectedTracks({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
primaryTrack: input.primaryTrack,
|
||||
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
|
||||
secondaryFailureLabel: input.secondaryFailureLabel,
|
||||
});
|
||||
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
|
||||
targetUrl: input.url,
|
||||
primaryTrack: input.primaryTrack,
|
||||
primaryPath: acquired.primaryPath,
|
||||
secondaryTrack: input.secondaryTrack,
|
||||
secondaryPath: acquired.secondaryPath,
|
||||
});
|
||||
primaryInjectedPath = primarySidebarPath;
|
||||
secondaryInjectedPath = acquired.secondaryPath;
|
||||
}
|
||||
|
||||
if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) {
|
||||
try {
|
||||
secondaryInjectedPath = (
|
||||
await deps.acquireYoutubeSubtitleTrack({
|
||||
targetUrl: input.url,
|
||||
outputDir: input.outputDir,
|
||||
track: input.secondaryTrack,
|
||||
})
|
||||
).path;
|
||||
} catch (error) {
|
||||
const fallbackExistingSecondaryTrackId =
|
||||
input.secondaryTrack.kind === 'auto'
|
||||
? matchExistingYoutubeTrackId(
|
||||
initialTrackListRaw,
|
||||
input.secondaryTrack,
|
||||
existingPrimaryTrackId,
|
||||
)
|
||||
: null;
|
||||
if (fallbackExistingSecondaryTrackId !== null) {
|
||||
existingSecondaryTrackId = fallbackExistingSecondaryTrackId;
|
||||
} else {
|
||||
deps.warn(
|
||||
`${input.secondaryFailureLabel}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deps.showMpvOsd('Loading subtitles...');
|
||||
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
|
||||
deps,
|
||||
{
|
||||
track: input.primaryTrack,
|
||||
existingTrackId: existingPrimaryTrackId,
|
||||
injectedPath: primaryInjectedPath,
|
||||
},
|
||||
input.secondaryTrack,
|
||||
input.secondaryTrack
|
||||
? {
|
||||
existingTrackId: existingSecondaryTrackId,
|
||||
injectedPath: secondaryInjectedPath,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
if (!refreshedActiveSubtitle) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to refresh parsed subtitle cues for sidebar: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (input.tokenizationWarmupPromise) {
|
||||
await input.tokenizationWarmupPromise;
|
||||
}
|
||||
await deps.waitForTokenizationReady();
|
||||
await deps.waitForAnkiReady();
|
||||
return true;
|
||||
} finally {
|
||||
osdProgress?.stop();
|
||||
}
|
||||
};
|
||||
|
||||
const openManualPicker = async (input: {
|
||||
url: string;
|
||||
mode?: YoutubeFlowMode;
|
||||
}): Promise<void> => {
|
||||
let probe: YoutubeTrackProbeResult;
|
||||
try {
|
||||
probe = await deps.probeYoutubeTracks(input.url);
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to probe YouTube subtitle tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
reportPrimarySubtitleFailure();
|
||||
restoreOverlayInputFocus(deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const openPayload = buildOpenPayload(input, probe);
|
||||
await deps.waitForPlaybackWindowReady();
|
||||
await deps.waitForOverlayGeometryReady();
|
||||
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
|
||||
const pickerSelection = createPickerSelectionPromise(openPayload.sessionId);
|
||||
void pickerSelection.catch(() => undefined);
|
||||
|
||||
let opened = false;
|
||||
try {
|
||||
opened = await deps.openPicker(openPayload);
|
||||
} catch (error) {
|
||||
activeSession?.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
deps.warn(
|
||||
`Unable to open YouTube subtitle picker: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
restoreOverlayInputFocus(deps);
|
||||
return;
|
||||
}
|
||||
if (!opened) {
|
||||
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
|
||||
activeSession = null;
|
||||
deps.warn('Unable to open YouTube subtitle picker.');
|
||||
restoreOverlayInputFocus(deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = await pickerSelection;
|
||||
if (request.action === 'continue-without-subtitles') {
|
||||
restoreOverlayInputFocus(deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
|
||||
if (!primaryTrack) {
|
||||
deps.warn('No primary YouTube subtitle track selected.');
|
||||
restoreOverlayInputFocus(deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = normalizeYoutubeTrackSelection({
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: request.secondaryTrackId,
|
||||
});
|
||||
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
|
||||
|
||||
try {
|
||||
deps.showMpvOsd('Getting subtitles...');
|
||||
const loaded = await loadTracksIntoMpv({
|
||||
url: input.url,
|
||||
mode: input.mode ?? 'download',
|
||||
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
|
||||
showDownloadProgress: true,
|
||||
});
|
||||
if (!loaded) {
|
||||
reportPrimarySubtitleFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to download primary YouTube subtitle track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
reportPrimarySubtitleFailure();
|
||||
} finally {
|
||||
restoreOverlayInputFocus(deps);
|
||||
}
|
||||
};
|
||||
|
||||
async function runYoutubePlaybackFlow(input: {
|
||||
url: string;
|
||||
mode: YoutubeFlowMode;
|
||||
}): Promise<void> {
|
||||
deps.showMpvOsd('Opening YouTube video');
|
||||
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
|
||||
deps.warn(
|
||||
`Failed to warm subtitle tokenization prerequisites: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
|
||||
deps.pauseMpv();
|
||||
suppressYoutubeSubtitleState(deps);
|
||||
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
||||
|
||||
let probe: YoutubeTrackProbeResult;
|
||||
try {
|
||||
probe = await deps.probeYoutubeTracks(input.url);
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to probe YouTube subtitle tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
reportPrimarySubtitleFailure();
|
||||
releasePlaybackGate(deps);
|
||||
restoreOverlayInputFocus(deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
|
||||
const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId);
|
||||
const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId);
|
||||
if (!primaryTrack) {
|
||||
reportPrimarySubtitleFailure();
|
||||
releasePlaybackGate(deps);
|
||||
restoreOverlayInputFocus(deps);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.showMpvOsd('Getting subtitles...');
|
||||
const loaded = await loadTracksIntoMpv({
|
||||
url: input.url,
|
||||
mode: input.mode,
|
||||
outputDir,
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
secondaryFailureLabel:
|
||||
input.mode === 'generate'
|
||||
? 'Failed to generate secondary YouTube subtitle track'
|
||||
: 'Failed to download secondary YouTube subtitle track',
|
||||
tokenizationWarmupPromise,
|
||||
showDownloadProgress: false,
|
||||
});
|
||||
if (!loaded) {
|
||||
reportPrimarySubtitleFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
deps.warn(
|
||||
`Failed to ${
|
||||
input.mode === 'generate' ? 'generate' : 'download'
|
||||
} primary YouTube subtitle track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
reportPrimarySubtitleFailure();
|
||||
} finally {
|
||||
releasePlaybackGate(deps);
|
||||
restoreOverlayInputFocus(deps);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runYoutubePlaybackFlow,
|
||||
openManualPicker,
|
||||
resolveActivePicker,
|
||||
cancelActivePicker,
|
||||
hasActiveSession: () => Boolean(activeSession),
|
||||
};
|
||||
}
|
||||
100
src/main/runtime/youtube-picker-open.test.ts
Normal file
100
src/main/runtime/youtube-picker-open.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { openYoutubeTrackPicker } from './youtube-picker-open';
|
||||
import type { YoutubePickerOpenPayload } from '../../types';
|
||||
|
||||
const payload: YoutubePickerOpenPayload = {
|
||||
sessionId: 'yt-1',
|
||||
url: 'https://example.com/watch?v=abc',
|
||||
tracks: [],
|
||||
defaultPrimaryTrackId: null,
|
||||
defaultSecondaryTrackId: null,
|
||||
hasTracks: false,
|
||||
};
|
||||
|
||||
test('youtube picker open prefers dedicated modal window on first attempt', async () => {
|
||||
const sends: Array<{
|
||||
channel: string;
|
||||
payload: YoutubePickerOpenPayload;
|
||||
options: {
|
||||
restoreOnModalClose: 'youtube-track-picker';
|
||||
preferModalWindow: boolean;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
const opened = await openYoutubeTrackPicker(
|
||||
{
|
||||
sendToActiveOverlayWindow: (channel, nextPayload, options) => {
|
||||
sends.push({
|
||||
channel,
|
||||
payload: nextPayload as YoutubePickerOpenPayload,
|
||||
options: options as {
|
||||
restoreOnModalClose: 'youtube-track-picker';
|
||||
preferModalWindow: boolean;
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => true,
|
||||
logWarn: () => {},
|
||||
},
|
||||
payload,
|
||||
);
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(sends, [
|
||||
{
|
||||
channel: 'youtube:picker-open',
|
||||
payload,
|
||||
options: {
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube picker open retries on the dedicated modal window after open timeout', async () => {
|
||||
const preferModalWindowValues: boolean[] = [];
|
||||
const warns: string[] = [];
|
||||
let waitCalls = 0;
|
||||
|
||||
const opened = await openYoutubeTrackPicker(
|
||||
{
|
||||
sendToActiveOverlayWindow: (_channel, _payload, options) => {
|
||||
preferModalWindowValues.push(Boolean(options?.preferModalWindow));
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => {
|
||||
waitCalls += 1;
|
||||
return waitCalls === 2;
|
||||
},
|
||||
logWarn: (message) => {
|
||||
warns.push(message);
|
||||
},
|
||||
},
|
||||
payload,
|
||||
);
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(preferModalWindowValues, [true, true]);
|
||||
assert.equal(
|
||||
warns.includes(
|
||||
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube picker open fails when the dedicated modal window cannot be targeted', async () => {
|
||||
const opened = await openYoutubeTrackPicker(
|
||||
{
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
waitForModalOpen: async () => true,
|
||||
logWarn: () => {},
|
||||
},
|
||||
payload,
|
||||
);
|
||||
|
||||
assert.equal(opened, false);
|
||||
});
|
||||
42
src/main/runtime/youtube-picker-open.ts
Normal file
42
src/main/runtime/youtube-picker-open.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { YoutubePickerOpenPayload } from '../../types';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
|
||||
const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker';
|
||||
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openYoutubeTrackPicker(
|
||||
deps: {
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
},
|
||||
payload: YoutubePickerOpenPayload,
|
||||
): Promise<boolean> {
|
||||
const sendPickerOpen = (): boolean =>
|
||||
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
|
||||
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
|
||||
preferModalWindow: true,
|
||||
});
|
||||
|
||||
if (!sendPickerOpen()) {
|
||||
return false;
|
||||
}
|
||||
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.logWarn(
|
||||
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
);
|
||||
if (!sendPickerOpen()) {
|
||||
return false;
|
||||
}
|
||||
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
|
||||
}
|
||||
24
src/main/runtime/youtube-playback.test.ts
Normal file
24
src/main/runtime/youtube-playback.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
|
||||
|
||||
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
|
||||
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
|
||||
assert.equal(isYoutubeMediaPath('https://m.youtube.com/watch?v=abc123'), true);
|
||||
assert.equal(isYoutubeMediaPath('https://youtu.be/abc123'), true);
|
||||
assert.equal(isYoutubeMediaPath('https://www.youtube-nocookie.com/embed/abc123'), true);
|
||||
});
|
||||
|
||||
test('isYoutubeMediaPath ignores local files and non-youtube urls', () => {
|
||||
assert.equal(isYoutubeMediaPath('/tmp/video.mkv'), false);
|
||||
assert.equal(isYoutubeMediaPath('https://example.com/watch?v=abc123'), false);
|
||||
assert.equal(isYoutubeMediaPath('https://notyoutube.com/watch?v=abc123'), false);
|
||||
assert.equal(isYoutubeMediaPath(' '), false);
|
||||
assert.equal(isYoutubeMediaPath(null), false);
|
||||
});
|
||||
|
||||
test('isYoutubePlaybackActive checks both current media and mpv video paths', () => {
|
||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', 'https://youtu.be/abc123'), true);
|
||||
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
|
||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
|
||||
});
|
||||
39
src/main/runtime/youtube-playback.ts
Normal file
39
src/main/runtime/youtube-playback.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
function trimToNull(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
|
||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||
}
|
||||
|
||||
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||
const normalized = trimToNull(mediaPath);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return (
|
||||
matchesYoutubeHost(host, 'youtu.be') ||
|
||||
matchesYoutubeHost(host, 'youtube.com') ||
|
||||
matchesYoutubeHost(host, 'youtube-nocookie.com')
|
||||
);
|
||||
}
|
||||
|
||||
export function isYoutubePlaybackActive(
|
||||
currentMediaPath: string | null | undefined,
|
||||
currentVideoPath: string | null | undefined,
|
||||
): boolean {
|
||||
return isYoutubeMediaPath(currentMediaPath) || isYoutubeMediaPath(currentVideoPath);
|
||||
}
|
||||
197
src/main/runtime/youtube-primary-subtitle-notification.test.ts
Normal file
197
src/main/runtime/youtube-primary-subtitle-notification.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createYoutubePrimarySubtitleNotificationRuntime,
|
||||
type YoutubePrimarySubtitleNotificationTimer,
|
||||
} from './youtube-primary-subtitle-notification';
|
||||
|
||||
function createTimerHarness() {
|
||||
let nextId = 1;
|
||||
const timers = new Map<number, () => void>();
|
||||
return {
|
||||
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
|
||||
const id = nextId++;
|
||||
timers.set(id, fn);
|
||||
return { id };
|
||||
},
|
||||
clear: (timer: YoutubePrimarySubtitleNotificationTimer | null) => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
if (typeof timer === 'object' && 'id' in timer) {
|
||||
timers.delete(timer.id);
|
||||
}
|
||||
},
|
||||
runAll: () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const fn of pending) {
|
||||
fn();
|
||||
}
|
||||
},
|
||||
size: () => timers.size,
|
||||
};
|
||||
}
|
||||
|
||||
test('notifier reports missing preferred primary subtitle once for youtube media', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 2, lang: 'en', title: 'English', external: true },
|
||||
]);
|
||||
|
||||
assert.equal(timers.size(), 1);
|
||||
timers.runAll();
|
||||
timers.runAll();
|
||||
|
||||
assert.deepEqual(notifications, [
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifier suppresses failure when preferred primary subtitle is selected', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: true },
|
||||
]);
|
||||
runtime.handleSubtitleTrackChange(5);
|
||||
timers.runAll();
|
||||
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier suppresses failure when selected track is marked active before sid arrives', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackChange(null);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: false, selected: true },
|
||||
]);
|
||||
timers.runAll();
|
||||
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier suppresses failure when any external subtitle track is selected', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja', 'jpn'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 5, lang: '', title: 'auto-ja-orig.ja-orig.vtt', external: true },
|
||||
]);
|
||||
runtime.handleSubtitleTrackChange(5);
|
||||
timers.runAll();
|
||||
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier resets when media changes away from youtube', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleMediaPathChange('/tmp/video.mkv');
|
||||
timers.runAll();
|
||||
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier ignores empty and null media paths and waits for track list before reporting', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange(null);
|
||||
runtime.handleMediaPathChange('');
|
||||
assert.equal(timers.size(), 0);
|
||||
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
runtime.handleSubtitleTrackChange(7);
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 7, lang: 'ja', title: 'Japanese', external: true },
|
||||
]);
|
||||
timers.runAll();
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test('notifier suppresses timer while app-owned youtube flow is still settling', () => {
|
||||
const notifications: string[] = [];
|
||||
const timers = createTimerHarness();
|
||||
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => ['ja'],
|
||||
notifyFailure: (message) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
schedule: (fn) => timers.schedule(fn),
|
||||
clearSchedule: (timer) => timers.clear(timer),
|
||||
});
|
||||
|
||||
runtime.setAppOwnedFlowInFlight(true);
|
||||
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
|
||||
|
||||
assert.equal(timers.size(), 0);
|
||||
|
||||
runtime.setAppOwnedFlowInFlight(false);
|
||||
assert.equal(timers.size(), 1);
|
||||
|
||||
timers.runAll();
|
||||
assert.deepEqual(notifications, [
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
]);
|
||||
});
|
||||
185
src/main/runtime/youtube-primary-subtitle-notification.ts
Normal file
185
src/main/runtime/youtube-primary-subtitle-notification.ts
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user