diff --git a/src/anki-connect.ts b/src/anki-connect.ts new file mode 100644 index 0000000..f4b5819 --- /dev/null +++ b/src/anki-connect.ts @@ -0,0 +1,234 @@ +/* + * SubMiner - Subtitle mining overlay for mpv + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import axios, { AxiosInstance } from 'axios'; +import http from 'http'; +import https from 'https'; +import { createLogger } from './logger'; + +const log = createLogger('anki'); + +interface AnkiConnectRequest { + action: string; + version: number; + params: Record; +} + +interface AnkiConnectResponse { + result: unknown; + error: string | null; +} + +export class AnkiConnectClient { + private client: AxiosInstance; + private backoffMs = 200; + private maxBackoffMs = 5000; + private consecutiveFailures = 0; + private maxConsecutiveFailures = 5; + + constructor(url: string) { + const httpAgent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxSockets: 5, + maxFreeSockets: 2, + timeout: 10000, + }); + + const httpsAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxSockets: 5, + maxFreeSockets: 2, + timeout: 10000, + }); + + this.client = axios.create({ + baseURL: url, + timeout: 10000, + httpAgent, + httpsAgent, + }); + } + + private async sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private isRetryableError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + + const code = (error as Record).code; + const message = + typeof (error as Record).message === 'string' + ? ((error as Record).message as string).toLowerCase() + : ''; + + return ( + code === 'ECONNRESET' || + code === 'ETIMEDOUT' || + code === 'ENOTFOUND' || + code === 'ECONNREFUSED' || + code === 'EPIPE' || + message.includes('socket hang up') || + message.includes('network error') || + message.includes('timeout') + ); + } + + async invoke( + action: string, + params: Record = {}, + options: { timeout?: number; maxRetries?: number } = {}, + ): Promise { + const maxRetries = options.maxRetries ?? 3; + let lastError: Error | null = null; + + const isMediaUpload = action === 'storeMediaFile'; + const requestTimeout = options.timeout || (isMediaUpload ? 30000 : 10000); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = Math.min(this.backoffMs * Math.pow(2, attempt - 1), this.maxBackoffMs); + log.info(`AnkiConnect retry ${attempt}/${maxRetries} after ${delay}ms delay`); + await this.sleep(delay); + } + + const response = await this.client.post( + '', + { + action, + version: 6, + params, + } as AnkiConnectRequest, + { + timeout: requestTimeout, + }, + ); + + this.consecutiveFailures = 0; + this.backoffMs = 200; + + if (response.data.error) { + throw new Error(response.data.error); + } + + return response.data.result; + } catch (error) { + lastError = error as Error; + this.consecutiveFailures++; + + if (!this.isRetryableError(error) || attempt === maxRetries) { + if (this.consecutiveFailures < this.maxConsecutiveFailures) { + log.error( + `AnkiConnect error (attempt ${this.consecutiveFailures}/${this.maxConsecutiveFailures}):`, + lastError.message, + ); + } else if (this.consecutiveFailures === this.maxConsecutiveFailures) { + log.error('AnkiConnect: Too many consecutive failures, suppressing further error logs'); + } + throw lastError; + } + } + } + + throw lastError || new Error('Unknown error'); + } + + async findNotes(query: string, options?: { maxRetries?: number }): Promise { + const result = await this.invoke('findNotes', { query }, options); + return (result as number[]) || []; + } + + async notesInfo(noteIds: number[]): Promise[]> { + const result = await this.invoke('notesInfo', { notes: noteIds }); + return (result as Record[]) || []; + } + + async updateNoteFields(noteId: number, fields: Record): Promise { + await this.invoke('updateNoteFields', { + note: { + id: noteId, + fields, + }, + }); + } + + async storeMediaFile(filename: string, data: Buffer): Promise { + const base64Data = data.toString('base64'); + const sizeKB = Math.round(base64Data.length / 1024); + log.info(`Uploading media file: ${filename} (${sizeKB}KB)`); + + await this.invoke( + 'storeMediaFile', + { + filename, + data: base64Data, + }, + { timeout: 30000 }, + ); + } + + async addNote( + deckName: string, + modelName: string, + fields: Record, + tags: string[] = [], + ): Promise { + const note: { + deckName: string; + modelName: string; + fields: Record; + tags?: string[]; + } = { deckName, modelName, fields }; + if (tags.length > 0) { + note.tags = tags; + } + + const result = await this.invoke('addNote', { + note, + }); + return result as number; + } + + async addTags(noteIds: number[], tags: string[]): Promise { + if (noteIds.length === 0 || tags.length === 0) { + return; + } + + await this.invoke('addTags', { + notes: noteIds, + tags: tags.join(' '), + }); + } + + async deleteNotes(noteIds: number[]): Promise { + await this.invoke('deleteNotes', { notes: noteIds }); + } + + async retrieveMediaFile(filename: string): Promise { + const result = await this.invoke('retrieveMediaFile', { filename }); + return (result as string) || ''; + } + + resetBackoff(): void { + this.backoffMs = 200; + this.consecutiveFailures = 0; + } +} diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts new file mode 100644 index 0000000..90e0c3d --- /dev/null +++ b/src/anki-integration.test.ts @@ -0,0 +1,268 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { AnkiIntegration } from './anki-integration'; +import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; +import { AnkiConnectConfig } from './types'; + +interface IntegrationTestContext { + integration: AnkiIntegration; + calls: { + findNotes: number; + notesInfo: number; + }; + stateDir: string; +} + +function createIntegrationTestContext( + options: { + highlightEnabled?: boolean; + onFindNotes?: () => Promise; + onNotesInfo?: () => Promise; + stateDirPrefix?: string; + } = {}, +): IntegrationTestContext { + const calls = { + findNotes: 0, + notesInfo: 0, + }; + + const stateDir = fs.mkdtempSync( + path.join(os.tmpdir(), options.stateDirPrefix ?? 'subminer-anki-integration-'), + ); + const knownWordCacheStatePath = path.join(stateDir, 'known-words-cache.json'); + + const client = { + findNotes: async () => { + calls.findNotes += 1; + if (options.onFindNotes) { + return options.onFindNotes(); + } + return [] as number[]; + }, + notesInfo: async () => { + calls.notesInfo += 1; + if (options.onNotesInfo) { + return options.onNotesInfo(); + } + return [] as unknown[]; + }, + } as { + findNotes: () => Promise; + notesInfo: () => Promise; + }; + + const integration = new AnkiIntegration( + { + nPlusOne: { + highlightEnabled: options.highlightEnabled ?? true, + }, + }, + {} as never, + {} as never, + undefined, + undefined, + undefined, + knownWordCacheStatePath, + ); + + const integrationWithClient = integration as unknown as { + client: { + findNotes: () => Promise; + notesInfo: () => Promise; + }; + }; + integrationWithClient.client = client; + + const privateState = integration as unknown as { + knownWordsScope: string; + knownWordsLastRefreshedAtMs: number; + }; + privateState.knownWordsScope = 'is:note'; + privateState.knownWordsLastRefreshedAtMs = Date.now(); + + return { + integration, + calls, + stateDir, + }; +} + +function cleanupIntegrationTestContext(ctx: IntegrationTestContext): void { + fs.rmSync(ctx.stateDir, { recursive: true, force: true }); +} + +function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { + const exact = availableFieldNames.find((name) => name === preferredName); + if (exact) return exact; + + const lower = preferredName.toLowerCase(); + return availableFieldNames.find((name) => name.toLowerCase() === lower) ?? null; +} + +function createFieldGroupingMergeCollaborator(options?: { + config?: Partial; + currentSubtitleText?: string; + generatedMedia?: { + audioField?: string; + audioValue?: string; + imageField?: string; + imageValue?: string; + miscInfoValue?: string; + }; +}): FieldGroupingMergeCollaborator { + const config = { + fields: { + sentence: 'Sentence', + audio: 'ExpressionAudio', + image: 'Picture', + ...(options?.config?.fields ?? {}), + }, + ...(options?.config ?? {}), + } as AnkiConnectConfig; + + return new FieldGroupingMergeCollaborator({ + getConfig: () => config, + getEffectiveSentenceCardConfig: () => ({ + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + }), + getCurrentSubtitleText: () => options?.currentSubtitleText, + resolveFieldName, + resolveNoteFieldName: (noteInfo, preferredName) => { + if (!preferredName) return null; + return resolveFieldName(Object.keys(noteInfo.fields), preferredName); + }, + extractFields: (fields) => { + const result: Record = {}; + for (const [key, value] of Object.entries(fields)) { + result[key.toLowerCase()] = value.value || ''; + } + return result; + }, + processSentence: (mpvSentence) => `${mpvSentence}::processed`, + generateMediaForMerge: async () => options?.generatedMedia ?? {}, + warnFieldParseOnce: () => undefined, + }); +} + +test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () => { + const ctx = createIntegrationTestContext(); + + try { + await ctx.integration.refreshKnownWordCache(); + + assert.equal(ctx.calls.findNotes, 1); + assert.equal(ctx.calls.notesInfo, 0); + } finally { + cleanupIntegrationTestContext(ctx); + } +}); + +test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => { + const ctx = createIntegrationTestContext({ + highlightEnabled: false, + stateDirPrefix: 'subminer-anki-integration-disabled-', + }); + + try { + await ctx.integration.refreshKnownWordCache(); + + assert.equal(ctx.calls.findNotes, 0); + assert.equal(ctx.calls.notesInfo, 0); + } finally { + cleanupIntegrationTestContext(ctx); + } +}); + +test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => { + let releaseFindNotes: (() => void) | undefined; + const findNotesPromise = new Promise((resolve) => { + releaseFindNotes = resolve; + }); + + const ctx = createIntegrationTestContext({ + onFindNotes: async () => { + await findNotesPromise; + return [] as number[]; + }, + stateDirPrefix: 'subminer-anki-integration-concurrent-', + }); + + const first = ctx.integration.refreshKnownWordCache(); + await Promise.resolve(); + const second = ctx.integration.refreshKnownWordCache(); + + if (releaseFindNotes !== undefined) { + releaseFindNotes(); + } + + await Promise.all([first, second]); + + try { + assert.equal(ctx.calls.findNotes, 1); + assert.equal(ctx.calls.notesInfo, 0); + } finally { + cleanupIntegrationTestContext(ctx); + } +}); + +test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { + const collaborator = createFieldGroupingMergeCollaborator(); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 101, + 202, + { + noteId: 101, + fields: { + SentenceAudio: { value: '[sound:keep.mp3]' }, + ExpressionAudio: { value: '[sound:stale.mp3]' }, + }, + }, + { + noteId: 202, + fields: { + SentenceAudio: { value: '[sound:new.mp3]' }, + }, + }, + false, + ); + + assert.equal( + merged.SentenceAudio, + '[sound:keep.mp3][sound:new.mp3]', + ); + assert.equal(merged.ExpressionAudio, merged.SentenceAudio); +}); + +test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => { + const collaborator = createFieldGroupingMergeCollaborator({ + generatedMedia: { + audioField: 'SentenceAudio', + audioValue: '[sound:generated.mp3]', + }, + }); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 11, + 22, + { + noteId: 11, + fields: { + SentenceAudio: { value: '' }, + }, + }, + { + noteId: 22, + fields: { + SentenceAudio: { value: '' }, + }, + }, + true, + ); + + assert.equal(merged.SentenceAudio, '[sound:generated.mp3]'); +}); diff --git a/src/anki-integration.ts b/src/anki-integration.ts new file mode 100644 index 0000000..1146399 --- /dev/null +++ b/src/anki-integration.ts @@ -0,0 +1,1120 @@ +/* + * SubMiner - Subtitle mining overlay for mpv + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { AnkiConnectClient } from './anki-connect'; +import { SubtitleTimingTracker } from './subtitle-timing-tracker'; +import { MediaGenerator } from './media-generator'; +import path from 'path'; +import { + AnkiConnectConfig, + KikuDuplicateCardInfo, + KikuFieldGroupingChoice, + KikuMergePreviewResponse, + MpvClient, + NotificationOptions, + NPlusOneMatchMode, +} from './types'; +import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; +import { createLogger } from './logger'; +import { + createUiFeedbackState, + beginUpdateProgress, + endUpdateProgress, + showStatusNotification, + withUpdateProgress, + UiFeedbackState, +} from './anki-integration/ui-feedback'; +import { KnownWordCacheManager } from './anki-integration/known-word-cache'; +import { PollingRunner } from './anki-integration/polling'; +import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate'; +import { CardCreationService } from './anki-integration/card-creation'; +import { FieldGroupingService } from './anki-integration/field-grouping'; +import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; +import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow'; +import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow'; + +const log = createLogger('anki').child('integration'); + +interface NoteInfo { + noteId: number; + fields: Record; +} + +type CardKind = 'sentence' | 'audio'; + +export class AnkiIntegration { + private client: AnkiConnectClient; + private mediaGenerator: MediaGenerator; + private timingTracker: SubtitleTimingTracker; + private config: AnkiConnectConfig; + private pollingRunner!: PollingRunner; + private previousNoteIds = new Set(); + private mpvClient: MpvClient; + private osdCallback: ((text: string) => void) | null = null; + private notificationCallback: ((title: string, options: NotificationOptions) => void) | null = + null; + private updateInProgress = false; + private uiFeedbackState: UiFeedbackState = createUiFeedbackState(); + private parseWarningKeys = new Set(); + private fieldGroupingCallback: + | ((data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }) => Promise) + | null = null; + private knownWordCache: KnownWordCacheManager; + private cardCreationService: CardCreationService; + private fieldGroupingMergeCollaborator: FieldGroupingMergeCollaborator; + private fieldGroupingService: FieldGroupingService; + private noteUpdateWorkflow: NoteUpdateWorkflow; + private fieldGroupingWorkflow: FieldGroupingWorkflow; + + constructor( + config: AnkiConnectConfig, + timingTracker: SubtitleTimingTracker, + mpvClient: MpvClient, + osdCallback?: (text: string) => void, + notificationCallback?: (title: string, options: NotificationOptions) => void, + fieldGroupingCallback?: (data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }) => Promise, + knownWordCacheStatePath?: string, + ) { + this.config = this.normalizeConfig(config); + this.client = new AnkiConnectClient(this.config.url!); + this.mediaGenerator = new MediaGenerator(); + this.timingTracker = timingTracker; + this.mpvClient = mpvClient; + this.osdCallback = osdCallback || null; + this.notificationCallback = notificationCallback || null; + this.fieldGroupingCallback = fieldGroupingCallback || null; + this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); + this.pollingRunner = this.createPollingRunner(); + this.cardCreationService = this.createCardCreationService(); + this.fieldGroupingMergeCollaborator = this.createFieldGroupingMergeCollaborator(); + this.fieldGroupingService = this.createFieldGroupingService(); + this.noteUpdateWorkflow = this.createNoteUpdateWorkflow(); + this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow(); + } + + private createFieldGroupingMergeCollaborator(): FieldGroupingMergeCollaborator { + return new FieldGroupingMergeCollaborator({ + getConfig: () => this.config, + getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), + getCurrentSubtitleText: () => this.mpvClient.currentSubText, + resolveFieldName: (availableFieldNames, preferredName) => + this.resolveFieldName(availableFieldNames, preferredName), + resolveNoteFieldName: (noteInfo, preferredName) => + this.resolveNoteFieldName(noteInfo, preferredName), + extractFields: (fields) => this.extractFields(fields), + processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), + generateMediaForMerge: () => this.generateMediaForMerge(), + warnFieldParseOnce: (fieldName, reason, detail) => + this.warnFieldParseOnce(fieldName, reason, detail), + }); + } + + private normalizeConfig(config: AnkiConnectConfig): AnkiConnectConfig { + return { + ...DEFAULT_ANKI_CONNECT_CONFIG, + ...config, + fields: { + ...DEFAULT_ANKI_CONNECT_CONFIG.fields, + ...(config.fields ?? {}), + }, + ai: { + ...DEFAULT_ANKI_CONNECT_CONFIG.ai, + ...(config.openRouter ?? {}), + ...(config.ai ?? {}), + }, + media: { + ...DEFAULT_ANKI_CONNECT_CONFIG.media, + ...(config.media ?? {}), + }, + behavior: { + ...DEFAULT_ANKI_CONNECT_CONFIG.behavior, + ...(config.behavior ?? {}), + }, + metadata: { + ...DEFAULT_ANKI_CONNECT_CONFIG.metadata, + ...(config.metadata ?? {}), + }, + isLapis: { + ...DEFAULT_ANKI_CONNECT_CONFIG.isLapis, + ...(config.isLapis ?? {}), + }, + isKiku: { + ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, + ...(config.isKiku ?? {}), + }, + } as AnkiConnectConfig; + } + + private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager { + return new KnownWordCacheManager({ + client: { + findNotes: async (query, options) => + (await this.client.findNotes(query, options)) as unknown, + notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, + }, + getConfig: () => this.config, + knownWordCacheStatePath, + showStatusNotification: (message: string) => this.showStatusNotification(message), + }); + } + + private createPollingRunner(): PollingRunner { + return new PollingRunner({ + getDeck: () => this.config.deck, + getPollingRate: () => this.config.pollingRate || DEFAULT_ANKI_CONNECT_CONFIG.pollingRate, + findNotes: async (query, options) => + (await this.client.findNotes(query, options)) as number[], + shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, + processNewCard: (noteId) => this.processNewCard(noteId), + isUpdateInProgress: () => this.updateInProgress, + setUpdateInProgress: (value) => { + this.updateInProgress = value; + }, + getTrackedNoteIds: () => this.previousNoteIds, + setTrackedNoteIds: (noteIds) => { + this.previousNoteIds = noteIds; + }, + showStatusNotification: (message: string) => this.showStatusNotification(message), + logDebug: (...args) => log.debug(args[0] as string, ...args.slice(1)), + logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), + logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), + }); + } + + private createCardCreationService(): CardCreationService { + return new CardCreationService({ + getConfig: () => this.config, + getTimingTracker: () => this.timingTracker, + getMpvClient: () => this.mpvClient, + getDeck: () => this.config.deck, + client: { + addNote: (deck, modelName, fields, tags) => + this.client.addNote(deck, modelName, fields, tags), + addTags: (noteIds, tags) => this.client.addTags(noteIds, tags), + notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, + updateNoteFields: (noteId, fields) => + this.client.updateNoteFields(noteId, fields) as Promise, + storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data), + findNotes: async (query, options) => + (await this.client.findNotes(query, options)) as number[], + }, + mediaGenerator: { + generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) => + this.mediaGenerator.generateAudio( + videoPath, + startTime, + endTime, + audioPadding, + audioStreamIndex, + ), + generateScreenshot: (videoPath, timestamp, options) => + this.mediaGenerator.generateScreenshot(videoPath, timestamp, options), + generateAnimatedImage: (videoPath, startTime, endTime, audioPadding, options) => + this.mediaGenerator.generateAnimatedImage( + videoPath, + startTime, + endTime, + audioPadding, + options, + ), + }, + showOsdNotification: (text: string) => this.showOsdNotification(text), + showStatusNotification: (message: string) => this.showStatusNotification(message), + showNotification: (noteId, label, errorSuffix) => + this.showNotification(noteId, label, errorSuffix), + beginUpdateProgress: (initialMessage: string) => this.beginUpdateProgress(initialMessage), + endUpdateProgress: () => this.endUpdateProgress(), + withUpdateProgress: (initialMessage: string, action: () => Promise) => + this.withUpdateProgress(initialMessage, action), + resolveConfiguredFieldName: (noteInfo, ...preferredNames) => + this.resolveConfiguredFieldName(noteInfo, ...preferredNames), + resolveNoteFieldName: (noteInfo, preferredName) => + this.resolveNoteFieldName(noteInfo, preferredName), + extractFields: (fields) => this.extractFields(fields), + processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), + setCardTypeFields: (updatedFields, availableFieldNames, cardKind) => + this.setCardTypeFields(updatedFields, availableFieldNames, cardKind), + mergeFieldValue: (existing, newValue, overwrite) => + this.mergeFieldValue(existing, newValue, overwrite), + formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) => + this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds), + getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), + getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(), + appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo), + isUpdateInProgress: () => this.updateInProgress, + setUpdateInProgress: (value) => { + this.updateInProgress = value; + }, + trackLastAddedNoteId: (noteId) => { + this.previousNoteIds.add(noteId); + }, + }); + } + + private createFieldGroupingService(): FieldGroupingService { + return new FieldGroupingService({ + getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), + isUpdateInProgress: () => this.updateInProgress, + getDeck: () => this.config.deck, + withUpdateProgress: (initialMessage: string, action: () => Promise) => + this.withUpdateProgress(initialMessage, action), + showOsdNotification: (text: string) => this.showOsdNotification(text), + findNotes: async (query, options) => + (await this.client.findNotes(query, options)) as number[], + notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[], + extractFields: (fields) => this.extractFields(fields), + findDuplicateNote: (expression, noteId, noteInfo) => + this.findDuplicateNote(expression, noteId, noteInfo), + hasAllConfiguredFields: (noteInfo, configuredFieldNames) => + this.hasAllConfiguredFields(noteInfo, configuredFieldNames), + processNewCard: (noteId, options) => this.processNewCard(noteId, options), + getSentenceCardImageFieldName: () => this.config.fields?.image, + resolveFieldName: (availableFieldNames, preferredName) => + this.resolveFieldName(availableFieldNames, preferredName), + computeFieldGroupingMergedFields: ( + keepNoteId, + deleteNoteId, + keepNoteInfo, + deleteNoteInfo, + includeGeneratedMedia, + ) => + this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields( + keepNoteId, + deleteNoteId, + keepNoteInfo, + deleteNoteInfo, + includeGeneratedMedia, + ), + getNoteFieldMap: (noteInfo) => this.fieldGroupingMergeCollaborator.getNoteFieldMap(noteInfo), + handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) => + this.handleFieldGroupingAuto(originalNoteId, newNoteId, newNoteInfo, expression), + handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) => + this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression), + }); + } + + private createNoteUpdateWorkflow(): NoteUpdateWorkflow { + return new NoteUpdateWorkflow({ + client: { + notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, + updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields), + storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data), + }, + getConfig: () => this.config, + getCurrentSubtitleText: () => this.mpvClient.currentSubText, + getCurrentSubtitleStart: () => this.mpvClient.currentSubStart, + getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), + appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo), + extractFields: (fields) => this.extractFields(fields), + findDuplicateNote: (expression, excludeNoteId, noteInfo) => + this.findDuplicateNote(expression, excludeNoteId, noteInfo), + handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) => + this.handleFieldGroupingAuto(originalNoteId, newNoteId, newNoteInfo, expression), + handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) => + this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression), + processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), + resolveConfiguredFieldName: (noteInfo, ...preferredNames) => + this.resolveConfiguredFieldName(noteInfo, ...preferredNames), + getResolvedSentenceAudioFieldName: (noteInfo) => + this.getResolvedSentenceAudioFieldName(noteInfo), + mergeFieldValue: (existing, newValue, overwrite) => + this.mergeFieldValue(existing, newValue, overwrite), + generateAudioFilename: () => this.generateAudioFilename(), + generateAudio: () => this.generateAudio(), + generateImageFilename: () => this.generateImageFilename(), + generateImage: () => this.generateImage(), + formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) => + this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds), + addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), + showNotification: (noteId, label) => this.showNotification(noteId, label), + showOsdNotification: (message) => this.showOsdNotification(message), + beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage), + endUpdateProgress: () => this.endUpdateProgress(), + logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), + logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), + logError: (...args) => log.error(args[0] as string, ...args.slice(1)), + }); + } + + private createFieldGroupingWorkflow(): FieldGroupingWorkflow { + return new FieldGroupingWorkflow({ + client: { + notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, + updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields), + deleteNotes: (noteIds) => this.client.deleteNotes(noteIds), + }, + getConfig: () => this.config, + getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), + getCurrentSubtitleText: () => this.mpvClient.currentSubText, + getFieldGroupingCallback: () => this.fieldGroupingCallback, + computeFieldGroupingMergedFields: ( + keepNoteId, + deleteNoteId, + keepNoteInfo, + deleteNoteInfo, + includeGeneratedMedia, + ) => + this.fieldGroupingMergeCollaborator.computeFieldGroupingMergedFields( + keepNoteId, + deleteNoteId, + keepNoteInfo, + deleteNoteInfo, + includeGeneratedMedia, + ), + extractFields: (fields) => this.extractFields(fields), + hasFieldValue: (noteInfo, preferredFieldName) => + this.hasFieldValue(noteInfo, preferredFieldName), + addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), + removeTrackedNoteId: (noteId) => { + this.previousNoteIds.delete(noteId); + }, + showStatusNotification: (message) => this.showStatusNotification(message), + showNotification: (noteId, label) => this.showNotification(noteId, label), + showOsdNotification: (message) => this.showOsdNotification(message), + logError: (...args) => log.error(args[0] as string, ...args.slice(1)), + logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), + truncateSentence: (sentence) => this.truncateSentence(sentence), + }); + } + + isKnownWord(text: string): boolean { + return this.knownWordCache.isKnownWord(text); + } + + getKnownWordMatchMode(): NPlusOneMatchMode { + return this.config.nPlusOne?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.matchMode; + } + + private isKnownWordCacheEnabled(): boolean { + return this.config.nPlusOne?.highlightEnabled === true; + } + + private startKnownWordCacheLifecycle(): void { + this.knownWordCache.startLifecycle(); + } + + private stopKnownWordCacheLifecycle(): void { + this.knownWordCache.stopLifecycle(); + } + + private getConfiguredAnkiTags(): string[] { + if (!Array.isArray(this.config.tags)) { + return []; + } + return [...new Set(this.config.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))]; + } + + private async addConfiguredTagsToNote(noteId: number): Promise { + const tags = this.getConfiguredAnkiTags(); + if (tags.length === 0) { + return; + } + try { + await this.client.addTags([noteId], tags); + } catch (error) { + log.warn('Failed to add tags to card:', (error as Error).message); + } + } + + async refreshKnownWordCache(): Promise { + return this.knownWordCache.refresh(true); + } + + private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void { + if (!this.isKnownWordCacheEnabled()) { + return; + } + + this.knownWordCache.appendFromNoteInfo({ + noteId: noteInfo.noteId, + fields: noteInfo.fields, + }); + } + + private getLapisConfig(): { + enabled: boolean; + sentenceCardModel?: string; + } { + const lapis = this.config.isLapis; + return { + enabled: lapis?.enabled === true, + sentenceCardModel: lapis?.sentenceCardModel, + }; + } + + private getKikuConfig(): { + enabled: boolean; + fieldGrouping?: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto?: boolean; + } { + const kiku = this.config.isKiku; + return { + enabled: kiku?.enabled === true, + fieldGrouping: kiku?.fieldGrouping, + deleteDuplicateInAuto: kiku?.deleteDuplicateInAuto, + }; + } + + private getEffectiveSentenceCardConfig(): { + model?: string; + sentenceField: string; + audioField: string; + lapisEnabled: boolean; + kikuEnabled: boolean; + kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; + kikuDeleteDuplicateInAuto: boolean; + } { + const lapis = this.getLapisConfig(); + const kiku = this.getKikuConfig(); + + return { + model: lapis.sentenceCardModel, + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + lapisEnabled: lapis.enabled, + kikuEnabled: kiku.enabled, + kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled', + kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false, + }; + } + + start(): void { + if (this.pollingRunner.isRunning) { + this.stop(); + } + + log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate); + this.startKnownWordCacheLifecycle(); + this.pollingRunner.start(); + } + + stop(): void { + this.pollingRunner.stop(); + this.stopKnownWordCacheLifecycle(); + log.info('Stopped AnkiConnect integration'); + } + + private async processNewCard( + noteId: number, + options?: { skipKikuFieldGrouping?: boolean }, + ): Promise { + await this.noteUpdateWorkflow.execute(noteId, options); + } + + private extractFields(fields: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(fields)) { + result[key.toLowerCase()] = value.value || ''; + } + return result; + } + + private processSentence(mpvSentence: string, noteFields: Record): string { + if (this.config.behavior?.highlightWord === false) { + return mpvSentence; + } + + const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence'; + const existingSentence = noteFields[sentenceFieldName] || ''; + + const highlightMatch = existingSentence.match(/(.*?)<\/b>/); + if (!highlightMatch || !highlightMatch[1]) { + return mpvSentence; + } + + const highlightedText = highlightMatch[1]; + const index = mpvSentence.indexOf(highlightedText); + + if (index === -1) { + return mpvSentence; + } + + const prefix = mpvSentence.substring(0, index); + const suffix = mpvSentence.substring(index + highlightedText.length); + return `${prefix}${highlightedText}${suffix}`; + } + + private async generateAudio(): Promise { + const mpvClient = this.mpvClient; + if (!mpvClient || !mpvClient.currentVideoPath) { + return null; + } + + const videoPath = mpvClient.currentVideoPath; + let startTime = mpvClient.currentSubStart; + let endTime = mpvClient.currentSubEnd; + + if (startTime === undefined || endTime === undefined) { + const currentTime = mpvClient.currentTimePos || 0; + const fallback = this.getFallbackDurationSeconds() / 2; + startTime = currentTime - fallback; + endTime = currentTime + fallback; + } + + return this.mediaGenerator.generateAudio( + videoPath, + startTime, + endTime, + this.config.media?.audioPadding, + this.mpvClient.currentAudioStreamIndex, + ); + } + + private async generateImage(): Promise { + if (!this.mpvClient || !this.mpvClient.currentVideoPath) { + return null; + } + + const videoPath = this.mpvClient.currentVideoPath; + const timestamp = this.mpvClient.currentTimePos || 0; + + if (this.config.media?.imageType === 'avif') { + let startTime = this.mpvClient.currentSubStart; + let endTime = this.mpvClient.currentSubEnd; + + if (startTime === undefined || endTime === undefined) { + const fallback = this.getFallbackDurationSeconds() / 2; + startTime = timestamp - fallback; + endTime = timestamp + fallback; + } + + return this.mediaGenerator.generateAnimatedImage( + videoPath, + startTime, + endTime, + this.config.media?.audioPadding, + { + fps: this.config.media?.animatedFps, + maxWidth: this.config.media?.animatedMaxWidth, + maxHeight: this.config.media?.animatedMaxHeight, + crf: this.config.media?.animatedCrf, + }, + ); + } else { + return this.mediaGenerator.generateScreenshot(videoPath, timestamp, { + format: this.config.media?.imageFormat as 'jpg' | 'png' | 'webp', + quality: this.config.media?.imageQuality, + maxWidth: this.config.media?.imageMaxWidth, + maxHeight: this.config.media?.imageMaxHeight, + }); + } + } + + private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string { + if (!this.config.metadata?.pattern) { + return ''; + } + + const currentVideoPath = this.mpvClient.currentVideoPath || ''; + const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : ''; + const filenameWithExt = videoFilename || fallbackFilename; + const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); + + const currentTimePos = + typeof startTimeSeconds === 'number' && Number.isFinite(startTimeSeconds) + ? startTimeSeconds + : this.mpvClient.currentTimePos; + let totalMilliseconds = 0; + if (Number.isFinite(currentTimePos) && currentTimePos >= 0) { + totalMilliseconds = Math.floor(currentTimePos * 1000); + } else { + const now = new Date(); + totalMilliseconds = + now.getHours() * 3600000 + + now.getMinutes() * 60000 + + now.getSeconds() * 1000 + + now.getMilliseconds(); + } + + const totalSeconds = Math.floor(totalMilliseconds / 1000); + const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0'); + const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0'); + const seconds = String(totalSeconds % 60).padStart(2, '0'); + const milliseconds = String(totalMilliseconds % 1000).padStart(3, '0'); + + let result = this.config.metadata?.pattern + .replace(/%f/g, filenameWithoutExt) + .replace(/%F/g, filenameWithExt) + .replace(/%t/g, `${hours}:${minutes}:${seconds}`) + .replace(/%T/g, `${hours}:${minutes}:${seconds}:${milliseconds}`) + .replace(/
/g, '\n'); + + return result; + } + + private getFallbackDurationSeconds(): number { + const configured = this.config.media?.fallbackDuration; + if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) { + return configured; + } + return DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration; + } + + private generateAudioFilename(): string { + const timestamp = Date.now(); + return `audio_${timestamp}.mp3`; + } + + private generateImageFilename(): string { + const timestamp = Date.now(); + const ext = this.config.media?.imageType === 'avif' ? 'avif' : this.config.media?.imageFormat; + return `image_${timestamp}.${ext}`; + } + + private showStatusNotification(message: string): void { + showStatusNotification(message, { + getNotificationType: () => this.config.behavior?.notificationType, + showOsd: (text: string) => { + this.showOsdNotification(text); + }, + showSystemNotification: (title: string, options: NotificationOptions) => { + if (this.notificationCallback) { + this.notificationCallback(title, options); + } + }, + }); + } + + private beginUpdateProgress(initialMessage: string): void { + beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => { + this.showOsdNotification(text); + }); + } + + private endUpdateProgress(): void { + endUpdateProgress(this.uiFeedbackState, (timer) => { + clearInterval(timer); + }); + } + + private async withUpdateProgress( + initialMessage: string, + action: () => Promise, + ): Promise { + return withUpdateProgress( + this.uiFeedbackState, + { + setUpdateInProgress: (value: boolean) => { + this.updateInProgress = value; + }, + showOsdNotification: (text: string) => { + this.showOsdNotification(text); + }, + }, + initialMessage, + action, + ); + } + + private showOsdNotification(text: string): void { + if (this.osdCallback) { + this.osdCallback(text); + } else if (this.mpvClient && this.mpvClient.send) { + this.mpvClient.send({ + command: ['show-text', text, '3000'], + }); + } + } + + private resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { + const exact = availableFieldNames.find((name) => name === preferredName); + if (exact) return exact; + + const lower = preferredName.toLowerCase(); + const ci = availableFieldNames.find((name) => name.toLowerCase() === lower); + return ci || null; + } + + private resolveNoteFieldName(noteInfo: NoteInfo, preferredName?: string): string | null { + if (!preferredName) return null; + return this.resolveFieldName(Object.keys(noteInfo.fields), preferredName); + } + + private resolveConfiguredFieldName( + noteInfo: NoteInfo, + ...preferredNames: (string | undefined)[] + ): string | null { + for (const preferredName of preferredNames) { + const resolved = this.resolveNoteFieldName(noteInfo, preferredName); + if (resolved) return resolved; + } + return null; + } + + private warnFieldParseOnce(fieldName: string, reason: string, detail?: string): void { + const key = `${fieldName.toLowerCase()}::${reason}`; + if (this.parseWarningKeys.has(key)) return; + this.parseWarningKeys.add(key); + const suffix = detail ? ` (${detail})` : ''; + log.warn(`Field grouping parse warning [${fieldName}] ${reason}${suffix}`); + } + + private setCardTypeFields( + updatedFields: Record, + availableFieldNames: string[], + cardKind: CardKind, + ): void { + const audioFlagNames = ['IsAudioCard']; + + if (cardKind === 'sentence') { + const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard'); + if (sentenceFlag) { + updatedFields[sentenceFlag] = 'x'; + } + + for (const audioFlagName of audioFlagNames) { + const resolved = this.resolveFieldName(availableFieldNames, audioFlagName); + if (resolved && resolved !== sentenceFlag) { + updatedFields[resolved] = ''; + } + } + + const wordAndSentenceFlag = this.resolveFieldName( + availableFieldNames, + 'IsWordAndSentenceCard', + ); + if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) { + updatedFields[wordAndSentenceFlag] = ''; + } + return; + } + + const resolvedAudioFlags = Array.from( + new Set( + audioFlagNames + .map((name) => this.resolveFieldName(availableFieldNames, name)) + .filter((name): name is string => Boolean(name)), + ), + ); + const audioFlagName = resolvedAudioFlags[0] || null; + if (audioFlagName) { + updatedFields[audioFlagName] = 'x'; + } + for (const extraAudioFlag of resolvedAudioFlags.slice(1)) { + updatedFields[extraAudioFlag] = ''; + } + + const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard'); + if (sentenceFlag && sentenceFlag !== audioFlagName) { + updatedFields[sentenceFlag] = ''; + } + + const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard'); + if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) { + updatedFields[wordAndSentenceFlag] = ''; + } + } + + private async showNotification( + noteId: number, + label: string | number, + errorSuffix?: string, + ): Promise { + const message = errorSuffix + ? `Updated card: ${label} (${errorSuffix})` + : `Updated card: ${label}`; + + const type = this.config.behavior?.notificationType || 'osd'; + + if (type === 'osd' || type === 'both') { + this.showOsdNotification(message); + } + + if ((type === 'system' || type === 'both') && this.notificationCallback) { + let notificationIconPath: string | undefined; + + if (this.mpvClient && this.mpvClient.currentVideoPath) { + try { + const timestamp = this.mpvClient.currentTimePos || 0; + const iconBuffer = await this.mediaGenerator.generateNotificationIcon( + this.mpvClient.currentVideoPath, + timestamp, + ); + if (iconBuffer && iconBuffer.length > 0) { + notificationIconPath = this.mediaGenerator.writeNotificationIconToFile( + iconBuffer, + noteId, + ); + } + } catch (err) { + log.warn('Failed to generate notification icon:', (err as Error).message); + } + } + + this.notificationCallback('Anki Card Updated', { + body: message, + icon: notificationIconPath, + }); + + if (notificationIconPath) { + this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath); + } + } + } + + private mergeFieldValue(existing: string, newValue: string, overwrite: boolean): string { + if (overwrite || !existing.trim()) { + return newValue; + } + if (this.config.behavior?.mediaInsertMode === 'prepend') { + return newValue + existing; + } + return existing + newValue; + } + + /** + * Update the last added Anki card using subtitle blocks from clipboard. + * This is the manual update flow (animecards-style) when auto-update is disabled. + */ + async updateLastAddedFromClipboard(clipboardText: string): Promise { + return this.cardCreationService.updateLastAddedFromClipboard(clipboardText); + } + + async triggerFieldGroupingForLastAddedCard(): Promise { + return this.fieldGroupingService.triggerFieldGroupingForLastAddedCard(); + } + + async markLastCardAsAudioCard(): Promise { + return this.cardCreationService.markLastCardAsAudioCard(); + } + + async createSentenceCard( + sentence: string, + startTime: number, + endTime: number, + secondarySubText?: string, + ): Promise { + return this.cardCreationService.createSentenceCard( + sentence, + startTime, + endTime, + secondarySubText, + ); + } + + private async findDuplicateNote( + expression: string, + excludeNoteId: number, + noteInfo: NoteInfo, + ): Promise { + return findDuplicateNoteForAnkiIntegration(expression, excludeNoteId, noteInfo, { + findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown, + notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, + getDeck: () => this.config.deck, + resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName), + logInfo: (message) => { + log.info(message); + }, + logDebug: (message) => { + log.debug(message); + }, + logWarn: (message, error) => { + log.warn(message, (error as Error).message); + }, + }); + } + + private getPreferredSentenceAudioFieldName(): string { + const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); + return sentenceCardConfig.audioField || 'SentenceAudio'; + } + + private getResolvedSentenceAudioFieldName(noteInfo: NoteInfo): string | null { + return ( + this.resolveNoteFieldName(noteInfo, this.getPreferredSentenceAudioFieldName()) || + this.resolveConfiguredFieldName(noteInfo, this.config.fields?.audio) + ); + } + + private async generateMediaForMerge(): Promise<{ + audioField?: string; + audioValue?: string; + imageField?: string; + imageValue?: string; + miscInfoValue?: string; + }> { + const result: { + audioField?: string; + audioValue?: string; + imageField?: string; + imageValue?: string; + miscInfoValue?: string; + } = {}; + + if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) { + try { + const audioFilename = this.generateAudioFilename(); + const audioBuffer = await this.generateAudio(); + if (audioBuffer) { + await this.client.storeMediaFile(audioFilename, audioBuffer); + result.audioField = this.getPreferredSentenceAudioFieldName(); + result.audioValue = `[sound:${audioFilename}]`; + if (this.config.fields?.miscInfo) { + result.miscInfoValue = this.formatMiscInfoPattern( + audioFilename, + this.mpvClient.currentSubStart, + ); + } + } + } catch (error) { + log.error('Failed to generate audio for merge:', (error as Error).message); + } + } + + if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) { + try { + const imageFilename = this.generateImageFilename(); + const imageBuffer = await this.generateImage(); + if (imageBuffer) { + await this.client.storeMediaFile(imageFilename, imageBuffer); + result.imageField = this.config.fields?.image || DEFAULT_ANKI_CONNECT_CONFIG.fields.image; + result.imageValue = ``; + if (this.config.fields?.miscInfo && !result.miscInfoValue) { + result.miscInfoValue = this.formatMiscInfoPattern( + imageFilename, + this.mpvClient.currentSubStart, + ); + } + } + } catch (error) { + log.error('Failed to generate image for merge:', (error as Error).message); + } + } + + return result; + } + + async buildFieldGroupingPreview( + keepNoteId: number, + deleteNoteId: number, + deleteDuplicate: boolean, + ): Promise { + return this.fieldGroupingService.buildFieldGroupingPreview( + keepNoteId, + deleteNoteId, + deleteDuplicate, + ); + } + + private async handleFieldGroupingAuto( + originalNoteId: number, + newNoteId: number, + newNoteInfo: NoteInfo, + expression: string, + ): Promise { + void expression; + await this.fieldGroupingWorkflow.handleAuto(originalNoteId, newNoteId, newNoteInfo); + } + + private async handleFieldGroupingManual( + originalNoteId: number, + newNoteId: number, + newNoteInfo: NoteInfo, + expression: string, + ): Promise { + void expression; + return this.fieldGroupingWorkflow.handleManual(originalNoteId, newNoteId, newNoteInfo); + } + + private truncateSentence(sentence: string): string { + const clean = sentence.replace(/<[^>]*>/g, '').trim(); + if (clean.length <= 100) return clean; + return clean.substring(0, 100) + '...'; + } + + private hasFieldValue(noteInfo: NoteInfo, preferredFieldName?: string): boolean { + const resolved = this.resolveNoteFieldName(noteInfo, preferredFieldName); + if (!resolved) return false; + return Boolean(noteInfo.fields[resolved]?.value); + } + + private hasAllConfiguredFields( + noteInfo: NoteInfo, + configuredFieldNames: (string | undefined)[], + ): boolean { + const requiredFields = configuredFieldNames.filter((fieldName): fieldName is string => + Boolean(fieldName), + ); + if (requiredFields.length === 0) return true; + return requiredFields.every((fieldName) => this.hasFieldValue(noteInfo, fieldName)); + } + + applyRuntimeConfigPatch(patch: Partial): void { + const wasEnabled = this.config.nPlusOne?.highlightEnabled === true; + const previousPollingRate = this.config.pollingRate; + this.config = { + ...this.config, + ...patch, + nPlusOne: + patch.nPlusOne !== undefined + ? { + ...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne), + ...patch.nPlusOne, + } + : this.config.nPlusOne, + fields: + patch.fields !== undefined + ? { ...this.config.fields, ...patch.fields } + : this.config.fields, + media: + patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media, + behavior: + patch.behavior !== undefined + ? { ...this.config.behavior, ...patch.behavior } + : this.config.behavior, + metadata: + patch.metadata !== undefined + ? { ...this.config.metadata, ...patch.metadata } + : this.config.metadata, + isLapis: + patch.isLapis !== undefined + ? { ...this.config.isLapis, ...patch.isLapis } + : this.config.isLapis, + isKiku: + patch.isKiku !== undefined + ? { ...this.config.isKiku, ...patch.isKiku } + : this.config.isKiku, + }; + + if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) { + this.stopKnownWordCacheLifecycle(); + this.knownWordCache.clearKnownWordCacheState(); + } else { + this.startKnownWordCacheLifecycle(); + } + + if ( + patch.pollingRate !== undefined && + previousPollingRate !== this.config.pollingRate && + this.pollingRunner.isRunning + ) { + this.pollingRunner.start(); + } + } + + destroy(): void { + this.stop(); + this.mediaGenerator.cleanup(); + } +} diff --git a/src/anki-integration/ai.ts b/src/anki-integration/ai.ts new file mode 100644 index 0000000..150f7a5 --- /dev/null +++ b/src/anki-integration/ai.ts @@ -0,0 +1,155 @@ +import axios from 'axios'; + +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; + +const DEFAULT_AI_SYSTEM_PROMPT = + 'You are a translation engine. Return only the translated text with no explanations.'; + +export function extractAiText(content: unknown): string { + if (typeof content === 'string') { + return content.trim(); + } + if (!Array.isArray(content)) { + return ''; + } + + const parts: string[] = []; + for (const item of content) { + if ( + item && + typeof item === 'object' && + 'type' in item && + (item as { type?: unknown }).type === 'text' && + 'text' in item && + typeof (item as { text?: unknown }).text === 'string' + ) { + parts.push((item as { text: string }).text); + } + } + + return parts.join('').trim(); +} + +export function normalizeOpenAiBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ''); + if (/\/v1$/i.test(trimmed)) { + return trimmed; + } + return `${trimmed}/v1`; +} + +export interface AiTranslateRequest { + sentence: string; + apiKey: string; + baseUrl?: string; + model?: string; + targetLanguage?: string; + systemPrompt?: string; +} + +export interface AiTranslateCallbacks { + logWarning: (message: string) => void; +} + +export interface AiSentenceTranslationInput { + sentence: string; + secondarySubText?: string; + config: { + apiKey?: string; + baseUrl?: string; + model?: string; + targetLanguage?: string; + systemPrompt?: string; + enabled?: boolean; + alwaysUseAiTranslation?: boolean; + }; +} + +export interface AiSentenceTranslationCallbacks { + logWarning: (message: string) => void; +} + +export async function translateSentenceWithAi( + request: AiTranslateRequest, + callbacks: AiTranslateCallbacks, +): Promise { + const aiConfig = DEFAULT_ANKI_CONNECT_CONFIG.ai; + if (!request.apiKey.trim()) { + return null; + } + + const baseUrl = normalizeOpenAiBaseUrl( + request.baseUrl || aiConfig.baseUrl || 'https://openrouter.ai/api', + ); + const model = request.model || 'openai/gpt-4o-mini'; + const targetLanguage = request.targetLanguage || 'English'; + const prompt = request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT; + + try { + const response = await axios.post( + `${baseUrl}/chat/completions`, + { + model, + temperature: 0, + messages: [ + { role: 'system', content: prompt }, + { + role: 'user', + content: `Translate this text to ${targetLanguage}:\n\n${request.sentence}`, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${request.apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: 15000, + }, + ); + const content = (response.data as { choices?: unknown[] })?.choices?.[0] as + | { message?: { content?: unknown } } + | undefined; + return extractAiText(content?.message?.content) || null; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown translation error'; + callbacks.logWarning(`AI translation failed: ${message}`); + return null; + } +} + +export async function resolveSentenceBackText( + input: AiSentenceTranslationInput, + callbacks: AiSentenceTranslationCallbacks, +): Promise { + const hasSecondarySub = Boolean(input.secondarySubText?.trim()); + let backText = input.secondarySubText?.trim() || ''; + + const aiConfig = { + ...DEFAULT_ANKI_CONNECT_CONFIG.ai, + ...input.config, + }; + const shouldAttemptAiTranslation = + aiConfig.enabled === true && (aiConfig.alwaysUseAiTranslation === true || !hasSecondarySub); + + if (!shouldAttemptAiTranslation) return backText; + + const request: AiTranslateRequest = { + sentence: input.sentence, + apiKey: aiConfig.apiKey ?? '', + baseUrl: aiConfig.baseUrl, + model: aiConfig.model, + targetLanguage: aiConfig.targetLanguage, + systemPrompt: aiConfig.systemPrompt, + }; + + const translated = await translateSentenceWithAi(request, { + logWarning: (message) => callbacks.logWarning(message), + }); + + if (translated) { + return translated; + } + + return hasSecondarySub ? backText : input.sentence; +} diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts new file mode 100644 index 0000000..9d9c63b --- /dev/null +++ b/src/anki-integration/card-creation.ts @@ -0,0 +1,717 @@ +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; +import { AnkiConnectConfig } from '../types'; +import { createLogger } from '../logger'; +import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; +import { MpvClient } from '../types'; +import { resolveSentenceBackText } from './ai'; + +const log = createLogger('anki').child('integration.card-creation'); + +export interface CardCreationNoteInfo { + noteId: number; + fields: Record; +} + +type CardKind = 'sentence' | 'audio'; + +interface CardCreationClient { + addNote( + deck: string, + modelName: string, + fields: Record, + tags?: string[], + ): Promise; + addTags(noteIds: number[], tags: string[]): Promise; + notesInfo(noteIds: number[]): Promise; + updateNoteFields(noteId: number, fields: Record): Promise; + storeMediaFile(filename: string, data: Buffer): Promise; + findNotes(query: string, options?: { maxRetries?: number }): Promise; +} + +interface CardCreationMediaGenerator { + generateAudio( + path: string, + startTime: number, + endTime: number, + audioPadding?: number, + audioStreamIndex?: number, + ): Promise; + generateScreenshot( + path: string, + timestamp: number, + options: { + format: 'jpg' | 'png' | 'webp'; + quality?: number; + maxWidth?: number; + maxHeight?: number; + }, + ): Promise; + generateAnimatedImage( + path: string, + startTime: number, + endTime: number, + audioPadding?: number, + options?: { + fps?: number; + maxWidth?: number; + maxHeight?: number; + crf?: number; + }, + ): Promise; +} + +interface CardCreationDeps { + getConfig: () => AnkiConnectConfig; + getTimingTracker: () => SubtitleTimingTracker; + getMpvClient: () => MpvClient; + getDeck?: () => string | undefined; + client: CardCreationClient; + mediaGenerator: CardCreationMediaGenerator; + showOsdNotification: (text: string) => void; + showStatusNotification: (message: string) => void; + showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise; + beginUpdateProgress: (initialMessage: string) => void; + endUpdateProgress: () => void; + withUpdateProgress: (initialMessage: string, action: () => Promise) => Promise; + resolveConfiguredFieldName: ( + noteInfo: CardCreationNoteInfo, + ...preferredNames: (string | undefined)[] + ) => string | null; + resolveNoteFieldName: (noteInfo: CardCreationNoteInfo, preferredName?: string) => string | null; + extractFields: (fields: Record) => Record; + processSentence: (mpvSentence: string, noteFields: Record) => string; + setCardTypeFields: ( + updatedFields: Record, + availableFieldNames: string[], + cardKind: CardKind, + ) => void; + mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; + formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + getEffectiveSentenceCardConfig: () => { + model?: string; + sentenceField: string; + audioField: string; + lapisEnabled: boolean; + kikuEnabled: boolean; + kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; + kikuDeleteDuplicateInAuto: boolean; + }; + getFallbackDurationSeconds: () => number; + appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void; + isUpdateInProgress: () => boolean; + setUpdateInProgress: (value: boolean) => void; + trackLastAddedNoteId?: (noteId: number) => void; +} + +export class CardCreationService { + constructor(private readonly deps: CardCreationDeps) {} + + private getConfiguredAnkiTags(): string[] { + const tags = this.deps.getConfig().tags; + if (!Array.isArray(tags)) { + return []; + } + return [...new Set(tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))]; + } + + private async addConfiguredTagsToNote(noteId: number): Promise { + const tags = this.getConfiguredAnkiTags(); + if (tags.length === 0) { + return; + } + try { + await this.deps.client.addTags([noteId], tags); + } catch (error) { + log.warn('Failed to add tags to card:', (error as Error).message); + } + } + + async updateLastAddedFromClipboard(clipboardText: string): Promise { + try { + if (!clipboardText || !clipboardText.trim()) { + this.deps.showOsdNotification('Clipboard is empty'); + return; + } + + const mpvClient = this.deps.getMpvClient(); + if (!mpvClient || !mpvClient.currentVideoPath) { + this.deps.showOsdNotification('No video loaded'); + return; + } + + const blocks = clipboardText + .split(/\n\s*\n/) + .map((block) => block.trim()) + .filter((block) => block.length > 0); + + if (blocks.length === 0) { + this.deps.showOsdNotification('No subtitle blocks found in clipboard'); + return; + } + + const timings: { startTime: number; endTime: number }[] = []; + const timingTracker = this.deps.getTimingTracker(); + for (const block of blocks) { + const timing = timingTracker.findTiming(block); + if (timing) { + timings.push(timing); + } + } + + if (timings.length === 0) { + this.deps.showOsdNotification('Subtitle timing not found; copy again while playing'); + return; + } + + const rangeStart = Math.min(...timings.map((entry) => entry.startTime)); + let rangeEnd = Math.max(...timings.map((entry) => entry.endTime)); + + const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; + if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) { + log.warn( + `Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, + ); + rangeEnd = rangeStart + maxMediaDuration; + } + + this.deps.showOsdNotification('Updating card from clipboard...'); + this.deps.beginUpdateProgress('Updating card from clipboard'); + this.deps.setUpdateInProgress(true); + + try { + const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck; + const query = deck ? `"deck:${deck}" added:1` : 'added:1'; + const noteIds = (await this.deps.client.findNotes(query, { + maxRetries: 0, + })) as number[]; + if (!noteIds || noteIds.length === 0) { + this.deps.showOsdNotification('No recently added cards found'); + return; + } + + const noteId = Math.max(...noteIds); + const notesInfoResult = (await this.deps.client.notesInfo([ + noteId, + ])) as CardCreationNoteInfo[]; + if (!notesInfoResult || notesInfoResult.length === 0) { + this.deps.showOsdNotification('Card not found'); + return; + } + + const noteInfo = notesInfoResult[0]!; + const fields = this.deps.extractFields(noteInfo.fields); + const expressionText = fields.expression || fields.word || ''; + const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo); + const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField; + + const sentence = blocks.join(' '); + const updatedFields: Record = {}; + let updatePerformed = false; + const errors: string[] = []; + let miscInfoFilename: string | null = null; + + if (sentenceField) { + const processedSentence = this.deps.processSentence(sentence, fields); + updatedFields[sentenceField] = processedSentence; + updatePerformed = true; + } + + log.info( + `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, + ); + + if (this.deps.getConfig().media?.generateAudio) { + try { + const audioFilename = this.generateAudioFilename(); + const audioBuffer = await this.mediaGenerateAudio( + mpvClient.currentVideoPath, + rangeStart, + rangeEnd, + ); + + if (audioBuffer) { + await this.deps.client.storeMediaFile(audioFilename, audioBuffer); + if (sentenceAudioField) { + const existingAudio = noteInfo.fields[sentenceAudioField]?.value || ''; + updatedFields[sentenceAudioField] = this.deps.mergeFieldValue( + existingAudio, + `[sound:${audioFilename}]`, + this.deps.getConfig().behavior?.overwriteAudio !== false, + ); + } + miscInfoFilename = audioFilename; + updatePerformed = true; + } + } catch (error) { + log.error('Failed to generate audio:', (error as Error).message); + errors.push('audio'); + } + } + + if (this.deps.getConfig().media?.generateImage) { + try { + const imageFilename = this.generateImageFilename(); + const imageBuffer = await this.generateImageBuffer( + mpvClient.currentVideoPath, + rangeStart, + rangeEnd, + ); + + if (imageBuffer) { + await this.deps.client.storeMediaFile(imageFilename, imageBuffer); + const imageFieldName = this.deps.resolveConfiguredFieldName( + noteInfo, + this.deps.getConfig().fields?.image, + DEFAULT_ANKI_CONNECT_CONFIG.fields.image, + ); + if (!imageFieldName) { + log.warn('Image field not found on note, skipping image update'); + } else { + const existingImage = noteInfo.fields[imageFieldName]?.value || ''; + updatedFields[imageFieldName] = this.deps.mergeFieldValue( + existingImage, + ``, + this.deps.getConfig().behavior?.overwriteImage !== false, + ); + miscInfoFilename = imageFilename; + updatePerformed = true; + } + } + } catch (error) { + log.error('Failed to generate image:', (error as Error).message); + errors.push('image'); + } + } + + if (this.deps.getConfig().fields?.miscInfo) { + const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', rangeStart); + const miscInfoField = this.deps.resolveConfiguredFieldName( + noteInfo, + this.deps.getConfig().fields?.miscInfo, + ); + if (miscInfo && miscInfoField) { + updatedFields[miscInfoField] = miscInfo; + updatePerformed = true; + } + } + + if (updatePerformed) { + await this.deps.client.updateNoteFields(noteId, updatedFields); + await this.addConfiguredTagsToNote(noteId); + const label = expressionText || noteId; + log.info('Updated card from clipboard:', label); + const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; + await this.deps.showNotification(noteId, label, errorSuffix); + } + } finally { + this.deps.setUpdateInProgress(false); + this.deps.endUpdateProgress(); + } + } catch (error) { + log.error('Error updating card from clipboard:', (error as Error).message); + this.deps.showOsdNotification(`Update failed: ${(error as Error).message}`); + } + } + + async markLastCardAsAudioCard(): Promise { + if (this.deps.isUpdateInProgress()) { + this.deps.showOsdNotification('Anki update already in progress'); + return; + } + + try { + const mpvClient = this.deps.getMpvClient(); + if (!mpvClient || !mpvClient.currentVideoPath) { + this.deps.showOsdNotification('No video loaded'); + return; + } + + if (!mpvClient.currentSubText) { + this.deps.showOsdNotification('No current subtitle'); + return; + } + + let startTime = mpvClient.currentSubStart; + let endTime = mpvClient.currentSubEnd; + + if (startTime === undefined || endTime === undefined) { + const currentTime = mpvClient.currentTimePos || 0; + const fallback = this.deps.getFallbackDurationSeconds() / 2; + startTime = currentTime - fallback; + endTime = currentTime + fallback; + } + + const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; + if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { + endTime = startTime + maxMediaDuration; + } + + this.deps.showOsdNotification('Marking card as audio card...'); + await this.deps.withUpdateProgress('Marking audio card', async () => { + const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck; + const query = deck ? `"deck:${deck}" added:1` : 'added:1'; + const noteIds = (await this.deps.client.findNotes(query)) as number[]; + if (!noteIds || noteIds.length === 0) { + this.deps.showOsdNotification('No recently added cards found'); + return; + } + + const noteId = Math.max(...noteIds); + const notesInfoResult = (await this.deps.client.notesInfo([ + noteId, + ])) as CardCreationNoteInfo[]; + if (!notesInfoResult || notesInfoResult.length === 0) { + this.deps.showOsdNotification('Card not found'); + return; + } + + const noteInfo = notesInfoResult[0]!; + const fields = this.deps.extractFields(noteInfo.fields); + const expressionText = fields.expression || fields.word || ''; + + const updatedFields: Record = {}; + const errors: string[] = []; + let miscInfoFilename: string | null = null; + + this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio'); + + const sentenceField = this.deps.getConfig().fields?.sentence; + if (sentenceField) { + const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields); + updatedFields[sentenceField] = processedSentence; + } + + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const audioFieldName = sentenceCardConfig.audioField; + try { + const audioFilename = this.generateAudioFilename(); + const audioBuffer = await this.mediaGenerateAudio( + mpvClient.currentVideoPath, + startTime, + endTime, + ); + + if (audioBuffer) { + await this.deps.client.storeMediaFile(audioFilename, audioBuffer); + updatedFields[audioFieldName] = `[sound:${audioFilename}]`; + miscInfoFilename = audioFilename; + } + } catch (error) { + log.error('Failed to generate audio for audio card:', (error as Error).message); + errors.push('audio'); + } + + if (this.deps.getConfig().media?.generateImage) { + try { + const imageFilename = this.generateImageFilename(); + const imageBuffer = await this.generateImageBuffer( + mpvClient.currentVideoPath, + startTime, + endTime, + ); + + const imageField = this.deps.getConfig().fields?.image; + if (imageBuffer && imageField) { + await this.deps.client.storeMediaFile(imageFilename, imageBuffer); + updatedFields[imageField] = ``; + miscInfoFilename = imageFilename; + } + } catch (error) { + log.error('Failed to generate image for audio card:', (error as Error).message); + errors.push('image'); + } + } + + if (this.deps.getConfig().fields?.miscInfo) { + const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime); + const miscInfoField = this.deps.resolveConfiguredFieldName( + noteInfo, + this.deps.getConfig().fields?.miscInfo, + ); + if (miscInfo && miscInfoField) { + updatedFields[miscInfoField] = miscInfo; + } + } + + await this.deps.client.updateNoteFields(noteId, updatedFields); + await this.addConfiguredTagsToNote(noteId); + const label = expressionText || noteId; + log.info('Marked card as audio card:', label); + const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; + await this.deps.showNotification(noteId, label, errorSuffix); + }); + } catch (error) { + log.error('Error marking card as audio card:', (error as Error).message); + this.deps.showOsdNotification(`Audio card failed: ${(error as Error).message}`); + } + } + + async createSentenceCard( + sentence: string, + startTime: number, + endTime: number, + secondarySubText?: string, + ): Promise { + if (this.deps.isUpdateInProgress()) { + this.deps.showOsdNotification('Anki update already in progress'); + return false; + } + + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const sentenceCardModel = sentenceCardConfig.model; + if (!sentenceCardModel) { + this.deps.showOsdNotification('sentenceCardModel not configured'); + return false; + } + + const mpvClient = this.deps.getMpvClient(); + if (!mpvClient || !mpvClient.currentVideoPath) { + this.deps.showOsdNotification('No video loaded'); + return false; + } + + const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; + if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { + log.warn( + `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, + ); + endTime = startTime + maxMediaDuration; + } + + this.deps.showOsdNotification('Creating sentence card...'); + try { + return await this.deps.withUpdateProgress('Creating sentence card', async () => { + const videoPath = mpvClient.currentVideoPath; + const fields: Record = {}; + const errors: string[] = []; + let miscInfoFilename: string | null = null; + + const sentenceField = sentenceCardConfig.sentenceField; + const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio'; + const translationField = this.deps.getConfig().fields?.translation || 'SelectionText'; + let resolvedMiscInfoField: string | null = null; + let resolvedSentenceAudioField: string = audioFieldName; + let resolvedExpressionAudioField: string | null = null; + + fields[sentenceField] = sentence; + + const backText = await resolveSentenceBackText( + { + sentence, + secondarySubText, + config: this.deps.getConfig().ai || {}, + }, + { + logWarning: (message: string) => log.warn(message), + }, + ); + if (backText) { + fields[translationField] = backText; + } + + if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) { + fields.IsSentenceCard = 'x'; + fields.Expression = sentence; + } + + const deck = this.deps.getConfig().deck || 'Default'; + let noteId: number; + try { + noteId = await this.deps.client.addNote( + deck, + sentenceCardModel, + fields, + this.getConfiguredAnkiTags(), + ); + log.info('Created sentence card:', noteId); + this.deps.trackLastAddedNoteId?.(noteId); + } catch (error) { + log.error('Failed to create sentence card:', (error as Error).message); + this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`); + return false; + } + + try { + const noteInfoResult = await this.deps.client.notesInfo([noteId]); + const noteInfos = noteInfoResult as CardCreationNoteInfo[]; + if (noteInfos.length > 0) { + const createdNoteInfo = noteInfos[0]!; + this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo); + resolvedSentenceAudioField = + this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName; + resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName( + createdNoteInfo, + this.deps.getConfig().fields?.audio || 'ExpressionAudio', + ); + resolvedMiscInfoField = this.deps.resolveConfiguredFieldName( + createdNoteInfo, + this.deps.getConfig().fields?.miscInfo, + ); + + const cardTypeFields: Record = {}; + this.deps.setCardTypeFields( + cardTypeFields, + Object.keys(createdNoteInfo.fields), + 'sentence', + ); + if (Object.keys(cardTypeFields).length > 0) { + await this.deps.client.updateNoteFields(noteId, cardTypeFields); + } + } + } catch (error) { + log.error('Failed to normalize sentence card type fields:', (error as Error).message); + errors.push('card type fields'); + } + + const mediaFields: Record = {}; + + try { + const audioFilename = this.generateAudioFilename(); + const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime); + + if (audioBuffer) { + await this.deps.client.storeMediaFile(audioFilename, audioBuffer); + const audioValue = `[sound:${audioFilename}]`; + mediaFields[resolvedSentenceAudioField] = audioValue; + if ( + resolvedExpressionAudioField && + resolvedExpressionAudioField !== resolvedSentenceAudioField + ) { + mediaFields[resolvedExpressionAudioField] = audioValue; + } + miscInfoFilename = audioFilename; + } + } catch (error) { + log.error('Failed to generate sentence audio:', (error as Error).message); + errors.push('audio'); + } + + try { + const imageFilename = this.generateImageFilename(); + const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime); + + const imageField = this.deps.getConfig().fields?.image; + if (imageBuffer && imageField) { + await this.deps.client.storeMediaFile(imageFilename, imageBuffer); + mediaFields[imageField] = ``; + miscInfoFilename = imageFilename; + } + } catch (error) { + log.error('Failed to generate sentence image:', (error as Error).message); + errors.push('image'); + } + + if (this.deps.getConfig().fields?.miscInfo) { + const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime); + if (miscInfo && resolvedMiscInfoField) { + mediaFields[resolvedMiscInfoField] = miscInfo; + } + } + + if (Object.keys(mediaFields).length > 0) { + try { + await this.deps.client.updateNoteFields(noteId, mediaFields); + } catch (error) { + log.error('Failed to update sentence card media:', (error as Error).message); + errors.push('media update'); + } + } + + const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence; + const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; + await this.deps.showNotification(noteId, label, errorSuffix); + return true; + }); + } catch (error) { + log.error('Error creating sentence card:', (error as Error).message); + this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`); + return false; + } + } + + private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null { + return ( + this.deps.resolveNoteFieldName( + noteInfo, + this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio', + ) || this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio) + ); + } + + private async mediaGenerateAudio( + videoPath: string, + startTime: number, + endTime: number, + ): Promise { + const mpvClient = this.deps.getMpvClient(); + if (!mpvClient) { + return null; + } + + return this.deps.mediaGenerator.generateAudio( + videoPath, + startTime, + endTime, + this.deps.getConfig().media?.audioPadding, + mpvClient.currentAudioStreamIndex ?? undefined, + ); + } + + private async generateImageBuffer( + videoPath: string, + startTime: number, + endTime: number, + ): Promise { + const mpvClient = this.deps.getMpvClient(); + if (!mpvClient) { + return null; + } + + const timestamp = mpvClient.currentTimePos || 0; + + if (this.deps.getConfig().media?.imageType === 'avif') { + let imageStart = startTime; + let imageEnd = endTime; + + if (!Number.isFinite(imageStart) || !Number.isFinite(imageEnd)) { + const fallback = this.deps.getFallbackDurationSeconds() / 2; + imageStart = timestamp - fallback; + imageEnd = timestamp + fallback; + } + + return this.deps.mediaGenerator.generateAnimatedImage( + videoPath, + imageStart, + imageEnd, + this.deps.getConfig().media?.audioPadding, + { + fps: this.deps.getConfig().media?.animatedFps, + maxWidth: this.deps.getConfig().media?.animatedMaxWidth, + maxHeight: this.deps.getConfig().media?.animatedMaxHeight, + crf: this.deps.getConfig().media?.animatedCrf, + }, + ); + } + + return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, { + format: this.deps.getConfig().media?.imageFormat as 'jpg' | 'png' | 'webp', + quality: this.deps.getConfig().media?.imageQuality, + maxWidth: this.deps.getConfig().media?.imageMaxWidth, + maxHeight: this.deps.getConfig().media?.imageMaxHeight, + }); + } + + private generateAudioFilename(): string { + const timestamp = Date.now(); + return `audio_${timestamp}.mp3`; + } + + private generateImageFilename(): string { + const timestamp = Date.now(); + const ext = + this.deps.getConfig().media?.imageType === 'avif' + ? 'avif' + : this.deps.getConfig().media?.imageFormat; + return `image_${timestamp}.${ext}`; + } +} diff --git a/src/anki-integration/duplicate.test.ts b/src/anki-integration/duplicate.test.ts new file mode 100644 index 0000000..240c6b2 --- /dev/null +++ b/src/anki-integration/duplicate.test.ts @@ -0,0 +1,265 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { findDuplicateNote, type NoteInfo } from './duplicate'; + +function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null { + const names = Object.keys(noteInfo.fields); + const exact = names.find((name) => name === preferredName); + if (exact) return exact; + const lower = preferredName.toLowerCase(); + return names.find((name) => name.toLowerCase() === lower) ?? null; +} + +test('findDuplicateNote matches duplicate when candidate uses alternate word/expression field name', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '食べる' }, + }, + }; + + const duplicateId = await findDuplicateNote('食べる', 100, currentNote, { + findNotes: async () => [100, 200], + notesInfo: async () => [ + { + noteId: 200, + fields: { + Word: { value: '食べる' }, + }, + }, + ], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.equal(duplicateId, 200); +}); + +test('findDuplicateNote falls back to alias field query when primary field query returns no candidates', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '食べる' }, + }, + }; + + const seenQueries: string[] = []; + const duplicateId = await findDuplicateNote('食べる', 100, currentNote, { + findNotes: async (query) => { + seenQueries.push(query); + if (query.includes('"Expression:')) { + return []; + } + if (query.includes('"word:') || query.includes('"Word:') || query.includes('"expression:')) { + return [200]; + } + return []; + }, + notesInfo: async () => [ + { + noteId: 200, + fields: { + Word: { value: '食べる' }, + }, + }, + ], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.equal(duplicateId, 200); + assert.equal(seenQueries.length, 2); +}); + +test('findDuplicateNote checks both source expression/word values when both fields are present', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '昨日は雨だった。' }, + Word: { value: '雨' }, + }, + }; + + const seenQueries: string[] = []; + const duplicateId = await findDuplicateNote('昨日は雨だった。', 100, currentNote, { + findNotes: async (query) => { + seenQueries.push(query); + if (query.includes('昨日は雨だった。')) { + return []; + } + if (query.includes('"Word:雨"') || query.includes('"word:雨"') || query.includes('"Expression:雨"')) { + return [200]; + } + return []; + }, + notesInfo: async () => [ + { + noteId: 200, + fields: { + Word: { value: '雨' }, + }, + }, + ], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.equal(duplicateId, 200); + assert.ok(seenQueries.some((query) => query.includes('昨日は雨だった。'))); + assert.ok(seenQueries.some((query) => query.includes('雨'))); +}); + +test('findDuplicateNote falls back to collection-wide query when deck-scoped query has no matches', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '貴様' }, + }, + }; + + const seenQueries: string[] = []; + const duplicateId = await findDuplicateNote('貴様', 100, currentNote, { + findNotes: async (query) => { + seenQueries.push(query); + if (query.includes('deck:Japanese')) { + return []; + } + if (query.includes('"Expression:貴様"') || query.includes('"Word:貴様"')) { + return [200]; + } + return []; + }, + notesInfo: async () => [ + { + noteId: 200, + fields: { + Expression: { value: '貴様' }, + }, + }, + ], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.equal(duplicateId, 200); + assert.ok(seenQueries.some((query) => query.includes('deck:Japanese'))); + assert.ok(seenQueries.some((query) => !query.includes('deck:Japanese'))); +}); + +test('findDuplicateNote falls back to plain text query when field queries miss', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '貴様' }, + }, + }; + + const seenQueries: string[] = []; + const duplicateId = await findDuplicateNote('貴様', 100, currentNote, { + findNotes: async (query) => { + seenQueries.push(query); + if (query.includes('Expression:') || query.includes('Word:')) { + return []; + } + if (query.includes('"貴様"')) { + return [200]; + } + return []; + }, + notesInfo: async () => [ + { + noteId: 200, + fields: { + Expression: { value: '貴様' }, + }, + }, + ], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.equal(duplicateId, 200); + assert.ok(seenQueries.some((query) => query.includes('Expression:'))); + assert.ok(seenQueries.some((query) => query.endsWith('"貴様"'))); +}); + +test('findDuplicateNote exact compare tolerates furigana bracket markup in candidate field', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '貴様' }, + }, + }; + + const duplicateId = await findDuplicateNote('貴様', 100, currentNote, { + findNotes: async () => [200], + notesInfo: async () => [ + { + noteId: 200, + fields: { + Expression: { value: '貴様[きさま]' }, + }, + }, + ], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.equal(duplicateId, 200); +}); + +test('findDuplicateNote exact compare tolerates html wrappers in candidate field', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '貴様' }, + }, + }; + + const duplicateId = await findDuplicateNote('貴様', 100, currentNote, { + findNotes: async () => [200], + notesInfo: async () => [ + { + noteId: 200, + fields: { + Expression: { value: '貴様' }, + }, + }, + ], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.equal(duplicateId, 200); +}); + +test('findDuplicateNote does not disable retries on findNotes calls', async () => { + const currentNote: NoteInfo = { + noteId: 100, + fields: { + Expression: { value: '貴様' }, + }, + }; + + const seenOptions: Array<{ maxRetries?: number } | undefined> = []; + await findDuplicateNote('貴様', 100, currentNote, { + findNotes: async (_query, options) => { + seenOptions.push(options); + return []; + }, + notesInfo: async () => [], + getDeck: () => 'Japanese::Mining', + resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName), + logWarn: () => {}, + }); + + assert.ok(seenOptions.length > 0); + assert.ok(seenOptions.every((options) => options?.maxRetries !== 0)); +}); diff --git a/src/anki-integration/duplicate.ts b/src/anki-integration/duplicate.ts new file mode 100644 index 0000000..52ed7ff --- /dev/null +++ b/src/anki-integration/duplicate.ts @@ -0,0 +1,194 @@ +export interface NoteField { + value: string; +} + +export interface NoteInfo { + noteId: number; + fields: Record; +} + +export interface DuplicateDetectionDeps { + findNotes: (query: string, options?: { maxRetries?: number }) => Promise; + notesInfo: (noteIds: number[]) => Promise; + getDeck: () => string | null | undefined; + resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null; + logInfo?: (message: string) => void; + logDebug?: (message: string) => void; + logWarn: (message: string, error: unknown) => void; +} + +export async function findDuplicateNote( + expression: string, + excludeNoteId: number, + noteInfo: NoteInfo, + deps: DuplicateDetectionDeps, +): Promise { + const sourceCandidates = getDuplicateSourceCandidates(noteInfo, expression); + if (sourceCandidates.length === 0) return null; + deps.logInfo?.( + `[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates + .map((entry) => `${entry.fieldName}:${entry.value}`) + .join('|')}`, + ); + + const deckValue = deps.getDeck(); + const queryPrefixes = deckValue + ? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, ''] + : ['']; + + try { + const noteIds = new Set(); + const executedQueries = new Set(); + for (const queryPrefix of queryPrefixes) { + for (const sourceCandidate of sourceCandidates) { + const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value); + const queryFieldNames = getDuplicateCandidateFieldNames(sourceCandidate.fieldName); + for (const queryFieldName of queryFieldNames) { + const escapedFieldName = escapeAnkiSearchValue(queryFieldName); + const query = `${queryPrefix}"${escapedFieldName}:${escapedExpression}"`; + if (executedQueries.has(query)) continue; + executedQueries.add(query); + const results = (await deps.findNotes(query)) as number[]; + deps.logDebug?.( + `[duplicate] query(field)="${query}" hits=${Array.isArray(results) ? results.length : 0}`, + ); + for (const noteId of results) { + noteIds.add(noteId); + } + } + } + if (noteIds.size > 0) break; + } + + if (noteIds.size === 0) { + for (const queryPrefix of queryPrefixes) { + for (const sourceCandidate of sourceCandidates) { + const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value); + const query = `${queryPrefix}"${escapedExpression}"`; + if (executedQueries.has(query)) continue; + executedQueries.add(query); + const results = (await deps.findNotes(query)) as number[]; + deps.logDebug?.( + `[duplicate] query(text)="${query}" hits=${Array.isArray(results) ? results.length : 0}`, + ); + for (const noteId of results) { + noteIds.add(noteId); + } + } + if (noteIds.size > 0) break; + } + } + + return await findFirstExactDuplicateNoteId( + noteIds, + excludeNoteId, + sourceCandidates.map((candidate) => candidate.value), + deps, + ); + } catch (error) { + deps.logWarn('Duplicate search failed:', error); + return null; + } +} + +function findFirstExactDuplicateNoteId( + candidateNoteIds: Iterable, + excludeNoteId: number, + sourceValues: string[], + deps: DuplicateDetectionDeps, +): Promise { + const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId); + deps.logDebug?.(`[duplicate] candidateIds=${candidates.length} exclude=${excludeNoteId}`); + if (candidates.length === 0) { + deps.logInfo?.('[duplicate] no candidates after query + exclude'); + return Promise.resolve(null); + } + + const normalizedValues = new Set( + sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0), + ); + if (normalizedValues.size === 0) { + return Promise.resolve(null); + } + + const chunkSize = 50; + return (async () => { + for (let i = 0; i < candidates.length; i += chunkSize) { + const chunk = candidates.slice(i, i + chunkSize); + const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[]; + const notesInfo = notesInfoResult as NoteInfo[]; + for (const noteInfo of notesInfo) { + const candidateFieldNames = ['word', 'expression']; + for (const candidateFieldName of candidateFieldNames) { + const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName); + if (!resolvedField) continue; + const candidateValue = noteInfo.fields[resolvedField]?.value || ''; + if (normalizedValues.has(normalizeDuplicateValue(candidateValue))) { + deps.logDebug?.( + `[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`, + ); + deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`); + return noteInfo.noteId; + } + } + } + } + deps.logInfo?.('[duplicate] no exact match in candidate notes'); + return null; + })(); +} + +function getDuplicateCandidateFieldNames(fieldName: string): string[] { + const candidates = [fieldName]; + const lower = fieldName.toLowerCase(); + if (lower === 'word') { + candidates.push('expression'); + } else if (lower === 'expression') { + candidates.push('word'); + } + return candidates; +} + +function getDuplicateSourceCandidates( + noteInfo: NoteInfo, + fallbackExpression: string, +): Array<{ fieldName: string; value: string }> { + const candidates: Array<{ fieldName: string; value: string }> = []; + const dedupeKey = new Set(); + + for (const fieldName of Object.keys(noteInfo.fields)) { + const lower = fieldName.toLowerCase(); + if (lower !== 'word' && lower !== 'expression') continue; + const value = noteInfo.fields[fieldName]?.value?.trim() ?? ''; + if (!value) continue; + const key = `${lower}:${normalizeDuplicateValue(value)}`; + if (dedupeKey.has(key)) continue; + dedupeKey.add(key); + candidates.push({ fieldName, value }); + } + + const trimmedFallback = fallbackExpression.trim(); + if (trimmedFallback.length > 0) { + const fallbackKey = `expression:${normalizeDuplicateValue(trimmedFallback)}`; + if (!dedupeKey.has(fallbackKey)) { + candidates.push({ fieldName: 'expression', value: trimmedFallback }); + } + } + + return candidates; +} + +function normalizeDuplicateValue(value: string): string { + return value + .replace(/<[^>]*>/g, '') + .replace(/([^\s\[\]]+)\[[^\]]*\]/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +function escapeAnkiSearchValue(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/([:*?()[\]{}])/g, '\\$1'); +} diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts new file mode 100644 index 0000000..06bff96 --- /dev/null +++ b/src/anki-integration/field-grouping-merge.ts @@ -0,0 +1,461 @@ +import { AnkiConnectConfig } from '../types'; + +interface FieldGroupingMergeMedia { + audioField?: string; + audioValue?: string; + imageField?: string; + imageValue?: string; + miscInfoValue?: string; +} + +export interface FieldGroupingMergeNoteInfo { + noteId: number; + fields: Record; +} + +interface FieldGroupingMergeDeps { + getConfig: () => AnkiConnectConfig; + getEffectiveSentenceCardConfig: () => { + sentenceField: string; + audioField: string; + }; + getCurrentSubtitleText: () => string | undefined; + resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; + resolveNoteFieldName: ( + noteInfo: FieldGroupingMergeNoteInfo, + preferredName?: string, + ) => string | null; + extractFields: (fields: Record) => Record; + processSentence: (mpvSentence: string, noteFields: Record) => string; + generateMediaForMerge: () => Promise; + warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void; +} + +export class FieldGroupingMergeCollaborator { + private readonly strictGroupingFieldDefaults = new Set([ + 'picture', + 'sentence', + 'sentenceaudio', + 'sentencefurigana', + 'miscinfo', + ]); + + constructor(private readonly deps: FieldGroupingMergeDeps) {} + + getGroupableFieldNames(): string[] { + const config = this.deps.getConfig(); + const fields: string[] = []; + fields.push('Sentence'); + fields.push('SentenceAudio'); + fields.push('Picture'); + if (config.fields?.image) fields.push(config.fields?.image); + if (config.fields?.sentence) fields.push(config.fields?.sentence); + if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') { + fields.push(config.fields?.audio); + } + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const sentenceAudioField = sentenceCardConfig.audioField; + if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField); + if (config.fields?.miscInfo) fields.push(config.fields?.miscInfo); + fields.push('SentenceFurigana'); + return fields; + } + + getNoteFieldMap(noteInfo: FieldGroupingMergeNoteInfo): Record { + const fields: Record = {}; + for (const [name, field] of Object.entries(noteInfo.fields)) { + fields[name] = field?.value || ''; + } + return fields; + } + + async computeFieldGroupingMergedFields( + keepNoteId: number, + deleteNoteId: number, + keepNoteInfo: FieldGroupingMergeNoteInfo, + deleteNoteInfo: FieldGroupingMergeNoteInfo, + includeGeneratedMedia: boolean, + ): Promise> { + const config = this.deps.getConfig(); + const groupableFields = this.getGroupableFieldNames(); + const keepFieldNames = Object.keys(keepNoteInfo.fields); + const sourceFields: Record = {}; + const resolvedKeepFieldByPreferred = new Map(); + for (const preferredFieldName of groupableFields) { + sourceFields[preferredFieldName] = this.getResolvedFieldValue( + deleteNoteInfo, + preferredFieldName, + ); + const keepResolved = this.deps.resolveFieldName(keepFieldNames, preferredFieldName); + if (keepResolved) { + resolvedKeepFieldByPreferred.set(preferredFieldName, keepResolved); + } + } + + if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) { + sourceFields['SentenceFurigana'] = sourceFields['Sentence']; + } + if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) { + sourceFields['Sentence'] = sourceFields['SentenceFurigana']; + } + if (!sourceFields['Expression'] && sourceFields['Word']) { + sourceFields['Expression'] = sourceFields['Word']; + } + if (!sourceFields['Word'] && sourceFields['Expression']) { + sourceFields['Word'] = sourceFields['Expression']; + } + if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) { + sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio']; + } + if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) { + sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio']; + } + + if ( + config.fields?.sentence && + !sourceFields[config.fields?.sentence] && + this.deps.getCurrentSubtitleText() + ) { + const deleteFields = this.deps.extractFields(deleteNoteInfo.fields); + sourceFields[config.fields?.sentence] = this.deps.processSentence( + this.deps.getCurrentSubtitleText()!, + deleteFields, + ); + } + + if (includeGeneratedMedia) { + const media = await this.deps.generateMediaForMerge(); + if (media.audioField && media.audioValue && !sourceFields[media.audioField]) { + sourceFields[media.audioField] = media.audioValue; + } + if (media.imageField && media.imageValue && !sourceFields[media.imageField]) { + sourceFields[media.imageField] = media.imageValue; + } + if ( + config.fields?.miscInfo && + media.miscInfoValue && + !sourceFields[config.fields?.miscInfo] + ) { + sourceFields[config.fields?.miscInfo] = media.miscInfoValue; + } + } + + const mergedFields: Record = {}; + for (const preferredFieldName of groupableFields) { + const keepFieldName = resolvedKeepFieldByPreferred.get(preferredFieldName); + if (!keepFieldName) continue; + + const keepFieldNormalized = keepFieldName.toLowerCase(); + if ( + keepFieldNormalized === 'expression' || + keepFieldNormalized === 'expressionfurigana' || + keepFieldNormalized === 'expressionreading' || + keepFieldNormalized === 'expressionaudio' + ) { + continue; + } + + const existingValue = keepNoteInfo.fields[keepFieldName]?.value || ''; + const newValue = sourceFields[preferredFieldName] || ''; + const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName); + if (!existingValue.trim() && !newValue.trim()) continue; + + if (isStrictField) { + mergedFields[keepFieldName] = this.applyFieldGrouping( + existingValue, + newValue, + keepNoteId, + deleteNoteId, + keepFieldName, + ); + } else if (existingValue.trim() && newValue.trim()) { + mergedFields[keepFieldName] = this.applyFieldGrouping( + existingValue, + newValue, + keepNoteId, + deleteNoteId, + keepFieldName, + ); + } else { + if (!newValue.trim()) continue; + mergedFields[keepFieldName] = newValue; + } + } + + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const resolvedSentenceAudioField = this.deps.resolveFieldName( + keepFieldNames, + sentenceCardConfig.audioField || 'SentenceAudio', + ); + const resolvedExpressionAudioField = this.deps.resolveFieldName( + keepFieldNames, + config.fields?.audio || 'ExpressionAudio', + ); + if ( + resolvedSentenceAudioField && + resolvedExpressionAudioField && + resolvedExpressionAudioField !== resolvedSentenceAudioField + ) { + const mergedSentenceAudioValue = + mergedFields[resolvedSentenceAudioField] || + keepNoteInfo.fields[resolvedSentenceAudioField]?.value || + ''; + if (mergedSentenceAudioValue.trim()) { + mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue; + } + } + + return mergedFields; + } + + private getResolvedFieldValue( + noteInfo: FieldGroupingMergeNoteInfo, + preferredFieldName?: string, + ): string { + if (!preferredFieldName) return ''; + const resolved = this.deps.resolveNoteFieldName(noteInfo, preferredFieldName); + if (!resolved) return ''; + return noteInfo.fields[resolved]?.value || ''; + } + + private extractUngroupedValue(value: string): string { + const groupedSpanRegex = /[\s\S]*?<\/span>/gi; + const ungrouped = value.replace(groupedSpanRegex, '').trim(); + if (ungrouped) return ungrouped; + return value.trim(); + } + + private extractLastSoundTag(value: string): string { + const matches = value.match(/\[sound:[^\]]+\]/g); + if (!matches || matches.length === 0) return ''; + return matches[matches.length - 1]!; + } + + private extractLastImageTag(value: string): string { + const matches = value.match(/]*>/gi); + if (!matches || matches.length === 0) return ''; + return matches[matches.length - 1]!; + } + + private extractImageTags(value: string): string[] { + const matches = value.match(/]*>/gi); + return matches || []; + } + + private ensureImageGroupId(imageTag: string, groupId: number): string { + if (!imageTag) return ''; + if (/data-group-id=/i.test(imageTag)) { + return imageTag.replace(/data-group-id="[^"]*"/i, `data-group-id="${groupId}"`); + } + return imageTag.replace(/]*data-group-id="([^"]*)"[^>]*>/gi; + let malformed; + while ((malformed = malformedIdRegex.exec(value)) !== null) { + const rawId = malformed[1]; + const groupId = Number(rawId); + if (!Number.isFinite(groupId) || groupId <= 0) { + this.deps.warnFieldParseOnce(fieldName, 'invalid-group-id', rawId); + } + } + + const spanRegex = /]*>([\s\S]*?)<\/span>/gi; + let match; + while ((match = spanRegex.exec(value)) !== null) { + const groupId = Number(match[1]); + if (!Number.isFinite(groupId) || groupId <= 0) continue; + const content = this.normalizeStrictGroupedValue(match[2] || '', fieldName); + if (!content) { + this.deps.warnFieldParseOnce(fieldName, 'empty-group-content'); + continue; + } + entries.push({ groupId, content }); + } + if (entries.length === 0 && /(); + for (const entry of entries) { + const key = `${entry.groupId}::${entry.content}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(entry); + } + return unique; + } + + private parsePictureEntries( + value: string, + fallbackGroupId: number, + ): { groupId: number; tag: string }[] { + const tags = this.extractImageTags(value); + const result: { groupId: number; tag: string }[] = []; + for (const tag of tags) { + const idMatch = tag.match(/data-group-id="(\d+)"/i); + let groupId = fallbackGroupId; + if (idMatch) { + const parsed = Number(idMatch[1]); + if (!Number.isFinite(parsed) || parsed <= 0) { + this.deps.warnFieldParseOnce('Picture', 'invalid-group-id', idMatch[1]); + } else { + groupId = parsed; + } + } + const normalizedTag = this.ensureImageGroupId(tag, groupId); + if (!normalizedTag) { + this.deps.warnFieldParseOnce('Picture', 'empty-image-tag'); + continue; + } + result.push({ groupId, tag: normalizedTag }); + } + return result; + } + + private normalizeStrictGroupedValue(value: string, fieldName: string): string { + const ungrouped = this.extractUngroupedValue(value); + if (!ungrouped) return ''; + + const normalizedField = fieldName.toLowerCase(); + if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') { + const lastSoundTag = this.extractLastSoundTag(ungrouped); + if (!lastSoundTag) { + this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); + } + return lastSoundTag || ungrouped; + } + + if (normalizedField === 'picture') { + const lastImageTag = this.extractLastImageTag(ungrouped); + if (!lastImageTag) { + this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag'); + } + return lastImageTag || ungrouped; + } + + return ungrouped; + } + + private getStrictSpanGroupingFields(): Set { + const strictFields = new Set(this.strictGroupingFieldDefaults); + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + strictFields.add((sentenceCardConfig.sentenceField || 'sentence').toLowerCase()); + strictFields.add((sentenceCardConfig.audioField || 'sentenceaudio').toLowerCase()); + const config = this.deps.getConfig(); + if (config.fields?.image) strictFields.add(config.fields.image.toLowerCase()); + if (config.fields?.miscInfo) strictFields.add(config.fields.miscInfo.toLowerCase()); + return strictFields; + } + + private shouldUseStrictSpanGrouping(fieldName: string): boolean { + const normalized = fieldName.toLowerCase(); + return this.getStrictSpanGroupingFields().has(normalized); + } + + private applyFieldGrouping( + existingValue: string, + newValue: string, + keepGroupId: number, + sourceGroupId: number, + fieldName: string, + ): string { + if (this.shouldUseStrictSpanGrouping(fieldName)) { + if (fieldName.toLowerCase() === 'picture') { + const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); + const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId); + if (keepEntries.length === 0 && sourceEntries.length === 0) { + return existingValue || newValue; + } + const mergedTags = keepEntries.map((entry) => + this.ensureImageGroupId(entry.tag, entry.groupId), + ); + const seen = new Set(mergedTags); + for (const entry of sourceEntries) { + const normalized = this.ensureImageGroupId(entry.tag, entry.groupId); + if (seen.has(normalized)) continue; + seen.add(normalized); + mergedTags.push(normalized); + } + return mergedTags.join(''); + } + + const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName); + const sourceEntries = this.parseStrictEntries(newValue, sourceGroupId, fieldName); + if (keepEntries.length === 0 && sourceEntries.length === 0) { + return existingValue || newValue; + } + if (sourceEntries.length === 0) { + return keepEntries + .map((entry) => `${entry.content}`) + .join(''); + } + const merged = [...keepEntries]; + const seen = new Set(keepEntries.map((entry) => `${entry.groupId}::${entry.content}`)); + for (const entry of sourceEntries) { + const key = `${entry.groupId}::${entry.content}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(entry); + } + if (merged.length === 0) return existingValue; + return merged + .map((entry) => `${entry.content}`) + .join(''); + } + + if (!existingValue.trim()) return newValue; + if (!newValue.trim()) return existingValue; + + const hasGroups = /data-group-id/.test(existingValue); + + if (!hasGroups) { + return `${existingValue}\n` + newValue; + } + + const groupedSpanRegex = /[\s\S]*?<\/span>/g; + let lastEnd = 0; + let result = ''; + let match; + + while ((match = groupedSpanRegex.exec(existingValue)) !== null) { + const before = existingValue.slice(lastEnd, match.index); + if (before.trim()) { + result += `${before.trim()}\n`; + } + result += match[0] + '\n'; + lastEnd = match.index + match[0].length; + } + + const after = existingValue.slice(lastEnd); + if (after.trim()) { + result += `\n${after.trim()}`; + } + + return result + '\n' + newValue; + } +} diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts new file mode 100644 index 0000000..08abf07 --- /dev/null +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { FieldGroupingWorkflow } from './field-grouping-workflow'; + +type NoteInfo = { + noteId: number; + fields: Record; +}; + +function createWorkflowHarness() { + const updates: Array<{ noteId: number; fields: Record }> = []; + const deleted: number[][] = []; + const statuses: string[] = []; + + const deps = { + client: { + notesInfo: async (noteIds: number[]) => + noteIds.map( + (noteId) => + ({ + noteId, + fields: { + Expression: { value: `word-${noteId}` }, + Sentence: { value: `line-${noteId}` }, + }, + }) satisfies NoteInfo, + ), + updateNoteFields: async (noteId: number, fields: Record) => { + updates.push({ noteId, fields }); + }, + deleteNotes: async (noteIds: number[]) => { + deleted.push(noteIds); + }, + }, + getConfig: () => ({ + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + }, + isKiku: { + deleteDuplicateInAuto: true, + }, + }), + getEffectiveSentenceCardConfig: () => ({ + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + kikuDeleteDuplicateInAuto: true, + }), + getCurrentSubtitleText: () => 'subtitle-text', + getFieldGroupingCallback: () => null, + setFieldGroupingCallback: () => undefined, + computeFieldGroupingMergedFields: async () => ({ + Sentence: 'merged sentence', + }), + extractFields: (fields: Record) => { + const out: Record = {}; + for (const [key, value] of Object.entries(fields)) { + out[key.toLowerCase()] = value.value; + } + return out; + }, + hasFieldValue: (_noteInfo: NoteInfo, _field?: string) => false, + addConfiguredTagsToNote: async () => undefined, + removeTrackedNoteId: () => undefined, + showStatusNotification: (message: string) => { + statuses.push(message); + }, + showNotification: async () => undefined, + showOsdNotification: () => undefined, + logError: () => undefined, + logInfo: () => undefined, + truncateSentence: (value: string) => value, + }; + + return { + workflow: new FieldGroupingWorkflow(deps), + updates, + deleted, + statuses, + deps, + }; +} + +test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate by default', async () => { + const harness = createWorkflowHarness(); + + await harness.workflow.handleAuto(1, 2, { + noteId: 2, + fields: { + Expression: { value: 'word-2' }, + Sentence: { value: 'line-2' }, + }, + }); + + assert.equal(harness.updates.length, 1); + assert.equal(harness.updates[0]?.noteId, 1); + assert.deepEqual(harness.deleted, [[2]]); + assert.equal(harness.statuses.length, 1); +}); + +test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => { + const harness = createWorkflowHarness(); + + const handled = await harness.workflow.handleManual(1, 2, { + noteId: 2, + fields: { + Expression: { value: 'word-2' }, + Sentence: { value: 'line-2' }, + }, + }); + + assert.equal(handled, false); + assert.equal(harness.updates.length, 0); +}); diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts new file mode 100644 index 0000000..3576acd --- /dev/null +++ b/src/anki-integration/field-grouping-workflow.ts @@ -0,0 +1,214 @@ +import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types'; + +export interface FieldGroupingWorkflowNoteInfo { + noteId: number; + fields: Record; +} + +export interface FieldGroupingWorkflowDeps { + client: { + notesInfo(noteIds: number[]): Promise; + updateNoteFields(noteId: number, fields: Record): Promise; + deleteNotes(noteIds: number[]): Promise; + }; + getConfig: () => { + fields?: { + audio?: string; + image?: string; + }; + }; + getEffectiveSentenceCardConfig: () => { + sentenceField: string; + audioField: string; + kikuDeleteDuplicateInAuto: boolean; + }; + getCurrentSubtitleText: () => string | undefined; + getFieldGroupingCallback: + | (() => Promise< + | ((data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }) => Promise) + | null + >) + | (() => + | ((data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }) => Promise) + | null); + computeFieldGroupingMergedFields: ( + keepNoteId: number, + deleteNoteId: number, + keepNoteInfo: FieldGroupingWorkflowNoteInfo, + deleteNoteInfo: FieldGroupingWorkflowNoteInfo, + includeGeneratedMedia: boolean, + ) => Promise>; + extractFields: (fields: Record) => Record; + hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean; + addConfiguredTagsToNote: (noteId: number) => Promise; + removeTrackedNoteId: (noteId: number) => void; + showStatusNotification: (message: string) => void; + showNotification: (noteId: number, label: string | number) => Promise; + showOsdNotification: (message: string) => void; + logError: (message: string, ...args: unknown[]) => void; + logInfo: (message: string, ...args: unknown[]) => void; + truncateSentence: (sentence: string) => string; +} + +export class FieldGroupingWorkflow { + constructor(private readonly deps: FieldGroupingWorkflowDeps) {} + + async handleAuto( + originalNoteId: number, + newNoteId: number, + newNoteInfo: FieldGroupingWorkflowNoteInfo, + ): Promise { + try { + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + await this.performMerge( + originalNoteId, + newNoteId, + newNoteInfo, + this.getExpression(newNoteInfo), + sentenceCardConfig.kikuDeleteDuplicateInAuto, + ); + } catch (error) { + this.deps.logError('Field grouping auto merge failed:', (error as Error).message); + this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`); + } + } + + async handleManual( + originalNoteId: number, + newNoteId: number, + newNoteInfo: FieldGroupingWorkflowNoteInfo, + ): Promise { + const callback = await this.resolveFieldGroupingCallback(); + if (!callback) { + this.deps.showOsdNotification('Field grouping UI unavailable'); + return false; + } + + try { + const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]); + const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[]; + if (!originalNotesInfo || originalNotesInfo.length === 0) { + return false; + } + + const originalNoteInfo = originalNotesInfo[0]!; + const expression = this.getExpression(newNoteInfo) || this.getExpression(originalNoteInfo); + + const choice = await callback({ + original: this.buildDuplicateCardInfo(originalNoteInfo, expression, true), + duplicate: this.buildDuplicateCardInfo(newNoteInfo, expression, false), + }); + + if (choice.cancelled) { + this.deps.showOsdNotification('Field grouping cancelled'); + return false; + } + + const keepNoteId = choice.keepNoteId; + const deleteNoteId = choice.deleteNoteId; + const deleteNoteInfo = deleteNoteId === newNoteId ? newNoteInfo : originalNoteInfo; + + await this.performMerge( + keepNoteId, + deleteNoteId, + deleteNoteInfo, + expression, + choice.deleteDuplicate, + ); + return true; + } catch (error) { + this.deps.logError('Field grouping manual merge failed:', (error as Error).message); + this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`); + return false; + } + } + + private async performMerge( + keepNoteId: number, + deleteNoteId: number, + deleteNoteInfo: FieldGroupingWorkflowNoteInfo, + expression: string, + deleteDuplicate = true, + ): Promise { + const keepNotesInfoResult = await this.deps.client.notesInfo([keepNoteId]); + const keepNotesInfo = keepNotesInfoResult as FieldGroupingWorkflowNoteInfo[]; + if (!keepNotesInfo || keepNotesInfo.length === 0) { + this.deps.logInfo('Keep note not found:', keepNoteId); + return; + } + + const keepNoteInfo = keepNotesInfo[0]!; + const mergedFields = await this.deps.computeFieldGroupingMergedFields( + keepNoteId, + deleteNoteId, + keepNoteInfo, + deleteNoteInfo, + true, + ); + + if (Object.keys(mergedFields).length > 0) { + await this.deps.client.updateNoteFields(keepNoteId, mergedFields); + await this.deps.addConfiguredTagsToNote(keepNoteId); + } + + if (deleteDuplicate) { + await this.deps.client.deleteNotes([deleteNoteId]); + this.deps.removeTrackedNoteId(deleteNoteId); + } + + this.deps.logInfo('Merged duplicate card:', expression, 'into note:', keepNoteId); + this.deps.showStatusNotification( + deleteDuplicate + ? `Merged duplicate: ${expression}` + : `Grouped duplicate (kept both): ${expression}`, + ); + await this.deps.showNotification(keepNoteId, expression); + } + + private buildDuplicateCardInfo( + noteInfo: FieldGroupingWorkflowNoteInfo, + fallbackExpression: string, + isOriginal: boolean, + ): KikuDuplicateCardInfo { + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const fields = this.deps.extractFields(noteInfo.fields); + return { + noteId: noteInfo.noteId, + expression: fields.expression || fields.word || fallbackExpression, + sentencePreview: this.deps.truncateSentence( + fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] || + (isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''), + ), + hasAudio: + this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.audio) || + this.deps.hasFieldValue(noteInfo, sentenceCardConfig.audioField), + hasImage: this.deps.hasFieldValue(noteInfo, this.deps.getConfig().fields?.image), + isOriginal, + }; + } + + private getExpression(noteInfo: FieldGroupingWorkflowNoteInfo): string { + const fields = this.deps.extractFields(noteInfo.fields); + return fields.expression || fields.word || ''; + } + + private async resolveFieldGroupingCallback(): Promise< + | ((data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }) => Promise) + | null + > { + const callback = this.deps.getFieldGroupingCallback(); + if (callback instanceof Promise) { + return callback; + } + return callback; + } +} diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts new file mode 100644 index 0000000..becb2f2 --- /dev/null +++ b/src/anki-integration/field-grouping.ts @@ -0,0 +1,236 @@ +import { KikuMergePreviewResponse } from '../types'; +import { createLogger } from '../logger'; + +const log = createLogger('anki').child('integration.field-grouping'); + +interface FieldGroupingNoteInfo { + noteId: number; + fields: Record; +} + +interface FieldGroupingDeps { + getEffectiveSentenceCardConfig: () => { + model?: string; + sentenceField: string; + audioField: string; + lapisEnabled: boolean; + kikuEnabled: boolean; + kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; + kikuDeleteDuplicateInAuto: boolean; + }; + isUpdateInProgress: () => boolean; + getDeck?: () => string | undefined; + withUpdateProgress: (initialMessage: string, action: () => Promise) => Promise; + showOsdNotification: (text: string) => void; + findNotes: ( + query: string, + options?: { + maxRetries?: number; + }, + ) => Promise; + notesInfo: (noteIds: number[]) => Promise; + extractFields: (fields: Record) => Record; + findDuplicateNote: ( + expression: string, + excludeNoteId: number, + noteInfo: FieldGroupingNoteInfo, + ) => Promise; + hasAllConfiguredFields: ( + noteInfo: FieldGroupingNoteInfo, + configuredFieldNames: (string | undefined)[], + ) => boolean; + processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise; + getSentenceCardImageFieldName: () => string | undefined; + resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null; + computeFieldGroupingMergedFields: ( + keepNoteId: number, + deleteNoteId: number, + keepNoteInfo: FieldGroupingNoteInfo, + deleteNoteInfo: FieldGroupingNoteInfo, + includeGeneratedMedia: boolean, + ) => Promise>; + getNoteFieldMap: (noteInfo: FieldGroupingNoteInfo) => Record; + handleFieldGroupingAuto: ( + originalNoteId: number, + newNoteId: number, + newNoteInfo: FieldGroupingNoteInfo, + expression: string, + ) => Promise; + handleFieldGroupingManual: ( + originalNoteId: number, + newNoteId: number, + newNoteInfo: FieldGroupingNoteInfo, + expression: string, + ) => Promise; +} + +export class FieldGroupingService { + constructor(private readonly deps: FieldGroupingDeps) {} + + async triggerFieldGroupingForLastAddedCard(): Promise { + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + if (!sentenceCardConfig.kikuEnabled) { + this.deps.showOsdNotification('Kiku mode is not enabled'); + return; + } + if (sentenceCardConfig.kikuFieldGrouping === 'disabled') { + this.deps.showOsdNotification('Kiku field grouping is disabled'); + return; + } + + if (this.deps.isUpdateInProgress()) { + this.deps.showOsdNotification('Anki update already in progress'); + return; + } + + try { + await this.deps.withUpdateProgress('Grouping duplicate cards', async () => { + const deck = this.deps.getDeck ? this.deps.getDeck() : undefined; + const query = deck ? `"deck:${deck}" added:1` : 'added:1'; + const noteIds = await this.deps.findNotes(query); + if (!noteIds || noteIds.length === 0) { + this.deps.showOsdNotification('No recently added cards found'); + return; + } + + const noteId = Math.max(...noteIds); + const notesInfoResult = await this.deps.notesInfo([noteId]); + const notesInfo = notesInfoResult as FieldGroupingNoteInfo[]; + if (!notesInfo || notesInfo.length === 0) { + this.deps.showOsdNotification('Card not found'); + return; + } + const noteInfoBeforeUpdate = notesInfo[0]!; + const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields); + const expressionText = fields.expression || fields.word || ''; + if (!expressionText) { + this.deps.showOsdNotification('No expression/word field found'); + return; + } + + const duplicateNoteId = await this.deps.findDuplicateNote( + expressionText, + noteId, + noteInfoBeforeUpdate, + ); + if (duplicateNoteId === null) { + this.deps.showOsdNotification('No duplicate card found'); + return; + } + + if ( + !this.deps.hasAllConfiguredFields(noteInfoBeforeUpdate, [ + this.deps.getSentenceCardImageFieldName(), + ]) + ) { + await this.deps.processNewCard(noteId, { + skipKikuFieldGrouping: true, + }); + } + + const refreshedInfoResult = await this.deps.notesInfo([noteId]); + const refreshedInfo = refreshedInfoResult as FieldGroupingNoteInfo[]; + if (!refreshedInfo || refreshedInfo.length === 0) { + this.deps.showOsdNotification('Card not found'); + return; + } + + const noteInfo = refreshedInfo[0]!; + + if (sentenceCardConfig.kikuFieldGrouping === 'auto') { + await this.deps.handleFieldGroupingAuto( + duplicateNoteId, + noteId, + noteInfo, + expressionText, + ); + return; + } + const handled = await this.deps.handleFieldGroupingManual( + duplicateNoteId, + noteId, + noteInfo, + expressionText, + ); + if (!handled) { + this.deps.showOsdNotification('Field grouping cancelled'); + } + }); + } catch (error) { + log.error('Error triggering field grouping:', (error as Error).message); + this.deps.showOsdNotification(`Field grouping failed: ${(error as Error).message}`); + } + } + + async buildFieldGroupingPreview( + keepNoteId: number, + deleteNoteId: number, + deleteDuplicate: boolean, + ): Promise { + try { + const notesInfoResult = await this.deps.notesInfo([keepNoteId, deleteNoteId]); + const notesInfo = notesInfoResult as FieldGroupingNoteInfo[]; + const keepNoteInfo = notesInfo.find((note) => note.noteId === keepNoteId); + const deleteNoteInfo = notesInfo.find((note) => note.noteId === deleteNoteId); + + if (!keepNoteInfo || !deleteNoteInfo) { + return { ok: false, error: 'Could not load selected notes' }; + } + + const mergedFields = await this.deps.computeFieldGroupingMergedFields( + keepNoteId, + deleteNoteId, + keepNoteInfo, + deleteNoteInfo, + false, + ); + const keepBefore = this.deps.getNoteFieldMap(keepNoteInfo); + const keepAfter = { ...keepBefore, ...mergedFields }; + const sourceBefore = this.deps.getNoteFieldMap(deleteNoteInfo); + + const compactFields: Record = {}; + for (const fieldName of [ + 'Sentence', + 'SentenceFurigana', + 'SentenceAudio', + 'Picture', + 'MiscInfo', + ]) { + const resolved = this.deps.resolveFieldName(Object.keys(keepAfter), fieldName); + if (!resolved) continue; + compactFields[fieldName] = keepAfter[resolved] || ''; + } + + return { + ok: true, + compact: { + action: { + keepNoteId, + deleteNoteId, + deleteDuplicate, + }, + mergedFields: compactFields, + }, + full: { + keepNote: { + id: keepNoteId, + fieldsBefore: keepBefore, + }, + sourceNote: { + id: deleteNoteId, + fieldsBefore: sourceBefore, + }, + result: { + fieldsAfter: keepAfter, + wouldDeleteNoteId: deleteDuplicate ? deleteNoteId : null, + }, + }, + }; + } catch (error) { + return { + ok: false, + error: `Failed to build preview: ${(error as Error).message}`, + }; + } + } +} diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts new file mode 100644 index 0000000..b693fb8 --- /dev/null +++ b/src/anki-integration/known-word-cache.ts @@ -0,0 +1,388 @@ +import fs from 'fs'; +import path from 'path'; + +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; +import { AnkiConnectConfig } from '../types'; +import { createLogger } from '../logger'; + +const log = createLogger('anki').child('integration.known-word-cache'); + +export interface KnownWordCacheNoteInfo { + noteId: number; + fields: Record; +} + +interface KnownWordCacheState { + readonly version: 1; + readonly refreshedAtMs: number; + readonly scope: string; + readonly words: string[]; +} + +interface KnownWordCacheClient { + findNotes: ( + query: string, + options?: { + maxRetries?: number; + }, + ) => Promise; + notesInfo: (noteIds: number[]) => Promise; +} + +interface KnownWordCacheDeps { + client: KnownWordCacheClient; + getConfig: () => AnkiConnectConfig; + knownWordCacheStatePath?: string; + showStatusNotification: (message: string) => void; +} + +export class KnownWordCacheManager { + private knownWordsLastRefreshedAtMs = 0; + private knownWordsScope = ''; + private knownWords: Set = new Set(); + private knownWordsRefreshTimer: ReturnType | null = null; + private isRefreshingKnownWords = false; + private readonly statePath: string; + + constructor(private readonly deps: KnownWordCacheDeps) { + this.statePath = path.normalize( + deps.knownWordCacheStatePath || path.join(process.cwd(), 'known-words-cache.json'), + ); + } + + isKnownWord(text: string): boolean { + if (!this.isKnownWordCacheEnabled()) { + return false; + } + + const normalized = this.normalizeKnownWordForLookup(text); + return normalized.length > 0 ? this.knownWords.has(normalized) : false; + } + + refresh(force = false): Promise { + return this.refreshKnownWords(force); + } + + startLifecycle(): void { + this.stopLifecycle(); + if (!this.isKnownWordCacheEnabled()) { + log.info('Known-word cache disabled; clearing local cache state'); + this.clearKnownWordCacheState(); + return; + } + + const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000; + const scope = this.getKnownWordCacheScope(); + log.info( + 'Known-word cache lifecycle enabled', + `scope=${scope}`, + `refreshMinutes=${refreshMinutes}`, + `cachePath=${this.statePath}`, + ); + + this.loadKnownWordCacheState(); + void this.refreshKnownWords(); + const refreshIntervalMs = this.getKnownWordRefreshIntervalMs(); + this.knownWordsRefreshTimer = setInterval(() => { + void this.refreshKnownWords(); + }, refreshIntervalMs); + } + + stopLifecycle(): void { + if (this.knownWordsRefreshTimer) { + clearInterval(this.knownWordsRefreshTimer); + this.knownWordsRefreshTimer = null; + } + } + + appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void { + if (!this.isKnownWordCacheEnabled()) { + return; + } + + const currentScope = this.getKnownWordCacheScope(); + if (this.knownWordsScope && this.knownWordsScope !== currentScope) { + this.clearKnownWordCacheState(); + } + if (!this.knownWordsScope) { + this.knownWordsScope = currentScope; + } + + let addedCount = 0; + for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) { + const normalized = this.normalizeKnownWordForLookup(rawWord); + if (!normalized || this.knownWords.has(normalized)) { + continue; + } + this.knownWords.add(normalized); + addedCount += 1; + } + + if (addedCount > 0) { + if (this.knownWordsLastRefreshedAtMs <= 0) { + this.knownWordsLastRefreshedAtMs = Date.now(); + } + this.persistKnownWordCacheState(); + log.info( + 'Known-word cache updated in-session', + `added=${addedCount}`, + `scope=${currentScope}`, + ); + } + } + + clearKnownWordCacheState(): void { + this.knownWords = new Set(); + this.knownWordsLastRefreshedAtMs = 0; + this.knownWordsScope = this.getKnownWordCacheScope(); + try { + if (fs.existsSync(this.statePath)) { + fs.unlinkSync(this.statePath); + } + } catch (error) { + log.warn('Failed to clear known-word cache state:', (error as Error).message); + } + } + + private async refreshKnownWords(force = false): Promise { + if (!this.isKnownWordCacheEnabled()) { + log.debug('Known-word cache refresh skipped; feature disabled'); + return; + } + if (this.isRefreshingKnownWords) { + log.debug('Known-word cache refresh skipped; already refreshing'); + return; + } + if (!force && !this.isKnownWordCacheStale()) { + log.debug('Known-word cache refresh skipped; cache is fresh'); + return; + } + + this.isRefreshingKnownWords = true; + try { + const query = this.buildKnownWordsQuery(); + log.debug('Refreshing known-word cache', `query=${query}`); + const noteIds = (await this.deps.client.findNotes(query, { + maxRetries: 0, + })) as number[]; + + const nextKnownWords = new Set(); + if (noteIds.length > 0) { + const chunkSize = 50; + for (let i = 0; i < noteIds.length; i += chunkSize) { + const chunk = noteIds.slice(i, i + chunkSize); + const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[]; + const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[]; + + for (const noteInfo of notesInfo) { + for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) { + const normalized = this.normalizeKnownWordForLookup(word); + if (normalized) { + nextKnownWords.add(normalized); + } + } + } + } + } + + this.knownWords = nextKnownWords; + this.knownWordsLastRefreshedAtMs = Date.now(); + this.knownWordsScope = this.getKnownWordCacheScope(); + this.persistKnownWordCacheState(); + log.info( + 'Known-word cache refreshed', + `noteCount=${noteIds.length}`, + `wordCount=${nextKnownWords.size}`, + ); + } catch (error) { + log.warn('Failed to refresh known-word cache:', (error as Error).message); + this.deps.showStatusNotification('AnkiConnect: unable to refresh known words'); + } finally { + this.isRefreshingKnownWords = false; + } + } + + private isKnownWordCacheEnabled(): boolean { + return this.deps.getConfig().nPlusOne?.highlightEnabled === true; + } + + private getKnownWordRefreshIntervalMs(): number { + const minutes = this.deps.getConfig().nPlusOne?.refreshMinutes; + const safeMinutes = + typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.refreshMinutes; + return safeMinutes * 60_000; + } + + private getKnownWordDecks(): string[] { + const configuredDecks = this.deps.getConfig().nPlusOne?.decks; + if (Array.isArray(configuredDecks)) { + const decks = configuredDecks + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return [...new Set(decks)]; + } + + const deck = this.deps.getConfig().deck?.trim(); + return deck ? [deck] : []; + } + + private buildKnownWordsQuery(): string { + const decks = this.getKnownWordDecks(); + if (decks.length === 0) { + return 'is:note'; + } + + if (decks.length === 1) { + return `deck:"${escapeAnkiSearchValue(decks[0]!)}"`; + } + + const deckQueries = decks.map((deck) => `deck:"${escapeAnkiSearchValue(deck)}"`); + return `(${deckQueries.join(' OR ')})`; + } + + private getKnownWordCacheScope(): string { + const decks = this.getKnownWordDecks(); + if (decks.length === 0) { + return 'is:note'; + } + return `decks:${JSON.stringify(decks)}`; + } + + private isKnownWordCacheStale(): boolean { + if (!this.isKnownWordCacheEnabled()) { + return true; + } + if (this.knownWordsScope !== this.getKnownWordCacheScope()) { + return true; + } + if (this.knownWordsLastRefreshedAtMs <= 0) { + return true; + } + return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs(); + } + + private loadKnownWordCacheState(): void { + try { + if (!fs.existsSync(this.statePath)) { + this.knownWords = new Set(); + this.knownWordsLastRefreshedAtMs = 0; + this.knownWordsScope = this.getKnownWordCacheScope(); + return; + } + + const raw = fs.readFileSync(this.statePath, 'utf-8'); + if (!raw.trim()) { + this.knownWords = new Set(); + this.knownWordsLastRefreshedAtMs = 0; + this.knownWordsScope = this.getKnownWordCacheScope(); + return; + } + + const parsed = JSON.parse(raw) as unknown; + if (!this.isKnownWordCacheStateValid(parsed)) { + this.knownWords = new Set(); + this.knownWordsLastRefreshedAtMs = 0; + this.knownWordsScope = this.getKnownWordCacheScope(); + return; + } + + if (parsed.scope !== this.getKnownWordCacheScope()) { + this.knownWords = new Set(); + this.knownWordsLastRefreshedAtMs = 0; + this.knownWordsScope = this.getKnownWordCacheScope(); + return; + } + + const nextKnownWords = new Set(); + for (const value of parsed.words) { + const normalized = this.normalizeKnownWordForLookup(value); + if (normalized) { + nextKnownWords.add(normalized); + } + } + + this.knownWords = nextKnownWords; + this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs; + this.knownWordsScope = parsed.scope; + } catch (error) { + log.warn('Failed to load known-word cache state:', (error as Error).message); + this.knownWords = new Set(); + this.knownWordsLastRefreshedAtMs = 0; + this.knownWordsScope = this.getKnownWordCacheScope(); + } + } + + private persistKnownWordCacheState(): void { + try { + const state: KnownWordCacheState = { + version: 1, + refreshedAtMs: this.knownWordsLastRefreshedAtMs, + scope: this.knownWordsScope, + words: Array.from(this.knownWords), + }; + fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8'); + } catch (error) { + log.warn('Failed to persist known-word cache state:', (error as Error).message); + } + } + + private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + if (candidate.version !== 1) return false; + if (typeof candidate.refreshedAtMs !== 'number') return false; + if (typeof candidate.scope !== 'string') return false; + if (!Array.isArray(candidate.words)) return false; + if (!candidate.words.every((entry) => typeof entry === 'string')) { + return false; + } + return true; + } + + private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] { + const words: string[] = []; + const preferredFields = ['Expression', 'Word']; + for (const preferredField of preferredFields) { + const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField); + if (!fieldName) continue; + + const raw = noteInfo.fields[fieldName]?.value; + if (!raw) continue; + + const extracted = this.normalizeRawKnownWordValue(raw); + if (extracted) { + words.push(extracted); + } + } + return words; + } + + private normalizeRawKnownWordValue(value: string): string { + return value + .replace(/<[^>]*>/g, '') + .replace(/\u3000/g, ' ') + .trim(); + } + + private normalizeKnownWordForLookup(value: string): string { + return this.normalizeRawKnownWordValue(value).toLowerCase(); + } +} + +function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { + const exact = availableFieldNames.find((name) => name === preferredName); + if (exact) return exact; + + const lower = preferredName.toLowerCase(); + return availableFieldNames.find((name) => name.toLowerCase() === lower) || null; +} + +function escapeAnkiSearchValue(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\"/g, '\\"') + .replace(/([:*?()\[\]{}])/g, '\\$1'); +} diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts new file mode 100644 index 0000000..c953ef5 --- /dev/null +++ b/src/anki-integration/note-update-workflow.test.ts @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + NoteUpdateWorkflow, + type NoteUpdateWorkflowDeps, + type NoteUpdateWorkflowNoteInfo, +} from './note-update-workflow'; + +function createWorkflowHarness() { + const updates: Array<{ noteId: number; fields: Record }> = []; + const notifications: Array<{ noteId: number; label: string | number }> = []; + const warnings: string[] = []; + + const deps: NoteUpdateWorkflowDeps = { + client: { + notesInfo: async (_noteIds: number[]) => + [ + { + noteId: 42, + fields: { + Expression: { value: 'taberu' }, + Sentence: { value: '' }, + }, + }, + ] satisfies NoteUpdateWorkflowNoteInfo[], + updateNoteFields: async (noteId: number, fields: Record) => { + updates.push({ noteId, fields }); + }, + storeMediaFile: async () => undefined, + }, + getConfig: () => ({ + fields: { + sentence: 'Sentence', + }, + media: {}, + behavior: {}, + }), + getCurrentSubtitleText: () => 'subtitle-text', + getCurrentSubtitleStart: () => 12.3, + getEffectiveSentenceCardConfig: () => ({ + sentenceField: 'Sentence', + kikuEnabled: false, + kikuFieldGrouping: 'disabled' as const, + }), + appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined, + extractFields: (fields: Record) => { + const out: Record = {}; + for (const [key, value] of Object.entries(fields)) { + out[key.toLowerCase()] = value.value; + } + return out; + }, + findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null, + handleFieldGroupingAuto: async ( + _originalNoteId, + _newNoteId, + _newNoteInfo, + _expression, + ) => undefined, + handleFieldGroupingManual: async ( + _originalNoteId, + _newNoteId, + _newNoteInfo, + _expression, + ) => false, + processSentence: (text: string, _noteFields: Record) => text, + resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => { + if (!preferred) return null; + const names = Object.keys(noteInfo.fields); + return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null; + }, + getResolvedSentenceAudioFieldName: () => null, + mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next, + generateAudioFilename: () => 'audio_1.mp3', + generateAudio: async () => null, + generateImageFilename: () => 'image_1.jpg', + generateImage: async () => null, + formatMiscInfoPattern: () => '', + addConfiguredTagsToNote: async () => undefined, + showNotification: async (noteId: number, label: string | number) => { + notifications.push({ noteId, label }); + }, + showOsdNotification: (_text: string) => undefined, + beginUpdateProgress: (_text: string) => undefined, + endUpdateProgress: () => undefined, + logWarn: (message: string, ..._args: unknown[]) => warnings.push(message), + logInfo: (_message: string) => undefined, + logError: (_message: string) => undefined, + }; + + return { + workflow: new NoteUpdateWorkflow(deps), + updates, + notifications, + warnings, + deps, + }; +} + +test('NoteUpdateWorkflow updates sentence field and emits notification', async () => { + const harness = createWorkflowHarness(); + + await harness.workflow.execute(42); + + assert.equal(harness.updates.length, 1); + assert.equal(harness.updates[0]?.noteId, 42); + assert.equal(harness.updates[0]?.fields.Sentence, 'subtitle-text'); + assert.equal(harness.notifications.length, 1); +}); + +test('NoteUpdateWorkflow no-ops when note info is missing', async () => { + const harness = createWorkflowHarness(); + harness.deps.client.notesInfo = async () => []; + + await harness.workflow.execute(777); + + assert.equal(harness.updates.length, 0); + assert.equal(harness.notifications.length, 0); + assert.equal(harness.warnings.length, 1); +}); + +test('NoteUpdateWorkflow updates note before auto field grouping merge', async () => { + const harness = createWorkflowHarness(); + const callOrder: string[] = []; + let notesInfoCallCount = 0; + harness.deps.getEffectiveSentenceCardConfig = () => ({ + sentenceField: 'Sentence', + kikuEnabled: true, + kikuFieldGrouping: 'auto', + }); + harness.deps.findDuplicateNote = async () => 99; + harness.deps.client.notesInfo = async () => { + notesInfoCallCount += 1; + if (notesInfoCallCount === 1) { + return [ + { + noteId: 42, + fields: { + Expression: { value: 'taberu' }, + Sentence: { value: '' }, + }, + }, + ] satisfies NoteUpdateWorkflowNoteInfo[]; + } + return [ + { + noteId: 42, + fields: { + Expression: { value: 'taberu' }, + Sentence: { value: 'subtitle-text' }, + }, + }, + ] satisfies NoteUpdateWorkflowNoteInfo[]; + }; + harness.deps.client.updateNoteFields = async (noteId, fields) => { + callOrder.push('update'); + harness.updates.push({ noteId, fields }); + }; + harness.deps.handleFieldGroupingAuto = async ( + _originalNoteId, + _newNoteId, + newNoteInfo, + _expression, + ) => { + callOrder.push('auto'); + assert.equal(newNoteInfo.fields.Sentence?.value, 'subtitle-text'); + }; + + await harness.workflow.execute(42); + + assert.deepEqual(callOrder, ['update', 'auto']); + assert.equal(harness.updates.length, 1); +}); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts new file mode 100644 index 0000000..2ffc761 --- /dev/null +++ b/src/anki-integration/note-update-workflow.ts @@ -0,0 +1,242 @@ +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; + +export interface NoteUpdateWorkflowNoteInfo { + noteId: number; + fields: Record; +} + +export interface NoteUpdateWorkflowDeps { + client: { + notesInfo(noteIds: number[]): Promise; + updateNoteFields(noteId: number, fields: Record): Promise; + storeMediaFile(filename: string, data: Buffer): Promise; + }; + getConfig: () => { + fields?: { + sentence?: string; + image?: string; + miscInfo?: string; + }; + media?: { + generateAudio?: boolean; + generateImage?: boolean; + }; + behavior?: { + overwriteAudio?: boolean; + overwriteImage?: boolean; + }; + }; + getCurrentSubtitleText: () => string | undefined; + getCurrentSubtitleStart: () => number | undefined; + getEffectiveSentenceCardConfig: () => { + sentenceField: string; + kikuEnabled: boolean; + kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; + }; + appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void; + extractFields: (fields: Record) => Record; + findDuplicateNote: ( + expression: string, + excludeNoteId: number, + noteInfo: NoteUpdateWorkflowNoteInfo, + ) => Promise; + handleFieldGroupingAuto: ( + originalNoteId: number, + newNoteId: number, + newNoteInfo: NoteUpdateWorkflowNoteInfo, + expression: string, + ) => Promise; + handleFieldGroupingManual: ( + originalNoteId: number, + newNoteId: number, + newNoteInfo: NoteUpdateWorkflowNoteInfo, + expression: string, + ) => Promise; + processSentence: (mpvSentence: string, noteFields: Record) => string; + resolveConfiguredFieldName: ( + noteInfo: NoteUpdateWorkflowNoteInfo, + ...preferredNames: (string | undefined)[] + ) => string | null; + getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null; + mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; + generateAudioFilename: () => string; + generateAudio: () => Promise; + generateImageFilename: () => string; + generateImage: () => Promise; + formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + addConfiguredTagsToNote: (noteId: number) => Promise; + showNotification: (noteId: number, label: string | number) => Promise; + showOsdNotification: (message: string) => void; + beginUpdateProgress: (initialMessage: string) => void; + endUpdateProgress: () => void; + logWarn: (message: string, ...args: unknown[]) => void; + logInfo: (message: string, ...args: unknown[]) => void; + logError: (message: string, ...args: unknown[]) => void; +} + +export class NoteUpdateWorkflow { + constructor(private readonly deps: NoteUpdateWorkflowDeps) {} + + async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise { + this.deps.beginUpdateProgress('Updating card'); + try { + const notesInfoResult = await this.deps.client.notesInfo([noteId]); + const notesInfo = notesInfoResult as NoteUpdateWorkflowNoteInfo[]; + if (!notesInfo || notesInfo.length === 0) { + this.deps.logWarn('Card not found:', noteId); + return; + } + + const noteInfo = notesInfo[0]!; + this.deps.appendKnownWordsFromNoteInfo(noteInfo); + const fields = this.deps.extractFields(noteInfo.fields); + + const expressionText = fields.expression || fields.word || ''; + if (!expressionText) { + this.deps.logWarn('No expression/word field found in card:', noteId); + return; + } + + const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); + const shouldRunFieldGrouping = + !options?.skipKikuFieldGrouping && + sentenceCardConfig.kikuEnabled && + sentenceCardConfig.kikuFieldGrouping !== 'disabled'; + let duplicateNoteId: number | null = null; + if (shouldRunFieldGrouping) { + duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); + } + + const updatedFields: Record = {}; + let updatePerformed = false; + let miscInfoFilename: string | null = null; + const sentenceField = sentenceCardConfig.sentenceField; + + const currentSubtitleText = this.deps.getCurrentSubtitleText(); + if (sentenceField && currentSubtitleText) { + const processedSentence = this.deps.processSentence(currentSubtitleText, fields); + updatedFields[sentenceField] = processedSentence; + updatePerformed = true; + } + + const config = this.deps.getConfig(); + + if (config.media?.generateAudio) { + try { + const audioFilename = this.deps.generateAudioFilename(); + const audioBuffer = await this.deps.generateAudio(); + + if (audioBuffer) { + await this.deps.client.storeMediaFile(audioFilename, audioBuffer); + const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo); + if (sentenceAudioField) { + const existingAudio = noteInfo.fields[sentenceAudioField]?.value || ''; + updatedFields[sentenceAudioField] = this.deps.mergeFieldValue( + existingAudio, + `[sound:${audioFilename}]`, + config.behavior?.overwriteAudio !== false, + ); + } + miscInfoFilename = audioFilename; + updatePerformed = true; + } + } catch (error) { + this.deps.logError('Failed to generate audio:', (error as Error).message); + this.deps.showOsdNotification(`Audio generation failed: ${(error as Error).message}`); + } + } + + if (config.media?.generateImage) { + try { + const imageFilename = this.deps.generateImageFilename(); + const imageBuffer = await this.deps.generateImage(); + + if (imageBuffer) { + await this.deps.client.storeMediaFile(imageFilename, imageBuffer); + const imageFieldName = this.deps.resolveConfiguredFieldName( + noteInfo, + config.fields?.image, + DEFAULT_ANKI_CONNECT_CONFIG.fields.image, + ); + if (!imageFieldName) { + this.deps.logWarn('Image field not found on note, skipping image update'); + } else { + const existingImage = noteInfo.fields[imageFieldName]?.value || ''; + updatedFields[imageFieldName] = this.deps.mergeFieldValue( + existingImage, + ``, + config.behavior?.overwriteImage !== false, + ); + miscInfoFilename = imageFilename; + updatePerformed = true; + } + } + } catch (error) { + this.deps.logError('Failed to generate image:', (error as Error).message); + this.deps.showOsdNotification(`Image generation failed: ${(error as Error).message}`); + } + } + + if (config.fields?.miscInfo) { + const miscInfo = this.deps.formatMiscInfoPattern( + miscInfoFilename || '', + this.deps.getCurrentSubtitleStart(), + ); + const miscInfoField = this.deps.resolveConfiguredFieldName( + noteInfo, + config.fields?.miscInfo, + ); + if (miscInfo && miscInfoField) { + updatedFields[miscInfoField] = miscInfo; + updatePerformed = true; + } + } + + if (updatePerformed) { + await this.deps.client.updateNoteFields(noteId, updatedFields); + await this.deps.addConfiguredTagsToNote(noteId); + this.deps.logInfo('Updated card fields for:', expressionText); + await this.deps.showNotification(noteId, expressionText); + } + + if (shouldRunFieldGrouping && duplicateNoteId !== null) { + let noteInfoForGrouping = noteInfo; + if (updatePerformed) { + const refreshedInfoResult = await this.deps.client.notesInfo([noteId]); + const refreshedInfo = refreshedInfoResult as NoteUpdateWorkflowNoteInfo[]; + if (!refreshedInfo || refreshedInfo.length === 0) { + this.deps.logWarn('Card not found after update:', noteId); + return; + } + noteInfoForGrouping = refreshedInfo[0]!; + } + + if (sentenceCardConfig.kikuFieldGrouping === 'auto') { + await this.deps.handleFieldGroupingAuto( + duplicateNoteId, + noteId, + noteInfoForGrouping, + expressionText, + ); + return; + } + if (sentenceCardConfig.kikuFieldGrouping === 'manual') { + await this.deps.handleFieldGroupingManual( + duplicateNoteId, + noteId, + noteInfoForGrouping, + expressionText, + ); + } + } + } catch (error) { + if ((error as Error).message.includes('note was not found')) { + this.deps.logWarn('Card was deleted before update:', noteId); + } else { + this.deps.logError('Error processing new card:', (error as Error).message); + } + } finally { + this.deps.endUpdateProgress(); + } + } +} diff --git a/src/anki-integration/polling.ts b/src/anki-integration/polling.ts new file mode 100644 index 0000000..372b40a --- /dev/null +++ b/src/anki-integration/polling.ts @@ -0,0 +1,119 @@ +export interface PollingRunnerDeps { + getDeck: () => string | undefined; + getPollingRate: () => number; + findNotes: ( + query: string, + options?: { + maxRetries?: number; + }, + ) => Promise; + shouldAutoUpdateNewCards: () => boolean; + processNewCard: (noteId: number) => Promise; + isUpdateInProgress: () => boolean; + setUpdateInProgress: (value: boolean) => void; + getTrackedNoteIds: () => Set; + setTrackedNoteIds: (noteIds: Set) => void; + showStatusNotification: (message: string) => void; + logDebug: (...args: unknown[]) => void; + logInfo: (...args: unknown[]) => void; + logWarn: (...args: unknown[]) => void; +} + +export class PollingRunner { + private pollingInterval: ReturnType | null = null; + private initialized = false; + private backoffMs = 200; + private maxBackoffMs = 5000; + private nextPollTime = 0; + + constructor(private readonly deps: PollingRunnerDeps) {} + + get isRunning(): boolean { + return this.pollingInterval !== null; + } + + start(): void { + if (this.pollingInterval) { + this.stop(); + } + + void this.pollOnce(); + this.pollingInterval = setInterval(() => { + void this.pollOnce(); + }, this.deps.getPollingRate()); + } + + stop(): void { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + } + + async pollOnce(): Promise { + if (this.deps.isUpdateInProgress()) return; + if (Date.now() < this.nextPollTime) return; + + this.deps.setUpdateInProgress(true); + try { + const query = this.deps.getDeck() ? `"deck:${this.deps.getDeck()}" added:1` : 'added:1'; + const noteIds = await this.deps.findNotes(query, { + maxRetries: 0, + }); + const currentNoteIds = new Set(noteIds); + + const previousNoteIds = this.deps.getTrackedNoteIds(); + if (!this.initialized) { + this.deps.setTrackedNoteIds(currentNoteIds); + this.initialized = true; + this.deps.logInfo(`AnkiConnect initialized with ${currentNoteIds.size} existing cards`); + this.backoffMs = 200; + return; + } + + const newNoteIds = Array.from(currentNoteIds).filter((id) => !previousNoteIds.has(id)); + + if (newNoteIds.length > 0) { + this.deps.logInfo('Found new cards:', newNoteIds); + + for (const noteId of newNoteIds) { + previousNoteIds.add(noteId); + } + this.deps.setTrackedNoteIds(previousNoteIds); + + if (this.deps.shouldAutoUpdateNewCards()) { + for (const noteId of newNoteIds) { + await this.deps.processNewCard(noteId); + } + } else { + this.deps.logInfo( + 'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.', + ); + } + } + + if (this.backoffMs > 200) { + this.deps.logInfo('AnkiConnect connection restored'); + } + this.backoffMs = 200; + } catch (error) { + const wasBackingOff = this.backoffMs > 200; + this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs); + this.nextPollTime = Date.now() + this.backoffMs; + if (!wasBackingOff) { + this.deps.logWarn('AnkiConnect polling failed, backing off...'); + this.deps.showStatusNotification('AnkiConnect: unable to connect'); + } + this.deps.logWarn((error as Error).message); + } finally { + this.deps.setUpdateInProgress(false); + } + } + + async poll(): Promise { + if (this.pollingInterval) { + return; + } + return this.pollOnce(); + } +} diff --git a/src/anki-integration/ui-feedback.ts b/src/anki-integration/ui-feedback.ts new file mode 100644 index 0000000..09844d7 --- /dev/null +++ b/src/anki-integration/ui-feedback.ts @@ -0,0 +1,104 @@ +import { NotificationOptions } from '../types'; + +export interface UiFeedbackState { + progressDepth: number; + progressTimer: ReturnType | null; + progressMessage: string; + progressFrame: number; +} + +export interface UiFeedbackNotificationContext { + getNotificationType: () => string | undefined; + showOsd: (text: string) => void; + showSystemNotification: (title: string, options: NotificationOptions) => void; +} + +export interface UiFeedbackOptions { + setUpdateInProgress: (value: boolean) => void; + showOsdNotification: (text: string) => void; +} + +export function createUiFeedbackState(): UiFeedbackState { + return { + progressDepth: 0, + progressTimer: null, + progressMessage: '', + progressFrame: 0, + }; +} + +export function showStatusNotification( + message: string, + context: UiFeedbackNotificationContext, +): void { + const type = context.getNotificationType() || 'osd'; + + if (type === 'osd' || type === 'both') { + context.showOsd(message); + } + + if (type === 'system' || type === 'both') { + context.showSystemNotification('SubMiner', { body: message }); + } +} + +export function beginUpdateProgress( + state: UiFeedbackState, + initialMessage: string, + showProgressTick: (text: string) => void, +): void { + state.progressDepth += 1; + if (state.progressDepth > 1) return; + + state.progressMessage = initialMessage; + state.progressFrame = 0; + showProgressTick(`${state.progressMessage}`); + state.progressTimer = setInterval(() => { + showProgressTick(`${state.progressMessage} ${['|', '/', '-', '\\'][state.progressFrame % 4]}`); + state.progressFrame += 1; + }, 180); +} + +export function endUpdateProgress( + state: UiFeedbackState, + clearProgressTimer: (timer: ReturnType) => void, +): void { + state.progressDepth = Math.max(0, state.progressDepth - 1); + if (state.progressDepth > 0) return; + + if (state.progressTimer) { + clearProgressTimer(state.progressTimer); + state.progressTimer = null; + } + state.progressMessage = ''; + state.progressFrame = 0; +} + +export function showProgressTick( + state: UiFeedbackState, + showOsdNotification: (text: string) => void, +): void { + if (!state.progressMessage) return; + const frames = ['|', '/', '-', '\\']; + const frame = frames[state.progressFrame % frames.length]; + state.progressFrame += 1; + showOsdNotification(`${state.progressMessage} ${frame}`); +} + +export async function withUpdateProgress( + state: UiFeedbackState, + options: UiFeedbackOptions, + initialMessage: string, + action: () => Promise, +): Promise { + beginUpdateProgress(state, initialMessage, () => + showProgressTick(state, options.showOsdNotification), + ); + options.setUpdateInProgress(true); + try { + return await action(); + } finally { + options.setUpdateInProgress(false); + endUpdateProgress(state, clearInterval); + } +} diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts new file mode 100644 index 0000000..1d2e76b --- /dev/null +++ b/src/cli/args.test.ts @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hasExplicitCommand, parseArgs, shouldStartApp } from './args'; + +test('parseArgs parses booleans and value flags', () => { + const args = parseArgs([ + '--background', + '--start', + '--socket', + '/tmp/mpv.sock', + '--backend=hyprland', + '--port', + '6000', + '--log-level', + 'warn', + '--debug', + '--jellyfin-play', + '--jellyfin-server', + 'http://jellyfin.local:8096', + '--jellyfin-item-id', + 'item-123', + '--jellyfin-audio-stream-index', + '2', + ]); + + assert.equal(args.background, true); + assert.equal(args.start, true); + assert.equal(args.socketPath, '/tmp/mpv.sock'); + assert.equal(args.backend, 'hyprland'); + assert.equal(args.texthookerPort, 6000); + assert.equal(args.logLevel, 'warn'); + assert.equal(args.debug, true); + assert.equal(args.jellyfinPlay, true); + assert.equal(args.jellyfinServer, 'http://jellyfin.local:8096'); + assert.equal(args.jellyfinItemId, 'item-123'); + assert.equal(args.jellyfinAudioStreamIndex, 2); +}); + +test('parseArgs ignores missing value after --log-level', () => { + const args = parseArgs(['--log-level', '--start']); + assert.equal(args.logLevel, undefined); + assert.equal(args.start, true); +}); + +test('hasExplicitCommand and shouldStartApp preserve command intent', () => { + const stopOnly = parseArgs(['--stop']); + assert.equal(hasExplicitCommand(stopOnly), true); + assert.equal(shouldStartApp(stopOnly), false); + + const toggle = parseArgs(['--toggle-visible-overlay']); + assert.equal(hasExplicitCommand(toggle), true); + assert.equal(shouldStartApp(toggle), true); + + const noCommand = parseArgs(['--log-level', 'warn']); + assert.equal(hasExplicitCommand(noCommand), false); + assert.equal(shouldStartApp(noCommand), false); + + const refreshKnownWords = parseArgs(['--refresh-known-words']); + assert.equal(refreshKnownWords.help, false); + assert.equal(hasExplicitCommand(refreshKnownWords), true); + assert.equal(shouldStartApp(refreshKnownWords), false); + + const anilistStatus = parseArgs(['--anilist-status']); + assert.equal(anilistStatus.anilistStatus, true); + assert.equal(hasExplicitCommand(anilistStatus), true); + assert.equal(shouldStartApp(anilistStatus), false); + + const anilistRetryQueue = parseArgs(['--anilist-retry-queue']); + assert.equal(anilistRetryQueue.anilistRetryQueue, true); + assert.equal(hasExplicitCommand(anilistRetryQueue), true); + assert.equal(shouldStartApp(anilistRetryQueue), false); + + const jellyfinLibraries = parseArgs(['--jellyfin-libraries']); + assert.equal(jellyfinLibraries.jellyfinLibraries, true); + assert.equal(hasExplicitCommand(jellyfinLibraries), true); + assert.equal(shouldStartApp(jellyfinLibraries), false); + + const jellyfinSetup = parseArgs(['--jellyfin']); + assert.equal(jellyfinSetup.jellyfin, true); + assert.equal(hasExplicitCommand(jellyfinSetup), true); + assert.equal(shouldStartApp(jellyfinSetup), true); + + const jellyfinPlay = parseArgs(['--jellyfin-play']); + assert.equal(jellyfinPlay.jellyfinPlay, true); + assert.equal(hasExplicitCommand(jellyfinPlay), true); + assert.equal(shouldStartApp(jellyfinPlay), true); + + const jellyfinSubtitles = parseArgs(['--jellyfin-subtitles', '--jellyfin-subtitle-urls']); + assert.equal(jellyfinSubtitles.jellyfinSubtitles, true); + assert.equal(jellyfinSubtitles.jellyfinSubtitleUrlsOnly, true); + assert.equal(hasExplicitCommand(jellyfinSubtitles), true); + assert.equal(shouldStartApp(jellyfinSubtitles), false); + + const jellyfinRemoteAnnounce = parseArgs(['--jellyfin-remote-announce']); + assert.equal(jellyfinRemoteAnnounce.jellyfinRemoteAnnounce, true); + assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true); + assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false); + + const background = parseArgs(['--background']); + assert.equal(background.background, true); + assert.equal(hasExplicitCommand(background), true); + assert.equal(shouldStartApp(background), true); +}); diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..a031d54 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,352 @@ +export interface CliArgs { + background: boolean; + start: boolean; + stop: boolean; + toggle: boolean; + toggleVisibleOverlay: boolean; + toggleInvisibleOverlay: boolean; + settings: boolean; + show: boolean; + hide: boolean; + showVisibleOverlay: boolean; + hideVisibleOverlay: boolean; + showInvisibleOverlay: boolean; + hideInvisibleOverlay: boolean; + copySubtitle: boolean; + copySubtitleMultiple: boolean; + mineSentence: boolean; + mineSentenceMultiple: boolean; + updateLastCardFromClipboard: boolean; + refreshKnownWords: boolean; + toggleSecondarySub: boolean; + triggerFieldGrouping: boolean; + triggerSubsync: boolean; + markAudioCard: boolean; + openRuntimeOptions: boolean; + anilistStatus: boolean; + anilistLogout: boolean; + anilistSetup: boolean; + anilistRetryQueue: boolean; + jellyfin: boolean; + jellyfinLogin: boolean; + jellyfinLogout: boolean; + jellyfinLibraries: boolean; + jellyfinItems: boolean; + jellyfinSubtitles: boolean; + jellyfinSubtitleUrlsOnly: boolean; + jellyfinPlay: boolean; + jellyfinRemoteAnnounce: boolean; + texthooker: boolean; + help: boolean; + autoStartOverlay: boolean; + generateConfig: boolean; + configPath?: string; + backupOverwrite: boolean; + socketPath?: string; + backend?: string; + texthookerPort?: number; + jellyfinServer?: string; + jellyfinUsername?: string; + jellyfinPassword?: string; + jellyfinLibraryId?: string; + jellyfinItemId?: string; + jellyfinSearch?: string; + jellyfinLimit?: number; + jellyfinAudioStreamIndex?: number; + jellyfinSubtitleStreamIndex?: number; + debug: boolean; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; +} + +export type CliCommandSource = 'initial' | 'second-instance'; + +export function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { + background: false, + start: false, + stop: false, + toggle: false, + toggleVisibleOverlay: false, + toggleInvisibleOverlay: false, + settings: false, + show: false, + hide: false, + showVisibleOverlay: false, + hideVisibleOverlay: false, + showInvisibleOverlay: false, + hideInvisibleOverlay: false, + copySubtitle: false, + copySubtitleMultiple: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + refreshKnownWords: false, + toggleSecondarySub: false, + triggerFieldGrouping: false, + triggerSubsync: false, + markAudioCard: false, + openRuntimeOptions: false, + anilistStatus: false, + anilistLogout: false, + anilistSetup: false, + anilistRetryQueue: false, + jellyfin: false, + jellyfinLogin: false, + jellyfinLogout: false, + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + jellyfinSubtitleUrlsOnly: false, + jellyfinPlay: false, + jellyfinRemoteAnnounce: false, + texthooker: false, + help: false, + autoStartOverlay: false, + generateConfig: false, + backupOverwrite: false, + debug: false, + }; + + const readValue = (value?: string): string | undefined => { + if (!value) return undefined; + if (value.startsWith('--')) return undefined; + return value; + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg || !arg.startsWith('--')) continue; + + if (arg === '--background') args.background = true; + else if (arg === '--start') args.start = true; + else if (arg === '--stop') args.stop = true; + else if (arg === '--toggle') args.toggle = true; + else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; + else if (arg === '--toggle-invisible-overlay') args.toggleInvisibleOverlay = true; + else if (arg === '--settings' || arg === '--yomitan') args.settings = true; + else if (arg === '--show') args.show = true; + else if (arg === '--hide') args.hide = true; + else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true; + else if (arg === '--hide-visible-overlay') args.hideVisibleOverlay = true; + else if (arg === '--show-invisible-overlay') args.showInvisibleOverlay = true; + else if (arg === '--hide-invisible-overlay') args.hideInvisibleOverlay = true; + else if (arg === '--copy-subtitle') args.copySubtitle = true; + else if (arg === '--copy-subtitle-multiple') args.copySubtitleMultiple = true; + else if (arg === '--mine-sentence') args.mineSentence = true; + else if (arg === '--mine-sentence-multiple') args.mineSentenceMultiple = true; + else if (arg === '--update-last-card-from-clipboard') args.updateLastCardFromClipboard = true; + else if (arg === '--refresh-known-words') args.refreshKnownWords = true; + else if (arg === '--toggle-secondary-sub') args.toggleSecondarySub = true; + else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true; + else if (arg === '--trigger-subsync') args.triggerSubsync = true; + else if (arg === '--mark-audio-card') args.markAudioCard = true; + else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; + else if (arg === '--anilist-status') args.anilistStatus = true; + else if (arg === '--anilist-logout') args.anilistLogout = true; + else if (arg === '--anilist-setup') args.anilistSetup = true; + else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true; + else if (arg === '--jellyfin') args.jellyfin = true; + else if (arg === '--jellyfin-login') args.jellyfinLogin = true; + else if (arg === '--jellyfin-logout') args.jellyfinLogout = true; + else if (arg === '--jellyfin-libraries') args.jellyfinLibraries = true; + else if (arg === '--jellyfin-items') args.jellyfinItems = true; + else if (arg === '--jellyfin-subtitles') args.jellyfinSubtitles = true; + else if (arg === '--jellyfin-subtitle-urls') { + args.jellyfinSubtitles = true; + args.jellyfinSubtitleUrlsOnly = true; + } else if (arg === '--jellyfin-play') args.jellyfinPlay = true; + else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true; + else if (arg === '--texthooker') args.texthooker = true; + else if (arg === '--auto-start-overlay') args.autoStartOverlay = true; + else if (arg === '--generate-config') args.generateConfig = true; + else if (arg === '--backup-overwrite') args.backupOverwrite = true; + else if (arg === '--help') args.help = true; + else if (arg === '--debug') args.debug = true; + else if (arg.startsWith('--log-level=')) { + const value = arg.split('=', 2)[1]?.toLowerCase(); + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + args.logLevel = value; + } + } else if (arg === '--log-level') { + const value = readValue(argv[i + 1])?.toLowerCase(); + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + args.logLevel = value; + } + } else if (arg.startsWith('--config-path=')) { + const value = arg.split('=', 2)[1]; + if (value) args.configPath = value; + } else if (arg === '--config-path') { + const value = readValue(argv[i + 1]); + if (value) args.configPath = value; + } else if (arg.startsWith('--socket=')) { + const value = arg.split('=', 2)[1]; + if (value) args.socketPath = value; + } else if (arg === '--socket') { + const value = readValue(argv[i + 1]); + if (value) args.socketPath = value; + } else if (arg.startsWith('--backend=')) { + const value = arg.split('=', 2)[1]; + if (value) args.backend = value; + } else if (arg === '--backend') { + const value = readValue(argv[i + 1]); + if (value) args.backend = value; + } else if (arg.startsWith('--port=')) { + const value = Number(arg.split('=', 2)[1]); + if (!Number.isNaN(value)) args.texthookerPort = value; + } else if (arg === '--port') { + const value = Number(readValue(argv[i + 1])); + if (!Number.isNaN(value)) args.texthookerPort = value; + } else if (arg.startsWith('--jellyfin-server=')) { + const value = arg.split('=', 2)[1]; + if (value) args.jellyfinServer = value; + } else if (arg === '--jellyfin-server') { + const value = readValue(argv[i + 1]); + if (value) args.jellyfinServer = value; + } else if (arg.startsWith('--jellyfin-username=')) { + const value = arg.split('=', 2)[1]; + if (value) args.jellyfinUsername = value; + } else if (arg === '--jellyfin-username') { + const value = readValue(argv[i + 1]); + if (value) args.jellyfinUsername = value; + } else if (arg.startsWith('--jellyfin-password=')) { + const value = arg.split('=', 2)[1]; + if (value) args.jellyfinPassword = value; + } else if (arg === '--jellyfin-password') { + const value = readValue(argv[i + 1]); + if (value) args.jellyfinPassword = value; + } else if (arg.startsWith('--jellyfin-library-id=')) { + const value = arg.split('=', 2)[1]; + if (value) args.jellyfinLibraryId = value; + } else if (arg === '--jellyfin-library-id') { + const value = readValue(argv[i + 1]); + if (value) args.jellyfinLibraryId = value; + } else if (arg.startsWith('--jellyfin-item-id=')) { + const value = arg.split('=', 2)[1]; + if (value) args.jellyfinItemId = value; + } else if (arg === '--jellyfin-item-id') { + const value = readValue(argv[i + 1]); + if (value) args.jellyfinItemId = value; + } else if (arg.startsWith('--jellyfin-search=')) { + const value = arg.split('=', 2)[1]; + if (value) args.jellyfinSearch = value; + } else if (arg === '--jellyfin-search') { + const value = readValue(argv[i + 1]); + if (value) args.jellyfinSearch = value; + } else if (arg.startsWith('--jellyfin-limit=')) { + const value = Number(arg.split('=', 2)[1]); + if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value); + } else if (arg === '--jellyfin-limit') { + const value = Number(readValue(argv[i + 1])); + if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value); + } else if (arg.startsWith('--jellyfin-audio-stream-index=')) { + const value = Number(arg.split('=', 2)[1]); + if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value; + } else if (arg === '--jellyfin-audio-stream-index') { + const value = Number(readValue(argv[i + 1])); + if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value; + } else if (arg.startsWith('--jellyfin-subtitle-stream-index=')) { + const value = Number(arg.split('=', 2)[1]); + if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value; + } else if (arg === '--jellyfin-subtitle-stream-index') { + const value = Number(readValue(argv[i + 1])); + if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value; + } + } + + return args; +} + +export function hasExplicitCommand(args: CliArgs): boolean { + return ( + args.background || + args.start || + args.stop || + args.toggle || + args.toggleVisibleOverlay || + args.toggleInvisibleOverlay || + args.settings || + args.show || + args.hide || + args.showVisibleOverlay || + args.hideVisibleOverlay || + args.showInvisibleOverlay || + args.hideInvisibleOverlay || + args.copySubtitle || + args.copySubtitleMultiple || + args.mineSentence || + args.mineSentenceMultiple || + args.updateLastCardFromClipboard || + args.refreshKnownWords || + args.toggleSecondarySub || + args.triggerFieldGrouping || + args.triggerSubsync || + args.markAudioCard || + args.openRuntimeOptions || + args.anilistStatus || + args.anilistLogout || + args.anilistSetup || + args.anilistRetryQueue || + args.jellyfin || + args.jellyfinLogin || + args.jellyfinLogout || + args.jellyfinLibraries || + args.jellyfinItems || + args.jellyfinSubtitles || + args.jellyfinPlay || + args.jellyfinRemoteAnnounce || + args.texthooker || + args.generateConfig || + args.help + ); +} + +export function shouldStartApp(args: CliArgs): boolean { + if (args.stop && !args.start) return false; + if ( + args.background || + args.start || + args.toggle || + args.toggleVisibleOverlay || + args.toggleInvisibleOverlay || + args.copySubtitle || + args.copySubtitleMultiple || + args.mineSentence || + args.mineSentenceMultiple || + args.updateLastCardFromClipboard || + args.toggleSecondarySub || + args.triggerFieldGrouping || + args.triggerSubsync || + args.markAudioCard || + args.openRuntimeOptions || + args.jellyfin || + args.jellyfinPlay || + args.texthooker + ) { + return true; + } + return false; +} + +export function commandNeedsOverlayRuntime(args: CliArgs): boolean { + return ( + args.toggle || + args.toggleVisibleOverlay || + args.toggleInvisibleOverlay || + args.show || + args.hide || + args.showVisibleOverlay || + args.hideVisibleOverlay || + args.showInvisibleOverlay || + args.hideInvisibleOverlay || + args.copySubtitle || + args.copySubtitleMultiple || + args.mineSentence || + args.mineSentenceMultiple || + args.updateLastCardFromClipboard || + args.toggleSecondarySub || + args.triggerFieldGrouping || + args.triggerSubsync || + args.markAudioCard || + args.openRuntimeOptions + ); +} diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts new file mode 100644 index 0000000..c60bcc5 --- /dev/null +++ b/src/cli/help.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { printHelp } from './help'; + +test('printHelp includes configured texthooker port', () => { + const original = console.log; + let output = ''; + console.log = (value?: unknown) => { + output += String(value); + }; + + try { + printHelp(7777); + } finally { + console.log = original; + } + + assert.match(output, /--help\s+Show this help/); + assert.match(output, /default: 7777/); + assert.match(output, /--refresh-known-words/); + assert.match(output, /--anilist-status/); + assert.match(output, /--anilist-retry-queue/); + assert.match(output, /--jellyfin\s+Open Jellyfin setup window/); + assert.match(output, /--jellyfin-login/); + assert.match(output, /--jellyfin-subtitles/); + assert.match(output, /--jellyfin-play/); +}); diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..ecdb0e1 --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,80 @@ +export function printHelp(defaultTexthookerPort: number): void { + const tty = process.stdout?.isTTY ?? false; + const B = tty ? '\x1b[1m' : ''; + const D = tty ? '\x1b[2m' : ''; + const R = tty ? '\x1b[0m' : ''; + + console.log(` +${B}SubMiner${R} — Japanese sentence mining with mpv + Yomitan + +${B}Usage:${R} subminer ${D}[command] [options]${R} + +${B}Session${R} + --background Start in tray/background mode + --start Connect to mpv and launch overlay + --stop Stop the running instance + --texthooker Start texthooker server only ${D}(no overlay)${R} + +${B}Overlay${R} + --toggle-visible-overlay Toggle subtitle overlay + --toggle-invisible-overlay Toggle interactive overlay ${D}(Yomitan lookup)${R} + --show-visible-overlay Show subtitle overlay + --hide-visible-overlay Hide subtitle overlay + --show-invisible-overlay Show interactive overlay + --hide-invisible-overlay Hide interactive overlay + --settings Open Yomitan settings window + --auto-start-overlay Auto-hide mpv subs, show overlay on connect + +${B}Mining${R} + --mine-sentence Create Anki card from current subtitle + --mine-sentence-multiple Select multiple lines, then mine + --copy-subtitle Copy current subtitle to clipboard + --copy-subtitle-multiple Enter multi-line copy mode + --update-last-card-from-clipboard Update last Anki card from clipboard + --mark-audio-card Mark last card as audio-only + --trigger-field-grouping Run Kiku field grouping + --trigger-subsync Run subtitle sync + --toggle-secondary-sub Cycle secondary subtitle mode + --refresh-known-words Refresh known words cache + --open-runtime-options Open runtime options palette + +${B}AniList${R} + --anilist-setup Open AniList authentication flow + --anilist-status Show token and retry queue status + --anilist-logout Clear stored AniList token + --anilist-retry-queue Retry next queued update + +${B}Jellyfin${R} + --jellyfin Open Jellyfin setup window + --jellyfin-login Authenticate and store session token + --jellyfin-logout Clear stored session data + --jellyfin-libraries List available libraries + --jellyfin-items List items from a library + --jellyfin-subtitles List subtitle tracks for an item + --jellyfin-subtitle-urls Print subtitle download URLs only + --jellyfin-play Stream an item in mpv + --jellyfin-remote-announce Broadcast cast-target capability + + ${D}Jellyfin options:${R} + --jellyfin-server ${D}URL${R} Server URL ${D}(overrides config)${R} + --jellyfin-username ${D}NAME${R} Username for login + --jellyfin-password ${D}PASS${R} Password for login + --jellyfin-library-id ${D}ID${R} Library to browse + --jellyfin-item-id ${D}ID${R} Item to play or inspect + --jellyfin-search ${D}QUERY${R} Filter items by search term + --jellyfin-limit ${D}N${R} Max items returned + --jellyfin-audio-stream-index ${D}N${R} Audio stream override + --jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override + +${B}Options${R} + --socket ${D}PATH${R} mpv IPC socket path + --backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos)${R} + --port ${D}PORT${R} Texthooker server port ${D}(default: ${defaultTexthookerPort})${R} + --log-level ${D}LEVEL${R} ${D}debug | info | warn | error${R} + --debug Enable debug mode ${D}(alias: --dev)${R} + --generate-config Write default config.jsonc + --config-path ${D}PATH${R} Target path for --generate-config + --backup-overwrite Backup existing config before overwrite + --help Show this help +`); +} diff --git a/src/config/config.test.ts b/src/config/config.test.ts new file mode 100644 index 0000000..ed35864 --- /dev/null +++ b/src/config/config.test.ts @@ -0,0 +1,1112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ConfigService, ConfigStartupParseError } from './service'; +import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from './definitions'; +import { generateConfigTemplate } from './template'; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-')); +} + +test('loads defaults when config is missing', () => { + const dir = makeTempDir(); + const service = new ConfigService(dir); + const config = service.getConfig(); + assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); + assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); + assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); + assert.equal(config.anilist.enabled, false); + assert.equal(config.jellyfin.remoteControlEnabled, true); + assert.equal(config.jellyfin.remoteControlAutoConnect, true); + assert.equal(config.jellyfin.autoAnnounce, false); + assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); + assert.equal(config.discordPresence.enabled, false); + assert.equal(config.discordPresence.updateIntervalMs, 3_000); + assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); + assert.equal(config.subtitleStyle.preserveLineBreaks, false); + assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6'); + assert.equal(config.immersionTracking.enabled, true); + assert.equal(config.immersionTracking.dbPath, ''); + assert.equal(config.immersionTracking.batchSize, 25); + assert.equal(config.immersionTracking.flushIntervalMs, 500); + assert.equal(config.immersionTracking.queueCap, 1000); + assert.equal(config.immersionTracking.payloadCapBytes, 256); + assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000); + assert.equal(config.immersionTracking.retention.eventsDays, 7); + assert.equal(config.immersionTracking.retention.telemetryDays, 30); + assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365); + assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825); + assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7); +}); + +test('throws actionable startup parse error for malformed config at construction time', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync(configPath, '{"websocket":', 'utf-8'); + + assert.throws( + () => new ConfigService(dir), + (error: unknown) => { + assert.ok(error instanceof ConfigStartupParseError); + assert.equal(error.path, configPath); + assert.ok(error.parseError.length > 0); + assert.ok(error.message.includes(configPath)); + assert.ok(error.message.includes(error.parseError)); + return true; + }, + ); +}); + +test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "preserveLineBreaks": true + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().subtitleStyle.preserveLineBreaks, true); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "preserveLineBreaks": "yes" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().subtitleStyle.preserveLineBreaks, + DEFAULT_CONFIG.subtitleStyle.preserveLineBreaks, + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.preserveLineBreaks'), + ); +}); + +test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "hoverTokenColor": "#c6a0f6" + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().subtitleStyle.hoverTokenColor, '#c6a0f6'); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "hoverTokenColor": "purple" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().subtitleStyle.hoverTokenColor, + DEFAULT_CONFIG.subtitleStyle.hoverTokenColor, + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.hoverTokenColor'), + ); +}); + +test('parses anilist.enabled and warns for invalid value', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "anilist": { + "enabled": "yes" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.anilist.enabled, DEFAULT_CONFIG.anilist.enabled); + assert.ok(warnings.some((warning) => warning.path === 'anilist.enabled')); + + service.patchRawConfig({ anilist: { enabled: true } }); + assert.equal(service.getConfig().anilist.enabled, true); +}); + +test('parses jellyfin remote control fields', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "jellyfin": { + "enabled": true, + "serverUrl": "http://127.0.0.1:8096", + "remoteControlEnabled": true, + "remoteControlAutoConnect": true, + "autoAnnounce": true, + "remoteControlDeviceName": "SubMiner" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.jellyfin.enabled, true); + assert.equal(config.jellyfin.serverUrl, 'http://127.0.0.1:8096'); + assert.equal(config.jellyfin.remoteControlEnabled, true); + assert.equal(config.jellyfin.remoteControlAutoConnect, true); + assert.equal(config.jellyfin.autoAnnounce, true); + assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); +}); + +test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => { + const disabledDir = makeTempDir(); + fs.writeFileSync( + path.join(disabledDir, 'config.jsonc'), + `{ + "jellyfin": { + "enabled": false, + "remoteControlEnabled": false + } + }`, + 'utf-8', + ); + + const disabledService = new ConfigService(disabledDir); + const disabledConfig = disabledService.getConfig(); + assert.equal(disabledConfig.jellyfin.enabled, false); + assert.equal(disabledConfig.jellyfin.remoteControlEnabled, false); + assert.equal( + disabledService + .getWarnings() + .some( + (warning) => + warning.path === 'jellyfin.enabled' || warning.path === 'jellyfin.remoteControlEnabled', + ), + false, + ); + + const mixedDir = makeTempDir(); + fs.writeFileSync( + path.join(mixedDir, 'config.jsonc'), + `{ + "jellyfin": { + "enabled": true, + "remoteControlEnabled": false + } + }`, + 'utf-8', + ); + + const mixedService = new ConfigService(mixedDir); + const mixedConfig = mixedService.getConfig(); + assert.equal(mixedConfig.jellyfin.enabled, true); + assert.equal(mixedConfig.jellyfin.remoteControlEnabled, false); + assert.equal( + mixedService + .getWarnings() + .some( + (warning) => + warning.path === 'jellyfin.enabled' || warning.path === 'jellyfin.remoteControlEnabled', + ), + false, + ); +}); + +test('parses discordPresence fields and warns for invalid types', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "discordPresence": { + "enabled": true, + "updateIntervalMs": 3000, + "debounceMs": 250 + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + assert.equal(config.discordPresence.enabled, true); + assert.equal(config.discordPresence.updateIntervalMs, 3000); + assert.equal(config.discordPresence.debounceMs, 250); + + service.patchRawConfig({ discordPresence: { enabled: 'yes' as never } }); + assert.equal(service.getConfig().discordPresence.enabled, DEFAULT_CONFIG.discordPresence.enabled); + assert.ok(service.getWarnings().some((warning) => warning.path === 'discordPresence.enabled')); +}); + +test('accepts immersion tracking config values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "immersionTracking": { + "enabled": false, + "dbPath": "/tmp/immersions/custom.sqlite", + "batchSize": 50, + "flushIntervalMs": 750, + "queueCap": 2000, + "payloadCapBytes": 512, + "maintenanceIntervalMs": 3600000, + "retention": { + "eventsDays": 14, + "telemetryDays": 45, + "dailyRollupsDays": 730, + "monthlyRollupsDays": 3650, + "vacuumIntervalDays": 14 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.immersionTracking.enabled, false); + assert.equal(config.immersionTracking.dbPath, '/tmp/immersions/custom.sqlite'); + assert.equal(config.immersionTracking.batchSize, 50); + assert.equal(config.immersionTracking.flushIntervalMs, 750); + assert.equal(config.immersionTracking.queueCap, 2000); + assert.equal(config.immersionTracking.payloadCapBytes, 512); + assert.equal(config.immersionTracking.maintenanceIntervalMs, 3_600_000); + assert.equal(config.immersionTracking.retention.eventsDays, 14); + assert.equal(config.immersionTracking.retention.telemetryDays, 45); + assert.equal(config.immersionTracking.retention.dailyRollupsDays, 730); + assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 3650); + assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 14); +}); + +test('falls back for invalid immersion tracking tuning values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "immersionTracking": { + "batchSize": 0, + "flushIntervalMs": 1, + "queueCap": 5, + "payloadCapBytes": 16, + "maintenanceIntervalMs": 1000, + "retention": { + "eventsDays": 0, + "telemetryDays": 99999, + "dailyRollupsDays": 0, + "monthlyRollupsDays": 999999, + "vacuumIntervalDays": 0 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.immersionTracking.batchSize, 25); + assert.equal(config.immersionTracking.flushIntervalMs, 500); + assert.equal(config.immersionTracking.queueCap, 1000); + assert.equal(config.immersionTracking.payloadCapBytes, 256); + assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000); + assert.equal(config.immersionTracking.retention.eventsDays, 7); + assert.equal(config.immersionTracking.retention.telemetryDays, 30); + assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365); + assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825); + assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7); + + assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.batchSize')); + assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.flushIntervalMs')); + assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.queueCap')); + assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.payloadCapBytes')); + assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.maintenanceIntervalMs')); + assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.retention.eventsDays')); + assert.ok( + warnings.some((warning) => warning.path === 'immersionTracking.retention.telemetryDays'), + ); + assert.ok( + warnings.some((warning) => warning.path === 'immersionTracking.retention.dailyRollupsDays'), + ); + assert.ok( + warnings.some((warning) => warning.path === 'immersionTracking.retention.monthlyRollupsDays'), + ); + assert.ok( + warnings.some((warning) => warning.path === 'immersionTracking.retention.vacuumIntervalDays'), + ); +}); + +test('parses jsonc and warns/falls back on invalid value', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + // invalid websocket port + "websocket": { "port": "bad" } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); + assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port')); +}); + +test('accepts trailing commas in jsonc', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "websocket": { + "enabled": "auto", + "port": 7788, + }, + "youtubeSubgen": { + "primarySubLanguages": ["ja", "en",], + }, + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + assert.equal(config.websocket.port, 7788); + assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'en']); +}); + +test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync( + configPath, + `{ + "logging": { + "level": "warn" + } + }`, + ); + + const service = new ConfigService(dir); + assert.equal(service.getConfig().logging.level, 'warn'); + + fs.writeFileSync( + configPath, + `{ + "logging":`, + ); + + const result = service.reloadConfigStrict(); + assert.equal(result.ok, false); + if (result.ok) { + throw new Error('Expected strict reload to fail on invalid JSONC.'); + } + assert.equal(result.path, configPath); + assert.equal(service.getConfig().logging.level, 'warn'); +}); + +test('reloadConfigStrict rejects invalid json and preserves previous config', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2)); + + const service = new ConfigService(dir); + assert.equal(service.getConfig().logging.level, 'error'); + + fs.writeFileSync(configPath, '{"logging":'); + + const result = service.reloadConfigStrict(); + assert.equal(result.ok, false); + if (result.ok) { + throw new Error('Expected strict reload to fail on invalid JSON.'); + } + assert.equal(result.path, configPath); + assert.equal(service.getConfig().logging.level, 'error'); +}); + +test('prefers config.jsonc over config.json when both exist', () => { + const dir = makeTempDir(); + const jsonPath = path.join(dir, 'config.json'); + const jsoncPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync(jsonPath, JSON.stringify({ logging: { level: 'error' } }, null, 2)); + fs.writeFileSync( + jsoncPath, + `{ + "logging": { + "level": "warn" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + assert.equal(service.getConfig().logging.level, 'warn'); + assert.equal(service.getConfigPath(), jsoncPath); +}); + +test('reloadConfigStrict parse failure does not mutate raw config or warnings', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync( + configPath, + `{ + "logging": { + "level": "warn" + }, + "websocket": { + "port": "bad" + } + }`, + ); + + const service = new ConfigService(dir); + const beforePath = service.getConfigPath(); + const beforeConfig = service.getConfig(); + const beforeRaw = service.getRawConfig(); + const beforeWarnings = service.getWarnings(); + + fs.writeFileSync(configPath, '{"logging":'); + + const result = service.reloadConfigStrict(); + assert.equal(result.ok, false); + assert.equal(service.getConfigPath(), beforePath); + assert.deepEqual(service.getConfig(), beforeConfig); + assert.deepEqual(service.getRawConfig(), beforeRaw); + assert.deepEqual(service.getWarnings(), beforeWarnings); +}); + +test('warning emission order is deterministic across reloads', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + fs.writeFileSync( + configPath, + `{ + "unknownFeature": true, + "websocket": { + "enabled": "sometimes", + "port": -1 + }, + "logging": { + "level": "trace" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const firstWarnings = service.getWarnings(); + + service.reloadConfig(); + const secondWarnings = service.getWarnings(); + + assert.deepEqual(secondWarnings, firstWarnings); + assert.deepEqual( + firstWarnings.map((warning) => warning.path), + ['unknownFeature', 'websocket.enabled', 'websocket.port', 'logging.level'], + ); +}); + +test('accepts valid logging.level', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "logging": { + "level": "warn" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.logging.level, 'warn'); +}); + +test('falls back for invalid logging.level and reports warning', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "logging": { + "level": "trace" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level); + assert.ok(warnings.some((warning) => warning.path === 'logging.level')); +}); + +test('warns and ignores unknown top-level config keys', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "websocket": { + "port": 7788 + }, + "unknownFeatureFlag": { + "enabled": true + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.websocket.port, 7788); + assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag')); +}); + +test('parses invisible overlay config and new global shortcuts', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "shortcuts": { + "toggleVisibleOverlayGlobal": "Alt+Shift+U", + "toggleInvisibleOverlayGlobal": "Alt+Shift+I", + "openJimaku": "Ctrl+Alt+J" + }, + "invisibleOverlay": { + "startupVisibility": "hidden" + }, + "bind_visible_overlay_to_mpv_sub_visibility": false, + "youtubeSubgen": { + "primarySubLanguages": ["ja", "jpn", "jp"] + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U'); + assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, 'Alt+Shift+I'); + assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J'); + assert.equal(config.invisibleOverlay.startupVisibility, 'hidden'); + assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false); + assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']); +}); + +test('runtime options registry is centralized', () => { + const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id); + assert.deepEqual(ids, [ + 'anki.autoUpdateNewCards', + 'anki.nPlusOneMatchMode', + 'anki.kikuFieldGrouping', + ]); +}); + +test('validates ankiConnect n+1 behavior values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "highlightEnabled": "yes", + "refreshMinutes": -5 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal( + config.ankiConnect.nPlusOne.highlightEnabled, + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, + ); + assert.equal( + config.ankiConnect.nPlusOne.refreshMinutes, + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.refreshMinutes')); +}); + +test('accepts valid ankiConnect n+1 behavior values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "highlightEnabled": true, + "refreshMinutes": 120 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true); + assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120); +}); + +test('validates ankiConnect n+1 minimum sentence word count', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "minSentenceWords": 0 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal( + config.ankiConnect.nPlusOne.minSentenceWords, + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords')); +}); + +test('accepts valid ankiConnect n+1 minimum sentence word count', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "minSentenceWords": 4 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4); +}); + +test('validates ankiConnect n+1 match mode values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "matchMode": "bad-mode" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal( + config.ankiConnect.nPlusOne.matchMode, + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.matchMode')); +}); + +test('accepts valid ankiConnect n+1 match mode values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "matchMode": "surface" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface'); +}); + +test('validates ankiConnect n+1 color values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "nPlusOne": "not-a-color", + "knownWord": 123 + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne); + assert.equal( + config.ankiConnect.nPlusOne.knownWord, + DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.knownWord')); +}); + +test('accepts valid ankiConnect n+1 color values', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "nPlusOne": "#c6a0f6", + "knownWord": "#a6da95" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6'); + assert.equal(config.ankiConnect.nPlusOne.knownWord, '#a6da95'); +}); + +test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "behavior": { + "nPlusOneHighlightEnabled": true, + "nPlusOneRefreshMinutes": 90, + "nPlusOneMatchMode": "surface" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true); + assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90); + assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface'); + assert.ok( + warnings.some( + (warning) => + warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled' || + warning.path === 'ankiConnect.behavior.nPlusOneRefreshMinutes' || + warning.path === 'ankiConnect.behavior.nPlusOneMatchMode', + ), + ); +}); + +test('warns when ankiConnect.openRouter is used and migrates to ai', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "openRouter": { + "model": "openrouter/test-model" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal((config.ankiConnect.ai as Record).model, 'openrouter/test-model'); + assert.ok( + warnings.some( + (warning) => + warning.path === 'ankiConnect.openRouter' && warning.message.includes('ankiConnect.ai'), + ), + ); +}); + +test('falls back and warns when legacy ankiConnect migration values are invalid', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "audioField": 123, + "generateAudio": "yes", + "imageType": "gif", + "imageQuality": -1, + "mediaInsertMode": "middle", + "notificationType": "toast" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.ankiConnect.fields.audio, DEFAULT_CONFIG.ankiConnect.fields.audio); + assert.equal( + config.ankiConnect.media.generateAudio, + DEFAULT_CONFIG.ankiConnect.media.generateAudio, + ); + assert.equal(config.ankiConnect.media.imageType, DEFAULT_CONFIG.ankiConnect.media.imageType); + assert.equal( + config.ankiConnect.media.imageQuality, + DEFAULT_CONFIG.ankiConnect.media.imageQuality, + ); + assert.equal( + config.ankiConnect.behavior.mediaInsertMode, + DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode, + ); + assert.equal( + config.ankiConnect.behavior.notificationType, + DEFAULT_CONFIG.ankiConnect.behavior.notificationType, + ); + + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.audioField')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.generateAudio')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageType')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageQuality')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.mediaInsertMode')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.notificationType')); +}); + +test('maps valid legacy ankiConnect values to equivalent modern config', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "audioField": "AudioLegacy", + "imageField": "ImageLegacy", + "generateAudio": false, + "imageType": "avif", + "imageFormat": "webp", + "imageQuality": 88, + "mediaInsertMode": "prepend", + "notificationType": "both", + "autoUpdateNewCards": false + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.ankiConnect.fields.audio, 'AudioLegacy'); + assert.equal(config.ankiConnect.fields.image, 'ImageLegacy'); + assert.equal(config.ankiConnect.media.generateAudio, false); + assert.equal(config.ankiConnect.media.imageType, 'avif'); + assert.equal(config.ankiConnect.media.imageFormat, 'webp'); + assert.equal(config.ankiConnect.media.imageQuality, 88); + assert.equal(config.ankiConnect.behavior.mediaInsertMode, 'prepend'); + assert.equal(config.ankiConnect.behavior.notificationType, 'both'); + assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, false); +}); + +test('ignores deprecated isLapis sentence-card field overrides', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "isLapis": { + "enabled": true, + "sentenceCardModel": "Japanese sentences", + "sentenceCardSentenceField": "CustomSentence", + "sentenceCardAudioField": "CustomAudio" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + const lapisConfig = config.ankiConnect.isLapis as Record; + assert.equal(lapisConfig.sentenceCardSentenceField, undefined); + assert.equal(lapisConfig.sentenceCardAudioField, undefined); + assert.ok( + warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardSentenceField'), + ); + assert.ok( + warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardAudioField'), + ); +}); + +test('accepts valid ankiConnect n+1 deck list', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "decks": ["Deck One", "Deck Two"] + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']); +}); + +test('accepts valid ankiConnect tags list', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "tags": ["SubMiner", "Mining"] + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']); +}); + +test('falls back to default when ankiConnect tags list is invalid', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "tags": ["SubMiner", 123] + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags')); +}); + +test('falls back to default when ankiConnect n+1 deck list is invalid', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "nPlusOne": { + "decks": "not-an-array" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.deepEqual(config.ankiConnect.nPlusOne.decks, []); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks')); +}); + +test('template generator includes known keys', () => { + const output = generateConfigTemplate(DEFAULT_CONFIG); + assert.match(output, /"ankiConnect":/); + assert.match(output, /"logging":/); + assert.match(output, /"websocket":/); + assert.match(output, /"discordPresence":/); + assert.match(output, /"youtubeSubgen":/); + assert.match(output, /"preserveLineBreaks": false/); + assert.match(output, /"nPlusOne"\s*:\s*\{/); + assert.match(output, /"nPlusOne": "#c6a0f6"/); + assert.match(output, /"knownWord": "#a6da95"/); + assert.match(output, /"minSentenceWords": 3/); + assert.match(output, /auto-generated from src\/config\/definitions.ts/); + assert.match( + output, + /"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/, + ); + assert.match( + output, + /"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/, + ); + assert.match( + output, + /"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/, + ); +}); diff --git a/src/config/definitions.ts b/src/config/definitions.ts new file mode 100644 index 0000000..f471b4e --- /dev/null +++ b/src/config/definitions.ts @@ -0,0 +1,101 @@ +import { RawConfig, ResolvedConfig } from '../types'; +import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core'; +import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion'; +import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations'; +import { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle'; +import { buildCoreConfigOptionRegistry } from './definitions/options-core'; +import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion'; +import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations'; +import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle'; +import { buildRuntimeOptionRegistry } from './definitions/runtime-options'; +import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections'; + +export { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from './definitions/shared'; +export type { + ConfigOptionRegistryEntry, + ConfigTemplateSection, + ConfigValueKind, + RuntimeOptionRegistryEntry, +} from './definitions/shared'; + +const { + subtitlePosition, + keybindings, + websocket, + logging, + texthooker, + shortcuts, + secondarySub, + subsync, + auto_start_overlay, + bind_visible_overlay_to_mpv_sub_visibility, + invisibleOverlay, +} = CORE_DEFAULT_CONFIG; +const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } = + INTEGRATIONS_DEFAULT_CONFIG; +const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; +const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; + +export const DEFAULT_CONFIG: ResolvedConfig = { + subtitlePosition, + keybindings, + websocket, + logging, + texthooker, + ankiConnect, + shortcuts, + secondarySub, + subsync, + subtitleStyle, + auto_start_overlay, + bind_visible_overlay_to_mpv_sub_visibility, + jimaku, + anilist, + jellyfin, + discordPresence, + youtubeSubgen, + invisibleOverlay, + immersionTracking, +}; + +export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect; + +export const RUNTIME_OPTION_REGISTRY = buildRuntimeOptionRegistry(DEFAULT_CONFIG); + +export const CONFIG_OPTION_REGISTRY = [ + ...buildCoreConfigOptionRegistry(DEFAULT_CONFIG), + ...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG), + ...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY), + ...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG), +]; + +export { CONFIG_TEMPLATE_SECTIONS }; + +export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig { + return JSON.parse(JSON.stringify(config)) as ResolvedConfig; +} + +export function deepMergeRawConfig(base: RawConfig, patch: RawConfig): RawConfig { + const clone = JSON.parse(JSON.stringify(base)) as Record; + const patchObject = patch as Record; + + const mergeInto = (target: Record, source: Record): void => { + for (const [key, value] of Object.entries(source)) { + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof target[key] === 'object' && + target[key] !== null && + !Array.isArray(target[key]) + ) { + mergeInto(target[key] as Record, value as Record); + } else { + target[key] = value; + } + } + }; + + mergeInto(clone, patchObject); + return clone as RawConfig; +} diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts new file mode 100644 index 0000000..9097066 --- /dev/null +++ b/src/config/definitions/defaults-core.ts @@ -0,0 +1,61 @@ +import { ResolvedConfig } from '../../types'; + +export const CORE_DEFAULT_CONFIG: Pick< + ResolvedConfig, + | 'subtitlePosition' + | 'keybindings' + | 'websocket' + | 'logging' + | 'texthooker' + | 'shortcuts' + | 'secondarySub' + | 'subsync' + | 'auto_start_overlay' + | 'bind_visible_overlay_to_mpv_sub_visibility' + | 'invisibleOverlay' +> = { + subtitlePosition: { yPercent: 10 }, + keybindings: [], + websocket: { + enabled: 'auto', + port: 6677, + }, + logging: { + level: 'info', + }, + texthooker: { + openBrowser: true, + }, + shortcuts: { + toggleVisibleOverlayGlobal: 'Alt+Shift+O', + toggleInvisibleOverlayGlobal: 'Alt+Shift+I', + copySubtitle: 'CommandOrControl+C', + copySubtitleMultiple: 'CommandOrControl+Shift+C', + updateLastCardFromClipboard: 'CommandOrControl+V', + triggerFieldGrouping: 'CommandOrControl+G', + triggerSubsync: 'Ctrl+Alt+S', + mineSentence: 'CommandOrControl+S', + mineSentenceMultiple: 'CommandOrControl+Shift+S', + multiCopyTimeoutMs: 3000, + toggleSecondarySub: 'CommandOrControl+Shift+V', + markAudioCard: 'CommandOrControl+Shift+A', + openRuntimeOptions: 'CommandOrControl+Shift+O', + openJimaku: 'Ctrl+Shift+J', + }, + secondarySub: { + secondarySubLanguages: [], + autoLoadSecondarySub: false, + defaultMode: 'hover', + }, + subsync: { + defaultMode: 'auto', + alass_path: '', + ffsubsync_path: '', + ffmpeg_path: '', + }, + auto_start_overlay: false, + bind_visible_overlay_to_mpv_sub_visibility: true, + invisibleOverlay: { + startupVisibility: 'platform-default', + }, +}; diff --git a/src/config/definitions/defaults-immersion.ts b/src/config/definitions/defaults-immersion.ts new file mode 100644 index 0000000..f648739 --- /dev/null +++ b/src/config/definitions/defaults-immersion.ts @@ -0,0 +1,20 @@ +import { ResolvedConfig } from '../../types'; + +export const IMMERSION_DEFAULT_CONFIG: Pick = { + immersionTracking: { + enabled: true, + dbPath: '', + batchSize: 25, + flushIntervalMs: 500, + queueCap: 1000, + payloadCapBytes: 256, + maintenanceIntervalMs: 24 * 60 * 60 * 1000, + retention: { + eventsDays: 7, + telemetryDays: 30, + dailyRollupsDays: 365, + monthlyRollupsDays: 5 * 365, + vacuumIntervalDays: 7, + }, + }, +}; diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts new file mode 100644 index 0000000..662265a --- /dev/null +++ b/src/config/definitions/defaults-integrations.ts @@ -0,0 +1,113 @@ +import { ResolvedConfig } from '../../types'; + +export const INTEGRATIONS_DEFAULT_CONFIG: Pick< + ResolvedConfig, + 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen' +> = { + ankiConnect: { + enabled: false, + url: 'http://127.0.0.1:8765', + pollingRate: 3000, + tags: ['SubMiner'], + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: 'Sentence', + miscInfo: 'MiscInfo', + translation: 'SelectionText', + }, + ai: { + enabled: false, + alwaysUseAiTranslation: false, + apiKey: '', + model: 'openai/gpt-4o-mini', + baseUrl: 'https://openrouter.ai/api', + targetLanguage: 'English', + systemPrompt: + 'You are a translation engine. Return only the translated text with no explanations.', + }, + media: { + generateAudio: true, + generateImage: true, + imageType: 'static', + imageFormat: 'jpg', + imageQuality: 92, + imageMaxWidth: undefined, + imageMaxHeight: undefined, + animatedFps: 10, + animatedMaxWidth: 640, + animatedMaxHeight: undefined, + animatedCrf: 35, + audioPadding: 0.5, + fallbackDuration: 3.0, + maxMediaDuration: 30, + }, + behavior: { + overwriteAudio: true, + overwriteImage: true, + mediaInsertMode: 'append', + highlightWord: true, + notificationType: 'osd', + autoUpdateNewCards: true, + }, + nPlusOne: { + highlightEnabled: false, + refreshMinutes: 1440, + matchMode: 'headword', + decks: [], + minSentenceWords: 3, + nPlusOne: '#c6a0f6', + knownWord: '#a6da95', + }, + metadata: { + pattern: '[SubMiner] %f (%t)', + }, + isLapis: { + enabled: false, + sentenceCardModel: 'Japanese sentences', + }, + isKiku: { + enabled: false, + fieldGrouping: 'disabled', + deleteDuplicateInAuto: true, + }, + }, + jimaku: { + apiBaseUrl: 'https://jimaku.cc', + languagePreference: 'ja', + maxEntryResults: 10, + }, + anilist: { + enabled: false, + accessToken: '', + }, + jellyfin: { + enabled: false, + serverUrl: '', + username: '', + deviceId: 'subminer', + clientName: 'SubMiner', + clientVersion: '0.1.0', + defaultLibraryId: '', + remoteControlEnabled: true, + remoteControlAutoConnect: true, + autoAnnounce: false, + remoteControlDeviceName: 'SubMiner', + pullPictures: false, + iconCacheDir: '/tmp/subminer-jellyfin-icons', + directPlayPreferred: true, + directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'], + transcodeVideoCodec: 'h264', + }, + discordPresence: { + enabled: false, + updateIntervalMs: 3_000, + debounceMs: 750, + }, + youtubeSubgen: { + mode: 'automatic', + whisperBin: '', + whisperModel: '', + primarySubLanguages: ['ja', 'jpn'], + }, +}; diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts new file mode 100644 index 0000000..00706e9 --- /dev/null +++ b/src/config/definitions/defaults-subtitle.ts @@ -0,0 +1,42 @@ +import { ResolvedConfig } from '../../types'; + +export const SUBTITLE_DEFAULT_CONFIG: Pick = { + subtitleStyle: { + enableJlpt: false, + preserveLineBreaks: false, + hoverTokenColor: '#c6a0f6', + fontFamily: + 'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif', + fontSize: 35, + fontColor: '#cad3f5', + fontWeight: 'normal', + fontStyle: 'normal', + backgroundColor: 'rgb(30, 32, 48, 0.88)', + nPlusOneColor: '#c6a0f6', + knownWordColor: '#a6da95', + jlptColors: { + N1: '#ed8796', + N2: '#f5a97f', + N3: '#f9e2af', + N4: '#a6e3a1', + N5: '#8aadf4', + }, + frequencyDictionary: { + enabled: false, + sourcePath: '', + topX: 1000, + mode: 'single', + singleColor: '#f5a97f', + bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'], + }, + secondary: { + fontSize: 24, + fontColor: '#ffffff', + backgroundColor: 'transparent', + fontWeight: 'normal', + fontStyle: 'normal', + fontFamily: + 'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif', + }, + }, +}; diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts new file mode 100644 index 0000000..40f32ac --- /dev/null +++ b/src/config/definitions/domain-registry.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CONFIG_OPTION_REGISTRY, + CONFIG_TEMPLATE_SECTIONS, + DEFAULT_CONFIG, + RUNTIME_OPTION_REGISTRY, +} from '../definitions'; +import { buildCoreConfigOptionRegistry } from './options-core'; +import { buildImmersionConfigOptionRegistry } from './options-immersion'; +import { buildIntegrationConfigOptionRegistry } from './options-integrations'; +import { buildSubtitleConfigOptionRegistry } from './options-subtitle'; + +test('config option registry includes critical paths and has unique entries', () => { + const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path); + + for (const requiredPath of [ + 'logging.level', + 'subtitleStyle.enableJlpt', + 'ankiConnect.enabled', + 'immersionTracking.enabled', + ]) { + assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`); + } + + assert.equal(new Set(paths).size, paths.length); +}); + +test('config template sections include expected domains and unique keys', () => { + const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key); + const requiredKeys: (typeof keys)[number][] = [ + 'websocket', + 'subtitleStyle', + 'ankiConnect', + 'immersionTracking', + ]; + + for (const requiredKey of requiredKeys) { + assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`); + } + + assert.equal(new Set(keys).size, keys.length); +}); + +test('domain registry builders each contribute entries to composed registry', () => { + const domainEntries = [ + buildCoreConfigOptionRegistry(DEFAULT_CONFIG), + buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG), + buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY), + buildImmersionConfigOptionRegistry(DEFAULT_CONFIG), + ]; + const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path)); + + for (const entries of domainEntries) { + assert.ok(entries.length > 0); + assert.ok(entries.some((entry) => composedPaths.has(entry.path))); + } +}); diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts new file mode 100644 index 0000000..a2e44a1 --- /dev/null +++ b/src/config/definitions/options-core.ts @@ -0,0 +1,49 @@ +import { ResolvedConfig } from '../../types'; +import { ConfigOptionRegistryEntry } from './shared'; + +export function buildCoreConfigOptionRegistry( + defaultConfig: ResolvedConfig, +): ConfigOptionRegistryEntry[] { + return [ + { + path: 'logging.level', + kind: 'enum', + enumValues: ['debug', 'info', 'warn', 'error'], + defaultValue: defaultConfig.logging.level, + description: 'Minimum log level for runtime logging.', + }, + { + path: 'websocket.enabled', + kind: 'enum', + enumValues: ['auto', 'true', 'false'], + defaultValue: defaultConfig.websocket.enabled, + description: 'Built-in subtitle websocket server mode.', + }, + { + path: 'websocket.port', + kind: 'number', + defaultValue: defaultConfig.websocket.port, + description: 'Built-in subtitle websocket server port.', + }, + { + path: 'subsync.defaultMode', + kind: 'enum', + enumValues: ['auto', 'manual'], + defaultValue: defaultConfig.subsync.defaultMode, + description: 'Subsync default mode.', + }, + { + path: 'shortcuts.multiCopyTimeoutMs', + kind: 'number', + defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs, + description: 'Timeout for multi-copy/mine modes.', + }, + { + path: 'bind_visible_overlay_to_mpv_sub_visibility', + kind: 'boolean', + defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility, + description: + 'Link visible overlay toggles to MPV subtitle visibility (primary and secondary).', + }, + ]; +} diff --git a/src/config/definitions/options-immersion.ts b/src/config/definitions/options-immersion.ts new file mode 100644 index 0000000..ccd6a99 --- /dev/null +++ b/src/config/definitions/options-immersion.ts @@ -0,0 +1,82 @@ +import { ResolvedConfig } from '../../types'; +import { ConfigOptionRegistryEntry } from './shared'; + +export function buildImmersionConfigOptionRegistry( + defaultConfig: ResolvedConfig, +): ConfigOptionRegistryEntry[] { + return [ + { + path: 'immersionTracking.enabled', + kind: 'boolean', + defaultValue: defaultConfig.immersionTracking.enabled, + description: 'Enable immersion tracking for mined subtitle metadata.', + }, + { + path: 'immersionTracking.dbPath', + kind: 'string', + defaultValue: defaultConfig.immersionTracking.dbPath, + description: + 'Optional SQLite database path for immersion tracking. Empty value uses the default app data path.', + }, + { + path: 'immersionTracking.batchSize', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.batchSize, + description: 'Buffered telemetry/event writes per SQLite transaction.', + }, + { + path: 'immersionTracking.flushIntervalMs', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.flushIntervalMs, + description: 'Max delay before queue flush in milliseconds.', + }, + { + path: 'immersionTracking.queueCap', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.queueCap, + description: 'In-memory write queue cap before overflow policy applies.', + }, + { + path: 'immersionTracking.payloadCapBytes', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.payloadCapBytes, + description: 'Max JSON payload size per event before truncation.', + }, + { + path: 'immersionTracking.maintenanceIntervalMs', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.maintenanceIntervalMs, + description: 'Maintenance cadence (prune + rollup + vacuum checks).', + }, + { + path: 'immersionTracking.retention.eventsDays', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.retention.eventsDays, + description: 'Raw event retention window in days.', + }, + { + path: 'immersionTracking.retention.telemetryDays', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.retention.telemetryDays, + description: 'Telemetry retention window in days.', + }, + { + path: 'immersionTracking.retention.dailyRollupsDays', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.retention.dailyRollupsDays, + description: 'Daily rollup retention window in days.', + }, + { + path: 'immersionTracking.retention.monthlyRollupsDays', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.retention.monthlyRollupsDays, + description: 'Monthly rollup retention window in days.', + }, + { + path: 'immersionTracking.retention.vacuumIntervalDays', + kind: 'number', + defaultValue: defaultConfig.immersionTracking.retention.vacuumIntervalDays, + description: 'Minimum days between VACUUM runs.', + }, + ]; +} diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts new file mode 100644 index 0000000..f102207 --- /dev/null +++ b/src/config/definitions/options-integrations.ts @@ -0,0 +1,235 @@ +import { ResolvedConfig } from '../../types'; +import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared'; + +export function buildIntegrationConfigOptionRegistry( + defaultConfig: ResolvedConfig, + runtimeOptionRegistry: RuntimeOptionRegistryEntry[], +): ConfigOptionRegistryEntry[] { + return [ + { + path: 'ankiConnect.enabled', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.enabled, + description: 'Enable AnkiConnect integration.', + }, + { + path: 'ankiConnect.pollingRate', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.pollingRate, + description: 'Polling interval in milliseconds.', + }, + { + path: 'ankiConnect.tags', + kind: 'array', + defaultValue: defaultConfig.ankiConnect.tags, + description: + 'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.', + }, + { + path: 'ankiConnect.behavior.autoUpdateNewCards', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards, + description: 'Automatically update newly added cards.', + runtime: runtimeOptionRegistry[0], + }, + { + path: 'ankiConnect.nPlusOne.matchMode', + kind: 'enum', + enumValues: ['headword', 'surface'], + defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode, + description: 'Known-word matching strategy for N+1 highlighting.', + }, + { + path: 'ankiConnect.nPlusOne.highlightEnabled', + kind: 'boolean', + defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled, + description: 'Enable fast local highlighting for words already known in Anki.', + }, + { + path: 'ankiConnect.nPlusOne.refreshMinutes', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.nPlusOne.refreshMinutes, + description: 'Minutes between known-word cache refreshes.', + }, + { + path: 'ankiConnect.nPlusOne.minSentenceWords', + kind: 'number', + defaultValue: defaultConfig.ankiConnect.nPlusOne.minSentenceWords, + description: 'Minimum sentence word count required for N+1 targeting (default: 3).', + }, + { + path: 'ankiConnect.nPlusOne.decks', + kind: 'array', + defaultValue: defaultConfig.ankiConnect.nPlusOne.decks, + description: 'Decks used for N+1 known-word cache scope. Supports one or more deck names.', + }, + { + path: 'ankiConnect.nPlusOne.nPlusOne', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne, + description: 'Color used for the single N+1 target token highlight.', + }, + { + path: 'ankiConnect.nPlusOne.knownWord', + kind: 'string', + defaultValue: defaultConfig.ankiConnect.nPlusOne.knownWord, + description: 'Color used for legacy known-word highlights.', + }, + { + path: 'ankiConnect.isKiku.fieldGrouping', + kind: 'enum', + enumValues: ['auto', 'manual', 'disabled'], + defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping, + description: 'Kiku duplicate-card field grouping mode.', + runtime: runtimeOptionRegistry[1], + }, + { + path: 'jimaku.languagePreference', + kind: 'enum', + enumValues: ['ja', 'en', 'none'], + defaultValue: defaultConfig.jimaku.languagePreference, + description: 'Preferred language used in Jimaku search.', + }, + { + path: 'jimaku.maxEntryResults', + kind: 'number', + defaultValue: defaultConfig.jimaku.maxEntryResults, + description: 'Maximum Jimaku search results returned.', + }, + { + path: 'anilist.enabled', + kind: 'boolean', + defaultValue: defaultConfig.anilist.enabled, + description: 'Enable AniList post-watch progress updates.', + }, + { + path: 'anilist.accessToken', + kind: 'string', + defaultValue: defaultConfig.anilist.accessToken, + description: + 'Optional explicit AniList access token override; leave empty to use locally stored token from setup.', + }, + { + path: 'jellyfin.enabled', + kind: 'boolean', + defaultValue: defaultConfig.jellyfin.enabled, + description: 'Enable optional Jellyfin integration and CLI control commands.', + }, + { + path: 'jellyfin.serverUrl', + kind: 'string', + defaultValue: defaultConfig.jellyfin.serverUrl, + description: 'Base Jellyfin server URL (for example: http://localhost:8096).', + }, + { + path: 'jellyfin.username', + kind: 'string', + defaultValue: defaultConfig.jellyfin.username, + description: 'Default Jellyfin username used during CLI login.', + }, + { + path: 'jellyfin.defaultLibraryId', + kind: 'string', + defaultValue: defaultConfig.jellyfin.defaultLibraryId, + description: 'Optional default Jellyfin library ID for item listing.', + }, + { + path: 'jellyfin.remoteControlEnabled', + kind: 'boolean', + defaultValue: defaultConfig.jellyfin.remoteControlEnabled, + description: 'Enable Jellyfin remote cast control mode.', + }, + { + path: 'jellyfin.remoteControlAutoConnect', + kind: 'boolean', + defaultValue: defaultConfig.jellyfin.remoteControlAutoConnect, + description: 'Auto-connect to the configured remote control target.', + }, + { + path: 'jellyfin.autoAnnounce', + kind: 'boolean', + defaultValue: defaultConfig.jellyfin.autoAnnounce, + description: + 'When enabled, automatically trigger remote announce/visibility check on websocket connect.', + }, + { + path: 'jellyfin.remoteControlDeviceName', + kind: 'string', + defaultValue: defaultConfig.jellyfin.remoteControlDeviceName, + description: 'Device name reported for Jellyfin remote control sessions.', + }, + { + path: 'jellyfin.pullPictures', + kind: 'boolean', + defaultValue: defaultConfig.jellyfin.pullPictures, + description: 'Enable Jellyfin poster/icon fetching for launcher menus.', + }, + { + path: 'jellyfin.iconCacheDir', + kind: 'string', + defaultValue: defaultConfig.jellyfin.iconCacheDir, + description: 'Directory used by launcher for cached Jellyfin poster icons.', + }, + { + path: 'jellyfin.directPlayPreferred', + kind: 'boolean', + defaultValue: defaultConfig.jellyfin.directPlayPreferred, + description: 'Try direct play before server-managed transcoding when possible.', + }, + { + path: 'jellyfin.directPlayContainers', + kind: 'array', + defaultValue: defaultConfig.jellyfin.directPlayContainers, + description: 'Container allowlist for direct play decisions.', + }, + { + path: 'jellyfin.transcodeVideoCodec', + kind: 'string', + defaultValue: defaultConfig.jellyfin.transcodeVideoCodec, + description: 'Preferred transcode video codec when direct play is unavailable.', + }, + { + path: 'discordPresence.enabled', + kind: 'boolean', + defaultValue: defaultConfig.discordPresence.enabled, + description: 'Enable optional Discord Rich Presence updates.', + }, + { + path: 'discordPresence.updateIntervalMs', + kind: 'number', + defaultValue: defaultConfig.discordPresence.updateIntervalMs, + description: 'Minimum interval between presence payload updates.', + }, + { + path: 'discordPresence.debounceMs', + kind: 'number', + defaultValue: defaultConfig.discordPresence.debounceMs, + description: 'Debounce delay used to collapse bursty presence updates.', + }, + { + path: 'youtubeSubgen.mode', + kind: 'enum', + enumValues: ['automatic', 'preprocess', 'off'], + defaultValue: defaultConfig.youtubeSubgen.mode, + description: 'YouTube subtitle generation mode for the launcher script.', + }, + { + path: 'youtubeSubgen.whisperBin', + kind: 'string', + defaultValue: defaultConfig.youtubeSubgen.whisperBin, + description: 'Path to whisper.cpp CLI used as fallback transcription engine.', + }, + { + path: 'youtubeSubgen.whisperModel', + kind: 'string', + defaultValue: defaultConfig.youtubeSubgen.whisperModel, + description: 'Path to whisper model used for fallback transcription.', + }, + { + path: 'youtubeSubgen.primarySubLanguages', + kind: 'string', + defaultValue: defaultConfig.youtubeSubgen.primarySubLanguages.join(','), + description: 'Comma-separated primary subtitle language priority used by the launcher.', + }, + ]; +} diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts new file mode 100644 index 0000000..e7d5bf7 --- /dev/null +++ b/src/config/definitions/options-subtitle.ts @@ -0,0 +1,72 @@ +import { ResolvedConfig } from '../../types'; +import { ConfigOptionRegistryEntry } from './shared'; + +export function buildSubtitleConfigOptionRegistry( + defaultConfig: ResolvedConfig, +): ConfigOptionRegistryEntry[] { + return [ + { + path: 'subtitleStyle.enableJlpt', + kind: 'boolean', + defaultValue: defaultConfig.subtitleStyle.enableJlpt, + description: + 'Enable JLPT vocabulary level underlines. ' + + 'When disabled, JLPT tagging lookup and underlines are skipped.', + }, + { + path: 'subtitleStyle.preserveLineBreaks', + kind: 'boolean', + defaultValue: defaultConfig.subtitleStyle.preserveLineBreaks, + description: + 'Preserve line breaks in visible overlay subtitle rendering. ' + + 'When false, line breaks are flattened to spaces for a single-line flow.', + }, + { + path: 'subtitleStyle.hoverTokenColor', + kind: 'string', + defaultValue: defaultConfig.subtitleStyle.hoverTokenColor, + description: 'Hex color used for hovered subtitle token highlight in mpv.', + }, + { + path: 'subtitleStyle.frequencyDictionary.enabled', + kind: 'boolean', + defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.enabled, + description: 'Enable frequency-dictionary-based highlighting based on token rank.', + }, + { + path: 'subtitleStyle.frequencyDictionary.sourcePath', + kind: 'string', + defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.sourcePath, + description: + 'Optional absolute path to a frequency dictionary directory.' + + ' If empty, built-in discovery search paths are used.', + }, + { + path: 'subtitleStyle.frequencyDictionary.topX', + kind: 'number', + defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX, + description: 'Only color tokens with frequency rank <= topX (default: 1000).', + }, + { + path: 'subtitleStyle.frequencyDictionary.mode', + kind: 'enum', + enumValues: ['single', 'banded'], + defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.mode, + description: + 'single: use one color for all matching tokens. banded: use color ramp by frequency band.', + }, + { + path: 'subtitleStyle.frequencyDictionary.singleColor', + kind: 'string', + defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.singleColor, + description: 'Color used when frequencyDictionary.mode is `single`.', + }, + { + path: 'subtitleStyle.frequencyDictionary.bandedColors', + kind: 'array', + defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.bandedColors, + description: + 'Five colors used for rank bands when mode is `banded` (from most common to least within topX).', + }, + ]; +} diff --git a/src/config/definitions/runtime-options.ts b/src/config/definitions/runtime-options.ts new file mode 100644 index 0000000..e35dade --- /dev/null +++ b/src/config/definitions/runtime-options.ts @@ -0,0 +1,56 @@ +import { ResolvedConfig } from '../../types'; +import { RuntimeOptionRegistryEntry } from './shared'; + +export function buildRuntimeOptionRegistry( + defaultConfig: ResolvedConfig, +): RuntimeOptionRegistryEntry[] { + return [ + { + id: 'anki.autoUpdateNewCards', + path: 'ankiConnect.behavior.autoUpdateNewCards', + label: 'Auto Update New Cards', + scope: 'ankiConnect', + valueType: 'boolean', + allowedValues: [true, false], + defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards, + requiresRestart: false, + formatValueForOsd: (value) => (value === true ? 'On' : 'Off'), + toAnkiPatch: (value) => ({ + behavior: { autoUpdateNewCards: value === true }, + }), + }, + { + id: 'anki.nPlusOneMatchMode', + path: 'ankiConnect.nPlusOne.matchMode', + label: 'N+1 Match Mode', + scope: 'ankiConnect', + valueType: 'enum', + allowedValues: ['headword', 'surface'], + defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode, + requiresRestart: false, + formatValueForOsd: (value) => String(value), + toAnkiPatch: (value) => ({ + nPlusOne: { + matchMode: value === 'headword' || value === 'surface' ? value : 'headword', + }, + }), + }, + { + id: 'anki.kikuFieldGrouping', + path: 'ankiConnect.isKiku.fieldGrouping', + label: 'Kiku Field Grouping', + scope: 'ankiConnect', + valueType: 'enum', + allowedValues: ['auto', 'manual', 'disabled'], + defaultValue: 'disabled', + requiresRestart: false, + formatValueForOsd: (value) => String(value), + toAnkiPatch: (value) => ({ + isKiku: { + fieldGrouping: + value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'disabled', + }, + }), + }, + ]; +} diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts new file mode 100644 index 0000000..ad648a5 --- /dev/null +++ b/src/config/definitions/shared.ts @@ -0,0 +1,61 @@ +import { + AnkiConnectConfig, + ResolvedConfig, + RuntimeOptionId, + RuntimeOptionScope, + RuntimeOptionValue, + RuntimeOptionValueType, +} from '../../types'; + +export type ConfigValueKind = 'boolean' | 'number' | 'string' | 'enum' | 'array' | 'object'; + +export interface RuntimeOptionRegistryEntry { + id: RuntimeOptionId; + path: string; + label: string; + scope: RuntimeOptionScope; + valueType: RuntimeOptionValueType; + allowedValues: RuntimeOptionValue[]; + defaultValue: RuntimeOptionValue; + requiresRestart: boolean; + formatValueForOsd: (value: RuntimeOptionValue) => string; + toAnkiPatch: (value: RuntimeOptionValue) => Partial; +} + +export interface ConfigOptionRegistryEntry { + path: string; + kind: ConfigValueKind; + defaultValue: unknown; + description: string; + enumValues?: readonly string[]; + runtime?: RuntimeOptionRegistryEntry; +} + +export interface ConfigTemplateSection { + title: string; + description: string[]; + key: keyof ResolvedConfig; + notes?: string[]; +} + +export const SPECIAL_COMMANDS = { + SUBSYNC_TRIGGER: '__subsync-trigger', + RUNTIME_OPTIONS_OPEN: '__runtime-options-open', + RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', + REPLAY_SUBTITLE: '__replay-subtitle', + PLAY_NEXT_SUBTITLE: '__play-next-subtitle', +} as const; + +export const DEFAULT_KEYBINDINGS: NonNullable = [ + { key: 'Space', command: ['cycle', 'pause'] }, + { key: 'ArrowRight', command: ['seek', 5] }, + { key: 'ArrowLeft', command: ['seek', -5] }, + { key: 'ArrowUp', command: ['seek', 60] }, + { key: 'ArrowDown', command: ['seek', -60] }, + { key: 'Shift+KeyH', command: ['sub-seek', -1] }, + { key: 'Shift+KeyL', command: ['sub-seek', 1] }, + { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, + { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, + { key: 'KeyQ', command: ['quit'] }, + { key: 'Ctrl+KeyW', command: ['quit'] }, +]; diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts new file mode 100644 index 0000000..a4a5a4f --- /dev/null +++ b/src/config/definitions/template-sections.ts @@ -0,0 +1,154 @@ +import { ConfigTemplateSection } from './shared'; + +const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ + { + title: 'Overlay Auto-Start', + description: [ + 'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.', + ], + key: 'auto_start_overlay', + }, + { + title: 'Visible Overlay Subtitle Binding', + description: [ + 'Control whether visible overlay toggles also toggle MPV subtitle visibility.', + 'When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.', + ], + key: 'bind_visible_overlay_to_mpv_sub_visibility', + }, + { + title: 'Texthooker Server', + description: ['Control whether browser opens automatically for texthooker.'], + key: 'texthooker', + }, + { + title: 'WebSocket Server', + description: [ + 'Built-in WebSocket server broadcasts subtitle text to connected clients.', + 'Auto mode disables built-in server if mpv_websocket is detected.', + ], + key: 'websocket', + }, + { + title: 'Logging', + description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], + key: 'logging', + }, + { + title: 'Keyboard Shortcuts', + description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'], + notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'], + key: 'shortcuts', + }, + { + title: 'Invisible Overlay', + description: ['Startup behavior for the invisible interactive subtitle mining layer.'], + notes: [ + 'Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.', + 'This edit-mode shortcut is fixed and is not currently configurable.', + ], + key: 'invisibleOverlay', + }, + { + title: 'Keybindings (MPV Commands)', + description: [ + 'Extra keybindings that are merged with built-in defaults.', + 'Set command to null to disable a default keybinding.', + ], + notes: [ + 'Hot-reload: keybinding changes apply live and update the session help modal on reopen.', + ], + key: 'keybindings', + }, + { + title: 'Secondary Subtitles', + description: [ + 'Dual subtitle track options.', + 'Used by subminer YouTube subtitle generation as secondary language preferences.', + ], + notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'], + key: 'secondarySub', + }, + { + title: 'Auto Subtitle Sync', + description: ['Subsync engine and executable paths.'], + key: 'subsync', + }, + { + title: 'Subtitle Position', + description: ['Initial vertical subtitle position from the bottom.'], + key: 'subtitlePosition', + }, +]; + +const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ + { + title: 'Subtitle Appearance', + description: ['Primary and secondary subtitle styling.'], + notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'], + key: 'subtitleStyle', + }, +]; + +const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ + { + title: 'AnkiConnect Integration', + description: ['Automatic Anki updates and media generation options.'], + notes: [ + 'Hot-reload: AI translation settings update live while SubMiner is running.', + 'Most other AnkiConnect settings still require restart.', + ], + key: 'ankiConnect', + }, + { + title: 'Jimaku', + description: ['Jimaku API configuration and defaults.'], + key: 'jimaku', + }, + { + title: 'YouTube Subtitle Generation', + description: ['Defaults for subminer YouTube subtitle extraction/transcription mode.'], + key: 'youtubeSubgen', + }, + { + title: 'Anilist', + description: ['Anilist API credentials and update behavior.'], + key: 'anilist', + }, + { + title: 'Jellyfin', + description: [ + 'Optional Jellyfin integration for auth, browsing, and playback launch.', + 'Access token is stored in local encrypted token storage after login/setup.', + 'jellyfin.accessToken remains an optional explicit override in config.', + ], + key: 'jellyfin', + }, + { + title: 'Discord Rich Presence', + description: [ + 'Optional Discord Rich Presence activity card updates for current playback/study session.', + 'Uses official SubMiner Discord app assets for polished card visuals.', + ], + key: 'discordPresence', + }, +]; + +const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ + { + title: 'Immersion Tracking', + description: [ + 'Enable/disable immersion tracking.', + 'Set dbPath to override the default sqlite database location.', + 'Policy tuning is available for queue, flush, and retention values.', + ], + key: 'immersionTracking', + }, +]; + +export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ + ...CORE_TEMPLATE_SECTIONS, + ...SUBTITLE_TEMPLATE_SECTIONS, + ...INTEGRATION_TEMPLATE_SECTIONS, + ...IMMERSION_TEMPLATE_SECTIONS, +]; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..e8bf318 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,3 @@ +export * from './definitions'; +export * from './service'; +export * from './template'; diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..d8a4bc1 --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs'; +import { RawConfig } from '../types'; +import { parseConfigContent } from './parse'; + +export interface ConfigPaths { + configDir: string; + configFileJsonc: string; + configFileJson: string; +} + +export interface LoadResult { + config: RawConfig; + path: string; +} + +export type StrictLoadResult = + | (LoadResult & { ok: true }) + | { + ok: false; + error: string; + path: string; + }; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function resolveExistingConfigPath(paths: ConfigPaths): string { + if (fs.existsSync(paths.configFileJsonc)) { + return paths.configFileJsonc; + } + if (fs.existsSync(paths.configFileJson)) { + return paths.configFileJson; + } + return paths.configFileJsonc; +} + +export function loadRawConfigStrict(paths: ConfigPaths): StrictLoadResult { + const configPath = resolveExistingConfigPath(paths); + + if (!fs.existsSync(configPath)) { + return { ok: true, config: {}, path: configPath }; + } + + try { + const data = fs.readFileSync(configPath, 'utf-8'); + const parsed = parseConfigContent(configPath, data); + return { + ok: true, + config: isObject(parsed) ? (parsed as RawConfig) : {}, + path: configPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown parse error'; + return { ok: false, error: message, path: configPath }; + } +} + +export function loadRawConfig(paths: ConfigPaths): LoadResult { + const strictResult = loadRawConfigStrict(paths); + if (strictResult.ok) { + return strictResult; + } + return { config: {}, path: strictResult.path }; +} diff --git a/src/config/parse.ts b/src/config/parse.ts new file mode 100644 index 0000000..2bc063a --- /dev/null +++ b/src/config/parse.ts @@ -0,0 +1,17 @@ +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; + +export function parseConfigContent(configPath: string, data: string): unknown { + if (!configPath.endsWith('.jsonc')) { + return JSON.parse(data); + } + + const errors: ParseError[] = []; + const result = parseJsonc(data, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`); + } + return result; +} diff --git a/src/config/path-resolution.test.ts b/src/config/path-resolution.test.ts new file mode 100644 index 0000000..abd06cd --- /dev/null +++ b/src/config/path-resolution.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { resolveConfigBaseDirs, resolveConfigDir, resolveConfigFilePath } from './path-resolution'; + +function existsSyncFrom(paths: string[]): (candidate: string) => boolean { + const normalized = new Set(paths.map((entry) => path.normalize(entry))); + return (candidate: string): boolean => normalized.has(path.normalize(candidate)); +} + +test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => { + const homeDir = '/home/tester'; + const baseDirs = resolveConfigBaseDirs(' /home/tester/.config ', homeDir); + assert.deepEqual(baseDirs, [path.join(homeDir, '.config')]); +}); + +test('resolveConfigDir prefers xdg SubMiner config when present', () => { + const homeDir = '/home/tester'; + const xdgConfigHome = '/tmp/xdg-config'; + const configDir = path.join(xdgConfigHome, 'SubMiner'); + const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]); + + const resolved = resolveConfigDir({ + xdgConfigHome, + homeDir, + existsSync, + }); + + assert.equal(resolved, configDir); +}); + +test('resolveConfigDir ignores lowercase subminer candidate', () => { + const homeDir = '/home/tester'; + const lowercaseConfigDir = path.join(homeDir, '.config', 'subminer'); + const existsSync = existsSyncFrom([path.join(lowercaseConfigDir, 'config.json')]); + + const resolved = resolveConfigDir({ + xdgConfigHome: '/tmp/missing-xdg', + homeDir, + existsSync, + }); + + assert.equal(resolved, '/tmp/missing-xdg/SubMiner'); +}); + +test('resolveConfigDir falls back to existing directory when file is missing', () => { + const homeDir = '/home/tester'; + const configDir = path.join(homeDir, '.config', 'SubMiner'); + const existsSync = existsSyncFrom([configDir]); + + const resolved = resolveConfigDir({ + xdgConfigHome: '/tmp/missing-xdg', + homeDir, + existsSync, + }); + + assert.equal(resolved, configDir); +}); + +test('resolveConfigFilePath prefers jsonc before json', () => { + const homeDir = '/home/tester'; + const xdgConfigHome = '/tmp/xdg-config'; + const existsSync = existsSyncFrom([ + path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), + path.join(xdgConfigHome, 'SubMiner', 'config.json'), + ]); + + const resolved = resolveConfigFilePath({ + xdgConfigHome, + homeDir, + existsSync, + }); + + assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc')); +}); + +test('resolveConfigFilePath keeps legacy fallback output path', () => { + const homeDir = '/home/tester'; + const xdgConfigHome = '/tmp/xdg-config'; + const existsSync = existsSyncFrom([]); + + const resolved = resolveConfigFilePath({ + xdgConfigHome, + homeDir, + existsSync, + }); + + assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc')); +}); diff --git a/src/config/path-resolution.ts b/src/config/path-resolution.ts new file mode 100644 index 0000000..ddd7469 --- /dev/null +++ b/src/config/path-resolution.ts @@ -0,0 +1,76 @@ +import path from 'node:path'; + +type ExistsSync = (candidate: string) => boolean; + +type ConfigPathOptions = { + xdgConfigHome?: string; + homeDir: string; + existsSync: ExistsSync; + appNames?: readonly string[]; + defaultAppName?: string; +}; + +const DEFAULT_APP_NAMES = ['SubMiner'] as const; +const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const; + +export function resolveConfigBaseDirs( + xdgConfigHome: string | undefined, + homeDir: string, +): string[] { + const fallbackBaseDir = path.join(homeDir, '.config'); + const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir; + return Array.from(new Set([primaryBaseDir, fallbackBaseDir])); +} + +function getAppNames(options: ConfigPathOptions): readonly string[] { + return options.appNames ?? DEFAULT_APP_NAMES; +} + +function getDefaultAppName(options: ConfigPathOptions): string { + return options.defaultAppName ?? DEFAULT_APP_NAMES[0]; +} + +export function resolveConfigDir(options: ConfigPathOptions): string { + const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir); + const appNames = getAppNames(options); + + for (const baseDir of baseDirs) { + for (const appName of appNames) { + const dir = path.join(baseDir, appName); + for (const fileName of DEFAULT_FILE_NAMES) { + if (options.existsSync(path.join(dir, fileName))) { + return dir; + } + } + } + } + + for (const baseDir of baseDirs) { + for (const appName of appNames) { + const dir = path.join(baseDir, appName); + if (options.existsSync(dir)) { + return dir; + } + } + } + + return path.join(baseDirs[0]!, getDefaultAppName(options)); +} + +export function resolveConfigFilePath(options: ConfigPathOptions): string { + const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir); + const appNames = getAppNames(options); + + for (const baseDir of baseDirs) { + for (const appName of appNames) { + for (const fileName of DEFAULT_FILE_NAMES) { + const candidate = path.join(baseDir, appName, fileName); + if (options.existsSync(candidate)) { + return candidate; + } + } + } + } + + return path.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!); +} diff --git a/src/config/resolve.ts b/src/config/resolve.ts new file mode 100644 index 0000000..d8eed5a --- /dev/null +++ b/src/config/resolve.ts @@ -0,0 +1,33 @@ +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; +import { applyAnkiConnectResolution } from './resolve/anki-connect'; +import { createResolveContext } from './resolve/context'; +import { applyCoreDomainConfig } from './resolve/core-domains'; +import { applyImmersionTrackingConfig } from './resolve/immersion-tracking'; +import { applyIntegrationConfig } from './resolve/integrations'; +import { applySubtitleDomainConfig } from './resolve/subtitle-domains'; +import { applyTopLevelConfig } from './resolve/top-level'; + +const APPLY_RESOLVE_STEPS = [ + applyTopLevelConfig, + applyCoreDomainConfig, + applySubtitleDomainConfig, + applyIntegrationConfig, + applyImmersionTrackingConfig, + applyAnkiConnectResolution, +] as const; + +export function resolveConfig(raw: RawConfig): { + resolved: ResolvedConfig; + warnings: ConfigValidationWarning[]; +} { + const { context, warnings } = createResolveContext(raw); + + for (const applyStep of APPLY_RESOLVE_STEPS) { + applyStep(context); + } + + return { + resolved: context.resolved, + warnings, + }; +} diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts new file mode 100644 index 0000000..0b7a1cd --- /dev/null +++ b/src/config/resolve/anki-connect.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions'; +import { createWarningCollector } from '../warnings'; +import { applyAnkiConnectResolution } from './anki-connect'; +import type { ResolveContext } from './context'; + +function makeContext(ankiConnect: unknown): { + context: ResolveContext; + warnings: ReturnType['warnings']; +} { + const { warnings, warn } = createWarningCollector(); + const resolved = deepCloneConfig(DEFAULT_CONFIG); + const context = { + src: { ankiConnect }, + resolved, + warn, + } as unknown as ResolveContext; + + return { context, warnings }; +} + +test('modern invalid nPlusOne.highlightEnabled warns modern key and does not fallback to legacy', () => { + const { context, warnings } = makeContext({ + behavior: { nPlusOneHighlightEnabled: true }, + nPlusOne: { highlightEnabled: 'yes' }, + }); + + applyAnkiConnectResolution(context); + + assert.equal( + context.resolved.ankiConnect.nPlusOne.highlightEnabled, + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled')); + assert.equal( + warnings.some((warning) => warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled'), + false, + ); +}); + +test('normalizes ankiConnect tags by trimming and deduping', () => { + const { context, warnings } = makeContext({ + tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '], + }); + + applyAnkiConnectResolution(context); + + assert.deepEqual(context.resolved.ankiConnect.tags, ['SubMiner', 'Mining']); + assert.equal( + warnings.some((warning) => warning.path === 'ankiConnect.tags'), + false, + ); +}); + +test('warns and falls back for invalid nPlusOne.decks entries', () => { + const { context, warnings } = makeContext({ + nPlusOne: { decks: ['Core Deck', 123] }, + }); + + applyAnkiConnectResolution(context); + + assert.deepEqual( + context.resolved.ankiConnect.nPlusOne.decks, + DEFAULT_CONFIG.ankiConnect.nPlusOne.decks, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks')); +}); diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts new file mode 100644 index 0000000..f88d7e6 --- /dev/null +++ b/src/config/resolve/anki-connect.ts @@ -0,0 +1,728 @@ +import { DEFAULT_CONFIG } from '../definitions'; +import type { ResolveContext } from './context'; +import { asBoolean, asColor, asNumber, asString, isObject } from './shared'; + +export function applyAnkiConnectResolution(context: ResolveContext): void { + if (!isObject(context.src.ankiConnect)) { + return; + } + + const ac = context.src.ankiConnect; + const behavior = isObject(ac.behavior) ? (ac.behavior as Record) : {}; + const fields = isObject(ac.fields) ? (ac.fields as Record) : {}; + const media = isObject(ac.media) ? (ac.media as Record) : {}; + const metadata = isObject(ac.metadata) ? (ac.metadata as Record) : {}; + const aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {}; + const legacyKeys = new Set([ + 'audioField', + 'imageField', + 'sentenceField', + 'miscInfoField', + 'miscInfoPattern', + 'generateAudio', + 'generateImage', + 'imageType', + 'imageFormat', + 'imageQuality', + 'imageMaxWidth', + 'imageMaxHeight', + 'animatedFps', + 'animatedMaxWidth', + 'animatedMaxHeight', + 'animatedCrf', + 'audioPadding', + 'fallbackDuration', + 'maxMediaDuration', + 'overwriteAudio', + 'overwriteImage', + 'mediaInsertMode', + 'highlightWord', + 'notificationType', + 'autoUpdateNewCards', + ]); + + if (ac.openRouter !== undefined) { + context.warn( + 'ankiConnect.openRouter', + ac.openRouter, + context.resolved.ankiConnect.ai, + 'Deprecated key; use ankiConnect.ai instead.', + ); + } + + const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = ac as Record< + string, + unknown + >; + const ankiConnectWithoutLegacy = Object.fromEntries( + Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)), + ); + + context.resolved.ankiConnect = { + ...context.resolved.ankiConnect, + ...(isObject(ankiConnectWithoutLegacy) + ? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>) + : {}), + fields: { + ...context.resolved.ankiConnect.fields, + ...(isObject(ac.fields) + ? (ac.fields as (typeof context.resolved)['ankiConnect']['fields']) + : {}), + }, + ai: { + ...context.resolved.ankiConnect.ai, + ...(aiSource as (typeof context.resolved)['ankiConnect']['ai']), + }, + media: { + ...context.resolved.ankiConnect.media, + ...(isObject(ac.media) + ? (ac.media as (typeof context.resolved)['ankiConnect']['media']) + : {}), + }, + behavior: { + ...context.resolved.ankiConnect.behavior, + ...(isObject(ac.behavior) + ? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior']) + : {}), + }, + metadata: { + ...context.resolved.ankiConnect.metadata, + ...(isObject(ac.metadata) + ? (ac.metadata as (typeof context.resolved)['ankiConnect']['metadata']) + : {}), + }, + isLapis: { + ...context.resolved.ankiConnect.isLapis, + }, + isKiku: { + ...context.resolved.ankiConnect.isKiku, + ...(isObject(ac.isKiku) + ? (ac.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) + : {}), + }, + }; + + if (isObject(ac.isLapis)) { + const lapisEnabled = asBoolean(ac.isLapis.enabled); + if (lapisEnabled !== undefined) { + context.resolved.ankiConnect.isLapis.enabled = lapisEnabled; + } else if (ac.isLapis.enabled !== undefined) { + context.warn( + 'ankiConnect.isLapis.enabled', + ac.isLapis.enabled, + context.resolved.ankiConnect.isLapis.enabled, + 'Expected boolean.', + ); + } + + const sentenceCardModel = asString(ac.isLapis.sentenceCardModel); + if (sentenceCardModel !== undefined) { + context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; + } else if (ac.isLapis.sentenceCardModel !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardModel', + ac.isLapis.sentenceCardModel, + context.resolved.ankiConnect.isLapis.sentenceCardModel, + 'Expected string.', + ); + } + + if (ac.isLapis.sentenceCardSentenceField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardSentenceField', + ac.isLapis.sentenceCardSentenceField, + 'Sentence', + 'Deprecated key; sentence-card sentence field is fixed to Sentence.', + ); + } + + if (ac.isLapis.sentenceCardAudioField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardAudioField', + ac.isLapis.sentenceCardAudioField, + 'SentenceAudio', + 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', + ); + } + } else if (ac.isLapis !== undefined) { + context.warn( + 'ankiConnect.isLapis', + ac.isLapis, + context.resolved.ankiConnect.isLapis, + 'Expected object.', + ); + } + + if (Array.isArray(ac.tags)) { + const normalizedTags = ac.tags + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (normalizedTags.length === ac.tags.length) { + context.resolved.ankiConnect.tags = [...new Set(normalizedTags)]; + } else { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ac.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of non-empty strings.', + ); + } + } else if (ac.tags !== undefined) { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ac.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of strings.', + ); + } + + const legacy = ac as Record; + const hasOwn = (obj: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(obj, key); + const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) { + return undefined; + } + return parsed; + }; + const asPositiveInteger = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) { + return undefined; + } + return parsed; + }; + const asPositiveNumber = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || parsed <= 0) { + return undefined; + } + return parsed; + }; + const asNonNegativeNumber = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || parsed < 0) { + return undefined; + } + return parsed; + }; + const asImageType = (value: unknown): 'static' | 'avif' | undefined => { + return value === 'static' || value === 'avif' ? value : undefined; + }; + const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => { + return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined; + }; + const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => { + return value === 'append' || value === 'prepend' ? value : undefined; + }; + const asNotificationType = (value: unknown): 'osd' | 'system' | 'both' | 'none' | undefined => { + return value === 'osd' || value === 'system' || value === 'both' || value === 'none' + ? value + : undefined; + }; + const mapLegacy = ( + key: string, + parse: (value: unknown) => T | undefined, + apply: (value: T) => void, + fallback: unknown, + message: string, + ): void => { + const value = legacy[key]; + if (value === undefined) return; + const parsed = parse(value); + if (parsed === undefined) { + context.warn(`ankiConnect.${key}`, value, fallback, message); + return; + } + apply(parsed); + }; + + if (!hasOwn(fields, 'audio')) { + mapLegacy( + 'audioField', + asString, + (value) => { + context.resolved.ankiConnect.fields.audio = value; + }, + context.resolved.ankiConnect.fields.audio, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'image')) { + mapLegacy( + 'imageField', + asString, + (value) => { + context.resolved.ankiConnect.fields.image = value; + }, + context.resolved.ankiConnect.fields.image, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'sentence')) { + mapLegacy( + 'sentenceField', + asString, + (value) => { + context.resolved.ankiConnect.fields.sentence = value; + }, + context.resolved.ankiConnect.fields.sentence, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'miscInfo')) { + mapLegacy( + 'miscInfoField', + asString, + (value) => { + context.resolved.ankiConnect.fields.miscInfo = value; + }, + context.resolved.ankiConnect.fields.miscInfo, + 'Expected string.', + ); + } + if (!hasOwn(metadata, 'pattern')) { + mapLegacy( + 'miscInfoPattern', + asString, + (value) => { + context.resolved.ankiConnect.metadata.pattern = value; + }, + context.resolved.ankiConnect.metadata.pattern, + 'Expected string.', + ); + } + if (!hasOwn(media, 'generateAudio')) { + mapLegacy( + 'generateAudio', + asBoolean, + (value) => { + context.resolved.ankiConnect.media.generateAudio = value; + }, + context.resolved.ankiConnect.media.generateAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'generateImage')) { + mapLegacy( + 'generateImage', + asBoolean, + (value) => { + context.resolved.ankiConnect.media.generateImage = value; + }, + context.resolved.ankiConnect.media.generateImage, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'imageType')) { + mapLegacy( + 'imageType', + asImageType, + (value) => { + context.resolved.ankiConnect.media.imageType = value; + }, + context.resolved.ankiConnect.media.imageType, + "Expected 'static' or 'avif'.", + ); + } + if (!hasOwn(media, 'imageFormat')) { + mapLegacy( + 'imageFormat', + asImageFormat, + (value) => { + context.resolved.ankiConnect.media.imageFormat = value; + }, + context.resolved.ankiConnect.media.imageFormat, + "Expected 'jpg', 'png', or 'webp'.", + ); + } + if (!hasOwn(media, 'imageQuality')) { + mapLegacy( + 'imageQuality', + (value) => asIntegerInRange(value, 1, 100), + (value) => { + context.resolved.ankiConnect.media.imageQuality = value; + }, + context.resolved.ankiConnect.media.imageQuality, + 'Expected integer between 1 and 100.', + ); + } + if (!hasOwn(media, 'imageMaxWidth')) { + mapLegacy( + 'imageMaxWidth', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.imageMaxWidth = value; + }, + context.resolved.ankiConnect.media.imageMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'imageMaxHeight')) { + mapLegacy( + 'imageMaxHeight', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.imageMaxHeight = value; + }, + context.resolved.ankiConnect.media.imageMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedFps')) { + mapLegacy( + 'animatedFps', + (value) => asIntegerInRange(value, 1, 60), + (value) => { + context.resolved.ankiConnect.media.animatedFps = value; + }, + context.resolved.ankiConnect.media.animatedFps, + 'Expected integer between 1 and 60.', + ); + } + if (!hasOwn(media, 'animatedMaxWidth')) { + mapLegacy( + 'animatedMaxWidth', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.animatedMaxWidth = value; + }, + context.resolved.ankiConnect.media.animatedMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedMaxHeight')) { + mapLegacy( + 'animatedMaxHeight', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.animatedMaxHeight = value; + }, + context.resolved.ankiConnect.media.animatedMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedCrf')) { + mapLegacy( + 'animatedCrf', + (value) => asIntegerInRange(value, 0, 63), + (value) => { + context.resolved.ankiConnect.media.animatedCrf = value; + }, + context.resolved.ankiConnect.media.animatedCrf, + 'Expected integer between 0 and 63.', + ); + } + if (!hasOwn(media, 'audioPadding')) { + mapLegacy( + 'audioPadding', + asNonNegativeNumber, + (value) => { + context.resolved.ankiConnect.media.audioPadding = value; + }, + context.resolved.ankiConnect.media.audioPadding, + 'Expected non-negative number.', + ); + } + if (!hasOwn(media, 'fallbackDuration')) { + mapLegacy( + 'fallbackDuration', + asPositiveNumber, + (value) => { + context.resolved.ankiConnect.media.fallbackDuration = value; + }, + context.resolved.ankiConnect.media.fallbackDuration, + 'Expected positive number.', + ); + } + if (!hasOwn(media, 'maxMediaDuration')) { + mapLegacy( + 'maxMediaDuration', + asNonNegativeNumber, + (value) => { + context.resolved.ankiConnect.media.maxMediaDuration = value; + }, + context.resolved.ankiConnect.media.maxMediaDuration, + 'Expected non-negative number.', + ); + } + if (!hasOwn(behavior, 'overwriteAudio')) { + mapLegacy( + 'overwriteAudio', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.overwriteAudio = value; + }, + context.resolved.ankiConnect.behavior.overwriteAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'overwriteImage')) { + mapLegacy( + 'overwriteImage', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.overwriteImage = value; + }, + context.resolved.ankiConnect.behavior.overwriteImage, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'mediaInsertMode')) { + mapLegacy( + 'mediaInsertMode', + asMediaInsertMode, + (value) => { + context.resolved.ankiConnect.behavior.mediaInsertMode = value; + }, + context.resolved.ankiConnect.behavior.mediaInsertMode, + "Expected 'append' or 'prepend'.", + ); + } + if (!hasOwn(behavior, 'highlightWord')) { + mapLegacy( + 'highlightWord', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.highlightWord = value; + }, + context.resolved.ankiConnect.behavior.highlightWord, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'notificationType')) { + mapLegacy( + 'notificationType', + asNotificationType, + (value) => { + context.resolved.ankiConnect.behavior.notificationType = value; + }, + context.resolved.ankiConnect.behavior.notificationType, + "Expected 'osd', 'system', 'both', or 'none'.", + ); + } + if (!hasOwn(behavior, 'autoUpdateNewCards')) { + mapLegacy( + 'autoUpdateNewCards', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.autoUpdateNewCards = value; + }, + context.resolved.ankiConnect.behavior.autoUpdateNewCards, + 'Expected boolean.', + ); + } + + const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; + + const nPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled); + if (nPlusOneHighlightEnabled !== undefined) { + context.resolved.ankiConnect.nPlusOne.highlightEnabled = nPlusOneHighlightEnabled; + } else if (nPlusOneConfig.highlightEnabled !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.highlightEnabled', + nPlusOneConfig.highlightEnabled, + context.resolved.ankiConnect.nPlusOne.highlightEnabled, + 'Expected boolean.', + ); + context.resolved.ankiConnect.nPlusOne.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; + } else { + const legacyNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled); + if (legacyNPlusOneHighlightEnabled !== undefined) { + context.resolved.ankiConnect.nPlusOne.highlightEnabled = legacyNPlusOneHighlightEnabled; + context.warn( + 'ankiConnect.behavior.nPlusOneHighlightEnabled', + behavior.nPlusOneHighlightEnabled, + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled', + ); + } else { + context.resolved.ankiConnect.nPlusOne.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; + } + } + + const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes); + const hasValidNPlusOneRefreshMinutes = + nPlusOneRefreshMinutes !== undefined && + Number.isInteger(nPlusOneRefreshMinutes) && + nPlusOneRefreshMinutes > 0; + if (nPlusOneRefreshMinutes !== undefined) { + if (hasValidNPlusOneRefreshMinutes) { + context.resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes; + } else { + context.warn( + 'ankiConnect.nPlusOne.refreshMinutes', + nPlusOneConfig.refreshMinutes, + context.resolved.ankiConnect.nPlusOne.refreshMinutes, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + } else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) { + const legacyNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes); + const hasValidLegacyRefreshMinutes = + legacyNPlusOneRefreshMinutes !== undefined && + Number.isInteger(legacyNPlusOneRefreshMinutes) && + legacyNPlusOneRefreshMinutes > 0; + if (hasValidLegacyRefreshMinutes) { + context.resolved.ankiConnect.nPlusOne.refreshMinutes = legacyNPlusOneRefreshMinutes; + context.warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes', + ); + } else { + context.warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + context.resolved.ankiConnect.nPlusOne.refreshMinutes, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + } else { + context.resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + + const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); + const hasValidNPlusOneMinSentenceWords = + nPlusOneMinSentenceWords !== undefined && + Number.isInteger(nPlusOneMinSentenceWords) && + nPlusOneMinSentenceWords > 0; + if (nPlusOneMinSentenceWords !== undefined) { + if (hasValidNPlusOneMinSentenceWords) { + context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords; + } else { + context.warn( + 'ankiConnect.nPlusOne.minSentenceWords', + nPlusOneConfig.minSentenceWords, + context.resolved.ankiConnect.nPlusOne.minSentenceWords, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + } else { + context.resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + + const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode); + const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode); + const hasValidNPlusOneMatchMode = + nPlusOneMatchMode === 'headword' || nPlusOneMatchMode === 'surface'; + const hasValidLegacyMatchMode = + legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface'; + if (hasValidNPlusOneMatchMode) { + context.resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode; + } else if (nPlusOneMatchMode !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.matchMode', + nPlusOneConfig.matchMode, + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, + "Expected 'headword' or 'surface'.", + ); + context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } else if (legacyNPlusOneMatchMode !== undefined) { + if (hasValidLegacyMatchMode) { + context.resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode; + context.warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode', + ); + } else { + context.warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + context.resolved.ankiConnect.nPlusOne.matchMode, + "Expected 'headword' or 'surface'.", + ); + context.resolved.ankiConnect.nPlusOne.matchMode = + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } + } else { + context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } + + const nPlusOneDecks = nPlusOneConfig.decks; + if (Array.isArray(nPlusOneDecks)) { + const normalizedDecks = nPlusOneDecks + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (normalizedDecks.length === nPlusOneDecks.length) { + context.resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)]; + } else if (nPlusOneDecks.length > 0) { + context.warn( + 'ankiConnect.nPlusOne.decks', + nPlusOneDecks, + context.resolved.ankiConnect.nPlusOne.decks, + 'Expected an array of strings.', + ); + } else { + context.resolved.ankiConnect.nPlusOne.decks = []; + } + } else if (nPlusOneDecks !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.decks', + nPlusOneDecks, + context.resolved.ankiConnect.nPlusOne.decks, + 'Expected an array of strings.', + ); + context.resolved.ankiConnect.nPlusOne.decks = []; + } + + const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne); + if (nPlusOneHighlightColor !== undefined) { + context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor; + } else if (nPlusOneConfig.nPlusOne !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.nPlusOne', + nPlusOneConfig.nPlusOne, + context.resolved.ankiConnect.nPlusOne.nPlusOne, + 'Expected a hex color value.', + ); + context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne; + } + + const nPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord); + if (nPlusOneKnownWordColor !== undefined) { + context.resolved.ankiConnect.nPlusOne.knownWord = nPlusOneKnownWordColor; + } else if (nPlusOneConfig.knownWord !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.knownWord', + nPlusOneConfig.knownWord, + context.resolved.ankiConnect.nPlusOne.knownWord, + 'Expected a hex color value.', + ); + context.resolved.ankiConnect.nPlusOne.knownWord = DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord; + } + + if ( + context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' && + context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' && + context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled' + ) { + context.warn( + 'ankiConnect.isKiku.fieldGrouping', + context.resolved.ankiConnect.isKiku.fieldGrouping, + DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping, + 'Expected auto, manual, or disabled.', + ); + context.resolved.ankiConnect.isKiku.fieldGrouping = + DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping; + } +} diff --git a/src/config/resolve/context.ts b/src/config/resolve/context.ts new file mode 100644 index 0000000..abae21d --- /dev/null +++ b/src/config/resolve/context.ts @@ -0,0 +1,30 @@ +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types'; +import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions'; +import { createWarningCollector } from '../warnings'; +import { isObject } from './shared'; + +export interface ResolveContext { + src: Record; + resolved: ResolvedConfig; + warn(path: string, value: unknown, fallback: unknown, message: string): void; +} + +export type ResolveConfigApplier = (context: ResolveContext) => void; + +export function createResolveContext(raw: RawConfig): { + context: ResolveContext; + warnings: ConfigValidationWarning[]; +} { + const resolved = deepCloneConfig(DEFAULT_CONFIG); + const { warnings, warn } = createWarningCollector(); + const src = isObject(raw) ? raw : {}; + + return { + context: { + src, + resolved, + warn, + }, + warnings, + }; +} diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts new file mode 100644 index 0000000..f26026b --- /dev/null +++ b/src/config/resolve/core-domains.ts @@ -0,0 +1,179 @@ +import { ResolveContext } from './context'; +import { asBoolean, asNumber, asString, isObject } from './shared'; + +export function applyCoreDomainConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.texthooker)) { + const openBrowser = asBoolean(src.texthooker.openBrowser); + if (openBrowser !== undefined) { + resolved.texthooker.openBrowser = openBrowser; + } else if (src.texthooker.openBrowser !== undefined) { + warn( + 'texthooker.openBrowser', + src.texthooker.openBrowser, + resolved.texthooker.openBrowser, + 'Expected boolean.', + ); + } + } + + if (isObject(src.websocket)) { + const enabled = src.websocket.enabled; + if (enabled === 'auto' || enabled === true || enabled === false) { + resolved.websocket.enabled = enabled; + } else if (enabled !== undefined) { + warn( + 'websocket.enabled', + enabled, + resolved.websocket.enabled, + "Expected true, false, or 'auto'.", + ); + } + + const port = asNumber(src.websocket.port); + if (port !== undefined && port > 0 && port <= 65535) { + resolved.websocket.port = Math.floor(port); + } else if (src.websocket.port !== undefined) { + warn( + 'websocket.port', + src.websocket.port, + resolved.websocket.port, + 'Expected integer between 1 and 65535.', + ); + } + } + + if (isObject(src.logging)) { + const logLevel = asString(src.logging.level); + if ( + logLevel === 'debug' || + logLevel === 'info' || + logLevel === 'warn' || + logLevel === 'error' + ) { + resolved.logging.level = logLevel; + } else if (src.logging.level !== undefined) { + warn( + 'logging.level', + src.logging.level, + resolved.logging.level, + 'Expected debug, info, warn, or error.', + ); + } + } + + if (Array.isArray(src.keybindings)) { + resolved.keybindings = src.keybindings.filter( + (entry): entry is { key: string; command: (string | number)[] | null } => { + if (!isObject(entry)) return false; + if (typeof entry.key !== 'string') return false; + if (entry.command === null) return true; + return Array.isArray(entry.command); + }, + ); + } + + if (isObject(src.shortcuts)) { + const shortcutKeys = [ + 'toggleVisibleOverlayGlobal', + 'toggleInvisibleOverlayGlobal', + 'copySubtitle', + 'copySubtitleMultiple', + 'updateLastCardFromClipboard', + 'triggerFieldGrouping', + 'triggerSubsync', + 'mineSentence', + 'mineSentenceMultiple', + 'toggleSecondarySub', + 'markAudioCard', + 'openRuntimeOptions', + 'openJimaku', + ] as const; + + for (const key of shortcutKeys) { + const value = src.shortcuts[key]; + if (typeof value === 'string' || value === null) { + resolved.shortcuts[key] = value as (typeof resolved.shortcuts)[typeof key]; + } else if (value !== undefined) { + warn(`shortcuts.${key}`, value, resolved.shortcuts[key], 'Expected string or null.'); + } + } + + const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs); + if (timeout !== undefined && timeout > 0) { + resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout); + } else if (src.shortcuts.multiCopyTimeoutMs !== undefined) { + warn( + 'shortcuts.multiCopyTimeoutMs', + src.shortcuts.multiCopyTimeoutMs, + resolved.shortcuts.multiCopyTimeoutMs, + 'Expected positive number.', + ); + } + } + + if (isObject(src.invisibleOverlay)) { + const startupVisibility = src.invisibleOverlay.startupVisibility; + if ( + startupVisibility === 'platform-default' || + startupVisibility === 'visible' || + startupVisibility === 'hidden' + ) { + resolved.invisibleOverlay.startupVisibility = startupVisibility; + } else if (startupVisibility !== undefined) { + warn( + 'invisibleOverlay.startupVisibility', + startupVisibility, + resolved.invisibleOverlay.startupVisibility, + 'Expected platform-default, visible, or hidden.', + ); + } + } + + if (isObject(src.secondarySub)) { + if (Array.isArray(src.secondarySub.secondarySubLanguages)) { + resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter( + (item): item is string => typeof item === 'string', + ); + } + const autoLoad = asBoolean(src.secondarySub.autoLoadSecondarySub); + if (autoLoad !== undefined) { + resolved.secondarySub.autoLoadSecondarySub = autoLoad; + } + const defaultMode = src.secondarySub.defaultMode; + if (defaultMode === 'hidden' || defaultMode === 'visible' || defaultMode === 'hover') { + resolved.secondarySub.defaultMode = defaultMode; + } else if (defaultMode !== undefined) { + warn( + 'secondarySub.defaultMode', + defaultMode, + resolved.secondarySub.defaultMode, + 'Expected hidden, visible, or hover.', + ); + } + } + + if (isObject(src.subsync)) { + const mode = src.subsync.defaultMode; + if (mode === 'auto' || mode === 'manual') { + resolved.subsync.defaultMode = mode; + } else if (mode !== undefined) { + warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.'); + } + + const alass = asString(src.subsync.alass_path); + if (alass !== undefined) resolved.subsync.alass_path = alass; + const ffsubsync = asString(src.subsync.ffsubsync_path); + if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync; + const ffmpeg = asString(src.subsync.ffmpeg_path); + if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg; + } + + if (isObject(src.subtitlePosition)) { + const y = asNumber(src.subtitlePosition.yPercent); + if (y !== undefined) { + resolved.subtitlePosition.yPercent = y; + } + } +} diff --git a/src/config/resolve/immersion-tracking.ts b/src/config/resolve/immersion-tracking.ts new file mode 100644 index 0000000..883a4aa --- /dev/null +++ b/src/config/resolve/immersion-tracking.ts @@ -0,0 +1,173 @@ +import { ResolveContext } from './context'; +import { asBoolean, asNumber, asString, isObject } from './shared'; + +export function applyImmersionTrackingConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.immersionTracking)) { + const enabled = asBoolean(src.immersionTracking.enabled); + if (enabled !== undefined) { + resolved.immersionTracking.enabled = enabled; + } else if (src.immersionTracking.enabled !== undefined) { + warn( + 'immersionTracking.enabled', + src.immersionTracking.enabled, + resolved.immersionTracking.enabled, + 'Expected boolean.', + ); + } + + const dbPath = asString(src.immersionTracking.dbPath); + if (dbPath !== undefined) { + resolved.immersionTracking.dbPath = dbPath; + } else if (src.immersionTracking.dbPath !== undefined) { + warn( + 'immersionTracking.dbPath', + src.immersionTracking.dbPath, + resolved.immersionTracking.dbPath, + 'Expected string.', + ); + } + + const batchSize = asNumber(src.immersionTracking.batchSize); + if (batchSize !== undefined && batchSize >= 1 && batchSize <= 10_000) { + resolved.immersionTracking.batchSize = Math.floor(batchSize); + } else if (src.immersionTracking.batchSize !== undefined) { + warn( + 'immersionTracking.batchSize', + src.immersionTracking.batchSize, + resolved.immersionTracking.batchSize, + 'Expected integer between 1 and 10000.', + ); + } + + const flushIntervalMs = asNumber(src.immersionTracking.flushIntervalMs); + if (flushIntervalMs !== undefined && flushIntervalMs >= 50 && flushIntervalMs <= 60_000) { + resolved.immersionTracking.flushIntervalMs = Math.floor(flushIntervalMs); + } else if (src.immersionTracking.flushIntervalMs !== undefined) { + warn( + 'immersionTracking.flushIntervalMs', + src.immersionTracking.flushIntervalMs, + resolved.immersionTracking.flushIntervalMs, + 'Expected integer between 50 and 60000.', + ); + } + + const queueCap = asNumber(src.immersionTracking.queueCap); + if (queueCap !== undefined && queueCap >= 100 && queueCap <= 100_000) { + resolved.immersionTracking.queueCap = Math.floor(queueCap); + } else if (src.immersionTracking.queueCap !== undefined) { + warn( + 'immersionTracking.queueCap', + src.immersionTracking.queueCap, + resolved.immersionTracking.queueCap, + 'Expected integer between 100 and 100000.', + ); + } + + const payloadCapBytes = asNumber(src.immersionTracking.payloadCapBytes); + if (payloadCapBytes !== undefined && payloadCapBytes >= 64 && payloadCapBytes <= 8192) { + resolved.immersionTracking.payloadCapBytes = Math.floor(payloadCapBytes); + } else if (src.immersionTracking.payloadCapBytes !== undefined) { + warn( + 'immersionTracking.payloadCapBytes', + src.immersionTracking.payloadCapBytes, + resolved.immersionTracking.payloadCapBytes, + 'Expected integer between 64 and 8192.', + ); + } + + const maintenanceIntervalMs = asNumber(src.immersionTracking.maintenanceIntervalMs); + if ( + maintenanceIntervalMs !== undefined && + maintenanceIntervalMs >= 60_000 && + maintenanceIntervalMs <= 7 * 24 * 60 * 60 * 1000 + ) { + resolved.immersionTracking.maintenanceIntervalMs = Math.floor(maintenanceIntervalMs); + } else if (src.immersionTracking.maintenanceIntervalMs !== undefined) { + warn( + 'immersionTracking.maintenanceIntervalMs', + src.immersionTracking.maintenanceIntervalMs, + resolved.immersionTracking.maintenanceIntervalMs, + 'Expected integer between 60000 and 604800000.', + ); + } + + if (isObject(src.immersionTracking.retention)) { + const eventsDays = asNumber(src.immersionTracking.retention.eventsDays); + if (eventsDays !== undefined && eventsDays >= 1 && eventsDays <= 3650) { + resolved.immersionTracking.retention.eventsDays = Math.floor(eventsDays); + } else if (src.immersionTracking.retention.eventsDays !== undefined) { + warn( + 'immersionTracking.retention.eventsDays', + src.immersionTracking.retention.eventsDays, + resolved.immersionTracking.retention.eventsDays, + 'Expected integer between 1 and 3650.', + ); + } + + const telemetryDays = asNumber(src.immersionTracking.retention.telemetryDays); + if (telemetryDays !== undefined && telemetryDays >= 1 && telemetryDays <= 3650) { + resolved.immersionTracking.retention.telemetryDays = Math.floor(telemetryDays); + } else if (src.immersionTracking.retention.telemetryDays !== undefined) { + warn( + 'immersionTracking.retention.telemetryDays', + src.immersionTracking.retention.telemetryDays, + resolved.immersionTracking.retention.telemetryDays, + 'Expected integer between 1 and 3650.', + ); + } + + const dailyRollupsDays = asNumber(src.immersionTracking.retention.dailyRollupsDays); + if (dailyRollupsDays !== undefined && dailyRollupsDays >= 1 && dailyRollupsDays <= 36500) { + resolved.immersionTracking.retention.dailyRollupsDays = Math.floor(dailyRollupsDays); + } else if (src.immersionTracking.retention.dailyRollupsDays !== undefined) { + warn( + 'immersionTracking.retention.dailyRollupsDays', + src.immersionTracking.retention.dailyRollupsDays, + resolved.immersionTracking.retention.dailyRollupsDays, + 'Expected integer between 1 and 36500.', + ); + } + + const monthlyRollupsDays = asNumber(src.immersionTracking.retention.monthlyRollupsDays); + if ( + monthlyRollupsDays !== undefined && + monthlyRollupsDays >= 1 && + monthlyRollupsDays <= 36500 + ) { + resolved.immersionTracking.retention.monthlyRollupsDays = Math.floor(monthlyRollupsDays); + } else if (src.immersionTracking.retention.monthlyRollupsDays !== undefined) { + warn( + 'immersionTracking.retention.monthlyRollupsDays', + src.immersionTracking.retention.monthlyRollupsDays, + resolved.immersionTracking.retention.monthlyRollupsDays, + 'Expected integer between 1 and 36500.', + ); + } + + const vacuumIntervalDays = asNumber(src.immersionTracking.retention.vacuumIntervalDays); + if ( + vacuumIntervalDays !== undefined && + vacuumIntervalDays >= 1 && + vacuumIntervalDays <= 3650 + ) { + resolved.immersionTracking.retention.vacuumIntervalDays = Math.floor(vacuumIntervalDays); + } else if (src.immersionTracking.retention.vacuumIntervalDays !== undefined) { + warn( + 'immersionTracking.retention.vacuumIntervalDays', + src.immersionTracking.retention.vacuumIntervalDays, + resolved.immersionTracking.retention.vacuumIntervalDays, + 'Expected integer between 1 and 3650.', + ); + } + } else if (src.immersionTracking.retention !== undefined) { + warn( + 'immersionTracking.retention', + src.immersionTracking.retention, + resolved.immersionTracking.retention, + 'Expected object.', + ); + } + } +} diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts new file mode 100644 index 0000000..d517889 --- /dev/null +++ b/src/config/resolve/integrations.ts @@ -0,0 +1,128 @@ +import { ResolveContext } from './context'; +import { asBoolean, asNumber, asString, isObject } from './shared'; + +export function applyIntegrationConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.anilist)) { + const enabled = asBoolean(src.anilist.enabled); + if (enabled !== undefined) { + resolved.anilist.enabled = enabled; + } else if (src.anilist.enabled !== undefined) { + warn('anilist.enabled', src.anilist.enabled, resolved.anilist.enabled, 'Expected boolean.'); + } + + const accessToken = asString(src.anilist.accessToken); + if (accessToken !== undefined) { + resolved.anilist.accessToken = accessToken; + } else if (src.anilist.accessToken !== undefined) { + warn( + 'anilist.accessToken', + src.anilist.accessToken, + resolved.anilist.accessToken, + 'Expected string.', + ); + } + } + + if (isObject(src.jellyfin)) { + const enabled = asBoolean(src.jellyfin.enabled); + if (enabled !== undefined) { + resolved.jellyfin.enabled = enabled; + } else if (src.jellyfin.enabled !== undefined) { + warn( + 'jellyfin.enabled', + src.jellyfin.enabled, + resolved.jellyfin.enabled, + 'Expected boolean.', + ); + } + + const stringKeys = [ + 'serverUrl', + 'username', + 'deviceId', + 'clientName', + 'clientVersion', + 'defaultLibraryId', + 'iconCacheDir', + 'transcodeVideoCodec', + ] as const; + for (const key of stringKeys) { + const value = asString(src.jellyfin[key]); + if (value !== undefined) { + resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; + } else if (src.jellyfin[key] !== undefined) { + warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected string.'); + } + } + + const booleanKeys = [ + 'remoteControlEnabled', + 'remoteControlAutoConnect', + 'autoAnnounce', + 'directPlayPreferred', + 'pullPictures', + ] as const; + for (const key of booleanKeys) { + const value = asBoolean(src.jellyfin[key]); + if (value !== undefined) { + resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; + } else if (src.jellyfin[key] !== undefined) { + warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected boolean.'); + } + } + + if (Array.isArray(src.jellyfin.directPlayContainers)) { + resolved.jellyfin.directPlayContainers = src.jellyfin.directPlayContainers + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0); + } else if (src.jellyfin.directPlayContainers !== undefined) { + warn( + 'jellyfin.directPlayContainers', + src.jellyfin.directPlayContainers, + resolved.jellyfin.directPlayContainers, + 'Expected string array.', + ); + } + } + + if (isObject(src.discordPresence)) { + const enabled = asBoolean(src.discordPresence.enabled); + if (enabled !== undefined) { + resolved.discordPresence.enabled = enabled; + } else if (src.discordPresence.enabled !== undefined) { + warn( + 'discordPresence.enabled', + src.discordPresence.enabled, + resolved.discordPresence.enabled, + 'Expected boolean.', + ); + } + + const updateIntervalMs = asNumber(src.discordPresence.updateIntervalMs); + if (updateIntervalMs !== undefined) { + resolved.discordPresence.updateIntervalMs = Math.max(1_000, Math.floor(updateIntervalMs)); + } else if (src.discordPresence.updateIntervalMs !== undefined) { + warn( + 'discordPresence.updateIntervalMs', + src.discordPresence.updateIntervalMs, + resolved.discordPresence.updateIntervalMs, + 'Expected number.', + ); + } + + const debounceMs = asNumber(src.discordPresence.debounceMs); + if (debounceMs !== undefined) { + resolved.discordPresence.debounceMs = Math.max(0, Math.floor(debounceMs)); + } else if (src.discordPresence.debounceMs !== undefined) { + warn( + 'discordPresence.debounceMs', + src.discordPresence.debounceMs, + resolved.discordPresence.debounceMs, + 'Expected number.', + ); + } + } +} diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts new file mode 100644 index 0000000..6802875 --- /dev/null +++ b/src/config/resolve/jellyfin.test.ts @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createResolveContext } from './context'; +import { applyIntegrationConfig } from './integrations'; + +test('jellyfin directPlayContainers are normalized', () => { + const { context } = createResolveContext({ + jellyfin: { + directPlayContainers: [' MKV ', 'mp4', '', ' WebM ', 42 as unknown as string], + }, + }); + + applyIntegrationConfig(context); + + assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']); +}); + +test('jellyfin legacy auth keys are ignored by resolver', () => { + const { context } = createResolveContext({ + jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never, + }); + + applyIntegrationConfig(context); + + assert.equal('accessToken' in (context.resolved.jellyfin as Record), false); + assert.equal('userId' in (context.resolved.jellyfin as Record), false); +}); + +test('discordPresence fields are parsed and clamped', () => { + const { context } = createResolveContext({ + discordPresence: { + enabled: true, + updateIntervalMs: 500, + debounceMs: -100, + }, + }); + + applyIntegrationConfig(context); + + assert.equal(context.resolved.discordPresence.enabled, true); + assert.equal(context.resolved.discordPresence.updateIntervalMs, 1000); + assert.equal(context.resolved.discordPresence.debounceMs, 0); +}); + +test('discordPresence invalid values warn and keep defaults', () => { + const { context, warnings } = createResolveContext({ + discordPresence: { + enabled: 'true' as never, + updateIntervalMs: 'fast' as never, + debounceMs: null as never, + }, + }); + + applyIntegrationConfig(context); + + assert.equal(context.resolved.discordPresence.enabled, false); + assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000); + assert.equal(context.resolved.discordPresence.debounceMs, 750); + + const warnedPaths = warnings.map((warning) => warning.path); + assert.ok(warnedPaths.includes('discordPresence.enabled')); + assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs')); + assert.ok(warnedPaths.includes('discordPresence.debounceMs')); +}); diff --git a/src/config/resolve/shared.ts b/src/config/resolve/shared.ts new file mode 100644 index 0000000..2490f91 --- /dev/null +++ b/src/config/resolve/shared.ts @@ -0,0 +1,38 @@ +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +export function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function asBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +export function asColor(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const text = value.trim(); + return hexColorPattern.test(text) ? text : undefined; +} + +export function asFrequencyBandedColors( + value: unknown, +): [string, string, string, string, string] | undefined { + if (!Array.isArray(value) || value.length !== 5) { + return undefined; + } + + const colors = value.map((item) => asColor(item)); + if (colors.some((color) => color === undefined)) { + return undefined; + } + + return colors as [string, string, string, string, string]; +} diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts new file mode 100644 index 0000000..2b0144a --- /dev/null +++ b/src/config/resolve/subtitle-domains.ts @@ -0,0 +1,239 @@ +import { ResolvedConfig } from '../../types'; +import { ResolveContext } from './context'; +import { + asBoolean, + asColor, + asFrequencyBandedColors, + asNumber, + asString, + isObject, +} from './shared'; + +export function applySubtitleDomainConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.jimaku)) { + const apiKey = asString(src.jimaku.apiKey); + if (apiKey !== undefined) resolved.jimaku.apiKey = apiKey; + const apiKeyCommand = asString(src.jimaku.apiKeyCommand); + if (apiKeyCommand !== undefined) resolved.jimaku.apiKeyCommand = apiKeyCommand; + const apiBaseUrl = asString(src.jimaku.apiBaseUrl); + if (apiBaseUrl !== undefined) resolved.jimaku.apiBaseUrl = apiBaseUrl; + + const lang = src.jimaku.languagePreference; + if (lang === 'ja' || lang === 'en' || lang === 'none') { + resolved.jimaku.languagePreference = lang; + } else if (lang !== undefined) { + warn( + 'jimaku.languagePreference', + lang, + resolved.jimaku.languagePreference, + 'Expected ja, en, or none.', + ); + } + + const maxEntryResults = asNumber(src.jimaku.maxEntryResults); + if (maxEntryResults !== undefined && maxEntryResults > 0) { + resolved.jimaku.maxEntryResults = Math.floor(maxEntryResults); + } else if (src.jimaku.maxEntryResults !== undefined) { + warn( + 'jimaku.maxEntryResults', + src.jimaku.maxEntryResults, + resolved.jimaku.maxEntryResults, + 'Expected positive number.', + ); + } + } + + if (isObject(src.youtubeSubgen)) { + const mode = src.youtubeSubgen.mode; + if (mode === 'automatic' || mode === 'preprocess' || mode === 'off') { + resolved.youtubeSubgen.mode = mode; + } else if (mode !== undefined) { + warn( + 'youtubeSubgen.mode', + mode, + resolved.youtubeSubgen.mode, + 'Expected automatic, preprocess, or off.', + ); + } + + const whisperBin = asString(src.youtubeSubgen.whisperBin); + if (whisperBin !== undefined) { + resolved.youtubeSubgen.whisperBin = whisperBin; + } else if (src.youtubeSubgen.whisperBin !== undefined) { + warn( + 'youtubeSubgen.whisperBin', + src.youtubeSubgen.whisperBin, + resolved.youtubeSubgen.whisperBin, + 'Expected string.', + ); + } + + const whisperModel = asString(src.youtubeSubgen.whisperModel); + if (whisperModel !== undefined) { + resolved.youtubeSubgen.whisperModel = whisperModel; + } else if (src.youtubeSubgen.whisperModel !== undefined) { + warn( + 'youtubeSubgen.whisperModel', + src.youtubeSubgen.whisperModel, + resolved.youtubeSubgen.whisperModel, + 'Expected string.', + ); + } + + if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) { + resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter( + (item): item is string => typeof item === 'string', + ); + } else if (src.youtubeSubgen.primarySubLanguages !== undefined) { + warn( + 'youtubeSubgen.primarySubLanguages', + src.youtubeSubgen.primarySubLanguages, + resolved.youtubeSubgen.primarySubLanguages, + 'Expected string array.', + ); + } + } + + if (isObject(src.subtitleStyle)) { + const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; + const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; + const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; + resolved.subtitleStyle = { + ...resolved.subtitleStyle, + ...(src.subtitleStyle as ResolvedConfig['subtitleStyle']), + secondary: { + ...resolved.subtitleStyle.secondary, + ...(isObject(src.subtitleStyle.secondary) + ? (src.subtitleStyle.secondary as ResolvedConfig['subtitleStyle']['secondary']) + : {}), + }, + }; + + const enableJlpt = asBoolean((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt); + if (enableJlpt !== undefined) { + resolved.subtitleStyle.enableJlpt = enableJlpt; + } else if ((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined) { + resolved.subtitleStyle.enableJlpt = fallbackSubtitleStyleEnableJlpt; + warn( + 'subtitleStyle.enableJlpt', + (src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt, + resolved.subtitleStyle.enableJlpt, + 'Expected boolean.', + ); + } + + const preserveLineBreaks = asBoolean( + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, + ); + if (preserveLineBreaks !== undefined) { + resolved.subtitleStyle.preserveLineBreaks = preserveLineBreaks; + } else if ( + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks !== undefined + ) { + resolved.subtitleStyle.preserveLineBreaks = fallbackSubtitleStylePreserveLineBreaks; + warn( + 'subtitleStyle.preserveLineBreaks', + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, + resolved.subtitleStyle.preserveLineBreaks, + 'Expected boolean.', + ); + } + + const hoverTokenColor = asColor((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor); + if (hoverTokenColor !== undefined) { + resolved.subtitleStyle.hoverTokenColor = hoverTokenColor; + } else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) { + resolved.subtitleStyle.hoverTokenColor = fallbackSubtitleStyleHoverTokenColor; + warn( + 'subtitleStyle.hoverTokenColor', + (src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor, + resolved.subtitleStyle.hoverTokenColor, + 'Expected hex color.', + ); + } + + const frequencyDictionary = isObject( + (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, + ) + ? ((src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary as Record< + string, + unknown + >) + : {}; + const frequencyEnabled = asBoolean((frequencyDictionary as { enabled?: unknown }).enabled); + if (frequencyEnabled !== undefined) { + resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled; + } else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.enabled', + (frequencyDictionary as { enabled?: unknown }).enabled, + resolved.subtitleStyle.frequencyDictionary.enabled, + 'Expected boolean.', + ); + } + + const sourcePath = asString((frequencyDictionary as { sourcePath?: unknown }).sourcePath); + if (sourcePath !== undefined) { + resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath; + } else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.sourcePath', + (frequencyDictionary as { sourcePath?: unknown }).sourcePath, + resolved.subtitleStyle.frequencyDictionary.sourcePath, + 'Expected string.', + ); + } + + const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX); + if (topX !== undefined && Number.isInteger(topX) && topX > 0) { + resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX); + } else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.topX', + (frequencyDictionary as { topX?: unknown }).topX, + resolved.subtitleStyle.frequencyDictionary.topX, + 'Expected a positive integer.', + ); + } + + const frequencyMode = frequencyDictionary.mode; + if (frequencyMode === 'single' || frequencyMode === 'banded') { + resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode; + } else if (frequencyMode !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.mode', + frequencyDictionary.mode, + resolved.subtitleStyle.frequencyDictionary.mode, + "Expected 'single' or 'banded'.", + ); + } + + const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor); + if (singleColor !== undefined) { + resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor; + } else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.singleColor', + (frequencyDictionary as { singleColor?: unknown }).singleColor, + resolved.subtitleStyle.frequencyDictionary.singleColor, + 'Expected hex color.', + ); + } + + const bandedColors = asFrequencyBandedColors( + (frequencyDictionary as { bandedColors?: unknown }).bandedColors, + ); + if (bandedColors !== undefined) { + resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors; + } else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.bandedColors', + (frequencyDictionary as { bandedColors?: unknown }).bandedColors, + resolved.subtitleStyle.frequencyDictionary.bandedColors, + 'Expected an array of five hex colors.', + ); + } + } +} diff --git a/src/config/resolve/subtitle-style.test.ts b/src/config/resolve/subtitle-style.test.ts new file mode 100644 index 0000000..43c7a3d --- /dev/null +++ b/src/config/resolve/subtitle-style.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createResolveContext } from './context'; +import { applySubtitleDomainConfig } from './subtitle-domains'; + +test('subtitleStyle preserveLineBreaks falls back while merge is preserved', () => { + const { context, warnings } = createResolveContext({ + subtitleStyle: { + preserveLineBreaks: 'invalid' as unknown as boolean, + backgroundColor: 'rgb(1, 2, 3, 0.5)', + secondary: { + fontColor: 'yellow', + }, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleStyle.preserveLineBreaks, false); + assert.equal(context.resolved.subtitleStyle.backgroundColor, 'rgb(1, 2, 3, 0.5)'); + assert.equal(context.resolved.subtitleStyle.secondary.fontColor, 'yellow'); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleStyle.preserveLineBreaks' && + warning.message === 'Expected boolean.', + ), + ); +}); diff --git a/src/config/resolve/top-level.ts b/src/config/resolve/top-level.ts new file mode 100644 index 0000000..1f8f87f --- /dev/null +++ b/src/config/resolve/top-level.ts @@ -0,0 +1,28 @@ +import { ResolveContext } from './context'; +import { asBoolean } from './shared'; + +export function applyTopLevelConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + const knownTopLevelKeys = new Set(Object.keys(resolved)); + for (const key of Object.keys(src)) { + if (!knownTopLevelKeys.has(key)) { + warn(key, src[key], undefined, 'Unknown top-level config key; ignored.'); + } + } + + if (asBoolean(src.auto_start_overlay) !== undefined) { + resolved.auto_start_overlay = src.auto_start_overlay as boolean; + } + + if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) { + resolved.bind_visible_overlay_to_mpv_sub_visibility = + src.bind_visible_overlay_to_mpv_sub_visibility as boolean; + } else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) { + warn( + 'bind_visible_overlay_to_mpv_sub_visibility', + src.bind_visible_overlay_to_mpv_sub_visibility, + resolved.bind_visible_overlay_to_mpv_sub_visibility, + 'Expected boolean.', + ); + } +} diff --git a/src/config/service.ts b/src/config/service.ts new file mode 100644 index 0000000..339c581 --- /dev/null +++ b/src/config/service.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; +import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions'; +import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load'; +import { resolveConfig } from './resolve'; + +export type ReloadConfigStrictResult = + | { + ok: true; + config: ResolvedConfig; + warnings: ConfigValidationWarning[]; + path: string; + } + | { + ok: false; + error: string; + path: string; + }; + +export class ConfigStartupParseError extends Error { + readonly path: string; + readonly parseError: string; + + constructor(configPath: string, parseError: string) { + super( + `Failed to parse startup config at ${configPath}: ${parseError}. Fix the config file and restart SubMiner.`, + ); + this.name = 'ConfigStartupParseError'; + this.path = configPath; + this.parseError = parseError; + } +} + +export class ConfigService { + private readonly configPaths: ConfigPaths; + private rawConfig: RawConfig = {}; + private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG); + private warnings: ConfigValidationWarning[] = []; + private configPathInUse!: string; + + constructor(configDir: string) { + this.configPaths = { + configDir, + configFileJsonc: path.join(configDir, 'config.jsonc'), + configFileJson: path.join(configDir, 'config.json'), + }; + const loadResult = loadRawConfigStrict(this.configPaths); + if (!loadResult.ok) { + throw new ConfigStartupParseError(loadResult.path, loadResult.error); + } + this.applyResolvedConfig(loadResult.config, loadResult.path); + } + + getConfigPath(): string { + return this.configPathInUse; + } + + getConfig(): ResolvedConfig { + return deepCloneConfig(this.resolvedConfig); + } + + getRawConfig(): RawConfig { + return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig; + } + + getWarnings(): ConfigValidationWarning[] { + return [...this.warnings]; + } + + reloadConfig(): ResolvedConfig { + const { config, path: configPath } = loadRawConfig(this.configPaths); + return this.applyResolvedConfig(config, configPath); + } + + reloadConfigStrict(): ReloadConfigStrictResult { + const loadResult = loadRawConfigStrict(this.configPaths); + if (!loadResult.ok) { + return loadResult; + } + + const { config, path: configPath } = loadResult; + const resolvedConfig = this.applyResolvedConfig(config, configPath); + return { + ok: true, + config: resolvedConfig, + warnings: this.getWarnings(), + path: configPath, + }; + } + + saveRawConfig(config: RawConfig): void { + if (!fs.existsSync(this.configPaths.configDir)) { + fs.mkdirSync(this.configPaths.configDir, { recursive: true }); + } + const targetPath = this.configPathInUse.endsWith('.json') + ? this.configPathInUse + : this.configPaths.configFileJsonc; + fs.writeFileSync(targetPath, JSON.stringify(config, null, 2)); + this.applyResolvedConfig(config, targetPath); + } + + patchRawConfig(patch: RawConfig): void { + const merged = deepMergeRawConfig(this.getRawConfig(), patch); + this.saveRawConfig(merged); + } + + private applyResolvedConfig(config: RawConfig, configPath: string): ResolvedConfig { + this.rawConfig = config; + this.configPathInUse = configPath; + const { resolved, warnings } = resolveConfig(config); + this.resolvedConfig = resolved; + this.warnings = warnings; + return this.getConfig(); + } +} diff --git a/src/config/template.ts b/src/config/template.ts new file mode 100644 index 0000000..325e90d --- /dev/null +++ b/src/config/template.ts @@ -0,0 +1,135 @@ +import { ResolvedConfig } from '../types'; +import { + CONFIG_OPTION_REGISTRY, + CONFIG_TEMPLATE_SECTIONS, + DEFAULT_CONFIG, + deepCloneConfig, +} from './definitions'; + +const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry])); +const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map( + CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']), +); + +function normalizeCommentText(value: string): string { + return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim(); +} + +function humanizeKey(key: string): string { + const spaced = key + .replace(/_/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +function buildInlineOptionComment(path: string, value: unknown): string { + const registryEntry = OPTION_REGISTRY_BY_PATH.get(path); + const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path); + const description = + baseDescription && baseDescription.trim().length > 0 + ? normalizeCommentText(baseDescription) + : `${humanizeKey(path.split('.').at(-1) ?? path)} setting.`; + + if (registryEntry?.enumValues?.length) { + return `${description} Values: ${registryEntry.enumValues.join(' | ')}`; + } + if (typeof value === 'boolean') { + return `${description} Values: true | false`; + } + return description; +} + +function renderValue(value: unknown, indent = 0, path = ''): string { + const pad = ' '.repeat(indent); + const nextPad = ' '.repeat(indent + 2); + + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2, `${path}[]`)}`); + return `\n${items.join(',\n')}\n${pad}`.replace(/^/, '[').concat(']'); + } + + if (typeof value === 'object') { + const entries = Object.entries(value as Record).filter( + ([, child]) => child !== undefined, + ); + if (entries.length === 0) return '{}'; + const lines = entries.map(([key, child], index) => { + const isLast = index === entries.length - 1; + const trailingComma = isLast ? '' : ','; + const childPath = path ? `${path}.${key}` : key; + const renderedChild = renderValue(child, indent + 2, childPath); + const comment = buildInlineOptionComment(childPath, child); + if (renderedChild.startsWith('\n')) { + return `${nextPad}${JSON.stringify(key)}: /* ${comment} */ ${renderedChild}${trailingComma}`; + } + return `${nextPad}${JSON.stringify(key)}: ${renderedChild}${trailingComma} // ${comment}`; + }); + return `\n${lines.join('\n')}\n${pad}`.replace(/^/, '{').concat('}'); + } + + return 'null'; +} + +function renderSection( + key: keyof ResolvedConfig, + value: unknown, + isLast: boolean, + comments: string[], +): string { + const lines: string[] = []; + lines.push(' // =========================================='); + for (const comment of comments) { + lines.push(` // ${comment}`); + } + lines.push(' // =========================================='); + const inlineComment = buildInlineOptionComment(String(key), value); + const renderedValue = renderValue(value, 2, String(key)); + if (renderedValue.startsWith('\n')) { + lines.push( + ` ${JSON.stringify(key)}: /* ${inlineComment} */ ${renderedValue}${isLast ? '' : ','}`, + ); + } else { + lines.push( + ` ${JSON.stringify(key)}: ${renderedValue}${isLast ? '' : ','} // ${inlineComment}`, + ); + } + return lines.join('\n'); +} + +export function generateConfigTemplate( + config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG), +): string { + const lines: string[] = []; + lines.push('/**'); + lines.push(' * SubMiner Example Configuration File'); + lines.push(' *'); + lines.push(' * This file is auto-generated from src/config/definitions.ts.'); + lines.push( + ' * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.', + ); + lines.push(' */'); + lines.push('{'); + + CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => { + lines.push(''); + const comments = [section.title, ...section.description, ...(section.notes ?? [])]; + lines.push( + renderSection( + section.key, + config[section.key], + index === CONFIG_TEMPLATE_SECTIONS.length - 1, + comments, + ), + ); + }); + + lines.push('}'); + lines.push(''); + return lines.join('\n'); +} diff --git a/src/config/warnings.ts b/src/config/warnings.ts new file mode 100644 index 0000000..ffa95ff --- /dev/null +++ b/src/config/warnings.ts @@ -0,0 +1,19 @@ +import { ConfigValidationWarning } from '../types'; + +export interface WarningCollector { + warnings: ConfigValidationWarning[]; + warn(path: string, value: unknown, fallback: unknown, message: string): void; +} + +export function createWarningCollector(): WarningCollector { + const warnings: ConfigValidationWarning[] = []; + const warn = (path: string, value: unknown, fallback: unknown, message: string): void => { + warnings.push({ + path, + value, + fallback, + message, + }); + }; + return { warnings, warn }; +} diff --git a/src/core/services/anilist/anilist-token-store.test.ts b/src/core/services/anilist/anilist-token-store.test.ts new file mode 100644 index 0000000..cf9a4e7 --- /dev/null +++ b/src/core/services/anilist/anilist-token-store.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { createAnilistTokenStore, type SafeStorageLike } from './anilist-token-store'; + +function createTempTokenFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-token-')); + return path.join(dir, 'token.json'); +} + +function createLogger() { + return { + info: (_message: string) => {}, + warn: (_message: string) => {}, + error: (_message: string) => {}, + }; +} + +function createStorage(encryptionAvailable: boolean): SafeStorageLike { + return { + isEncryptionAvailable: () => encryptionAvailable, + encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'), + decryptString: (value: Buffer) => { + const raw = value.toString('utf-8'); + return raw.startsWith('enc:') ? raw.slice(4) : raw; + }, + }; +} + +test('anilist token store saves and loads encrypted token', () => { + const filePath = createTempTokenFile(); + const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true)); + store.saveToken(' demo-token '); + + const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + encryptedToken?: string; + plaintextToken?: string; + }; + assert.equal(typeof payload.encryptedToken, 'string'); + assert.equal(payload.plaintextToken, undefined); + assert.equal(store.loadToken(), 'demo-token'); +}); + +test('anilist token store falls back to plaintext when encryption unavailable', () => { + const filePath = createTempTokenFile(); + const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false)); + store.saveToken('plain-token'); + + const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + plaintextToken?: string; + }; + assert.equal(payload.plaintextToken, 'plain-token'); + assert.equal(store.loadToken(), 'plain-token'); +}); + +test('anilist token store migrates legacy plaintext to encrypted', () => { + const filePath = createTempTokenFile(); + fs.writeFileSync( + filePath, + JSON.stringify({ plaintextToken: 'legacy-token', updatedAt: Date.now() }), + 'utf-8', + ); + + const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true)); + assert.equal(store.loadToken(), 'legacy-token'); + + const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + encryptedToken?: string; + plaintextToken?: string; + }; + assert.equal(typeof payload.encryptedToken, 'string'); + assert.equal(payload.plaintextToken, undefined); +}); + +test('anilist token store clears persisted token file', () => { + const filePath = createTempTokenFile(); + const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true)); + store.saveToken('to-clear'); + assert.equal(fs.existsSync(filePath), true); + store.clearToken(); + assert.equal(fs.existsSync(filePath), false); +}); diff --git a/src/core/services/anilist/anilist-token-store.ts b/src/core/services/anilist/anilist-token-store.ts new file mode 100644 index 0000000..d89ded1 --- /dev/null +++ b/src/core/services/anilist/anilist-token-store.ts @@ -0,0 +1,108 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as electron from 'electron'; + +interface PersistedTokenPayload { + encryptedToken?: string; + plaintextToken?: string; + updatedAt?: number; +} + +export interface AnilistTokenStore { + loadToken: () => string | null; + saveToken: (token: string) => void; + clearToken: () => void; +} + +export interface SafeStorageLike { + isEncryptionAvailable: () => boolean; + encryptString: (value: string) => Buffer; + decryptString: (value: Buffer) => string; +} + +function ensureDirectory(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function writePayload(filePath: string, payload: PersistedTokenPayload): void { + ensureDirectory(filePath); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); +} + +export function createAnilistTokenStore( + filePath: string, + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + error: (message: string, details?: unknown) => void; + }, + storage: SafeStorageLike = electron.safeStorage, +): AnilistTokenStore { + return { + loadToken(): string | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as PersistedTokenPayload; + if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) { + const encrypted = Buffer.from(parsed.encryptedToken, 'base64'); + if (!storage.isEncryptionAvailable()) { + logger.warn('AniList token encryption is not available on this system.'); + return null; + } + const decrypted = storage.decryptString(encrypted).trim(); + return decrypted.length > 0 ? decrypted : null; + } + if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) { + // Legacy fallback: migrate plaintext token to encrypted storage on load. + const plaintext = parsed.plaintextToken.trim(); + this.saveToken(plaintext); + return plaintext; + } + } catch (error) { + logger.error('Failed to read AniList token store.', error); + } + return null; + }, + + saveToken(token: string): void { + const trimmed = token.trim(); + if (trimmed.length === 0) { + this.clearToken(); + return; + } + try { + if (!storage.isEncryptionAvailable()) { + logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.'); + writePayload(filePath, { + plaintextToken: trimmed, + updatedAt: Date.now(), + }); + return; + } + const encrypted = storage.encryptString(trimmed); + writePayload(filePath, { + encryptedToken: encrypted.toString('base64'), + updatedAt: Date.now(), + }); + } catch (error) { + logger.error('Failed to persist AniList token.', error); + } + }, + + clearToken(): void { + if (!fs.existsSync(filePath)) return; + try { + fs.unlinkSync(filePath); + logger.info('Cleared stored AniList token.'); + } catch (error) { + logger.error('Failed to clear stored AniList token.', error); + } + }, + }; +} diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts new file mode 100644 index 0000000..dace595 --- /dev/null +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { createAnilistUpdateQueue } from './anilist-update-queue'; + +function createTempQueueFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-queue-')); + return path.join(dir, 'queue.json'); +} + +function createLogger() { + const info: string[] = []; + const warn: string[] = []; + const error: string[] = []; + return { + info, + warn, + error, + logger: { + info: (message: string) => info.push(message), + warn: (message: string) => warn.push(message), + error: (message: string) => error.push(message), + }, + }; +} + +test('anilist update queue enqueues, snapshots, and dequeues success', () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + + queue.enqueue('k1', 'Demo', 1); + const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER); + assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 }); + assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1'); + + queue.markSuccess('k1'); + assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), { + pending: 0, + ready: 0, + deadLetter: 0, + }); + assert.ok(loggerState.info.some((message) => message.includes('Queued AniList retry'))); +}); + +test('anilist update queue applies retry backoff and dead-letter', () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + + const now = 1_700_000_000_000; + queue.enqueue('k2', 'Backoff Demo', 2); + + queue.markFailure('k2', 'fail-1', now); + const firstRetry = queue.nextReady(now); + assert.equal(firstRetry, null); + + const pendingPayload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as { + pending: Array<{ attemptCount: number; nextAttemptAt: number }>; + }; + assert.equal(pendingPayload.pending[0]?.attemptCount, 1); + assert.equal(pendingPayload.pending[0]?.nextAttemptAt, now + 30_000); + + for (let attempt = 2; attempt <= 8; attempt += 1) { + queue.markFailure('k2', `fail-${attempt}`, now); + } + + const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER); + assert.deepEqual(snapshot, { pending: 0, ready: 0, deadLetter: 1 }); + assert.ok( + loggerState.warn.some((message) => + message.includes('AniList retry moved to dead-letter queue.'), + ), + ); +}); + +test('anilist update queue persists and reloads from disk', () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queueA = createAnilistUpdateQueue(queueFile, loggerState.logger); + queueA.enqueue('k3', 'Persist Demo', 3); + + const queueB = createAnilistUpdateQueue(queueFile, loggerState.logger); + assert.deepEqual(queueB.getSnapshot(Number.MAX_SAFE_INTEGER), { + pending: 1, + ready: 1, + deadLetter: 0, + }); + assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo'); +}); diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts new file mode 100644 index 0000000..71e1339 --- /dev/null +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -0,0 +1,193 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const INITIAL_BACKOFF_MS = 30_000; +const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000; +const MAX_ATTEMPTS = 8; +const MAX_ITEMS = 500; + +export interface AnilistQueuedUpdate { + key: string; + title: string; + episode: number; + createdAt: number; + attemptCount: number; + nextAttemptAt: number; + lastError: string | null; +} + +interface AnilistRetryQueuePayload { + pending?: AnilistQueuedUpdate[]; + deadLetter?: AnilistQueuedUpdate[]; +} + +export interface AnilistRetryQueueSnapshot { + pending: number; + ready: number; + deadLetter: number; +} + +export interface AnilistUpdateQueue { + enqueue: (key: string, title: string, episode: number) => void; + nextReady: (nowMs?: number) => AnilistQueuedUpdate | null; + markSuccess: (key: string) => void; + markFailure: (key: string, reason: string, nowMs?: number) => void; + getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot; +} + +function ensureDir(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function clampBackoffMs(attemptCount: number): number { + const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1)); + return Math.min(MAX_BACKOFF_MS, computed); +} + +export function createAnilistUpdateQueue( + filePath: string, + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + error: (message: string, details?: unknown) => void; + }, +): AnilistUpdateQueue { + let pending: AnilistQueuedUpdate[] = []; + let deadLetter: AnilistQueuedUpdate[] = []; + + const persist = () => { + try { + ensureDir(filePath); + const payload: AnilistRetryQueuePayload = { pending, deadLetter }; + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); + } catch (error) { + logger.error('Failed to persist AniList retry queue.', error); + } + }; + + const load = () => { + if (!fs.existsSync(filePath)) { + return; + } + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as AnilistRetryQueuePayload; + const parsedPending = Array.isArray(parsed.pending) ? parsed.pending : []; + const parsedDeadLetter = Array.isArray(parsed.deadLetter) ? parsed.deadLetter : []; + pending = parsedPending + .filter( + (item): item is AnilistQueuedUpdate => + item && + typeof item.key === 'string' && + typeof item.title === 'string' && + typeof item.episode === 'number' && + item.episode > 0 && + typeof item.createdAt === 'number' && + typeof item.attemptCount === 'number' && + typeof item.nextAttemptAt === 'number' && + (typeof item.lastError === 'string' || item.lastError === null), + ) + .slice(0, MAX_ITEMS); + deadLetter = parsedDeadLetter + .filter( + (item): item is AnilistQueuedUpdate => + item && + typeof item.key === 'string' && + typeof item.title === 'string' && + typeof item.episode === 'number' && + item.episode > 0 && + typeof item.createdAt === 'number' && + typeof item.attemptCount === 'number' && + typeof item.nextAttemptAt === 'number' && + (typeof item.lastError === 'string' || item.lastError === null), + ) + .slice(0, MAX_ITEMS); + } catch (error) { + logger.error('Failed to load AniList retry queue.', error); + } + }; + + load(); + + return { + enqueue(key: string, title: string, episode: number): void { + const existing = pending.find((item) => item.key === key); + if (existing) { + return; + } + if (pending.length >= MAX_ITEMS) { + pending.shift(); + } + pending.push({ + key, + title, + episode, + createdAt: Date.now(), + attemptCount: 0, + nextAttemptAt: Date.now(), + lastError: null, + }); + persist(); + logger.info(`Queued AniList retry for "${title}" episode ${episode}.`); + }, + + nextReady(nowMs: number = Date.now()): AnilistQueuedUpdate | null { + const ready = pending.find((item) => item.nextAttemptAt <= nowMs); + return ready ?? null; + }, + + markSuccess(key: string): void { + const before = pending.length; + pending = pending.filter((item) => item.key !== key); + if (pending.length !== before) { + persist(); + } + }, + + markFailure(key: string, reason: string, nowMs: number = Date.now()): void { + const item = pending.find((candidate) => candidate.key === key); + if (!item) { + return; + } + item.attemptCount += 1; + item.lastError = reason; + if (item.attemptCount >= MAX_ATTEMPTS) { + pending = pending.filter((candidate) => candidate.key !== key); + if (deadLetter.length >= MAX_ITEMS) { + deadLetter.shift(); + } + deadLetter.push({ + ...item, + nextAttemptAt: nowMs, + }); + logger.warn('AniList retry moved to dead-letter queue.', { + key, + reason, + attempts: item.attemptCount, + }); + persist(); + return; + } + item.nextAttemptAt = nowMs + clampBackoffMs(item.attemptCount); + persist(); + logger.warn('AniList retry scheduled with backoff.', { + key, + attemptCount: item.attemptCount, + nextAttemptAt: item.nextAttemptAt, + reason, + }); + }, + + getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot { + const ready = pending.filter((item) => item.nextAttemptAt <= nowMs).length; + return { + pending: pending.length, + ready, + deadLetter: deadLetter.length, + }; + }, + }; +} diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts new file mode 100644 index 0000000..632d0e7 --- /dev/null +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -0,0 +1,166 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as childProcess from 'child_process'; + +import { guessAnilistMediaInfo, updateAnilistPostWatchProgress } from './anilist-updater'; + +function createJsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +test('guessAnilistMediaInfo uses guessit output when available', async () => { + const originalExecFile = childProcess.execFile; + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = ((...args: unknown[]) => { + const callback = args[args.length - 1]; + const cb = + typeof callback === 'function' + ? (callback as (error: Error | null, stdout: string, stderr: string) => void) + : null; + cb?.(null, JSON.stringify({ title: 'Guessit Title', episode: 7 }), ''); + return {} as childProcess.ChildProcess; + }) as typeof childProcess.execFile; + + try { + const result = await guessAnilistMediaInfo('/tmp/demo.mkv', null); + assert.deepEqual(result, { + title: 'Guessit Title', + episode: 7, + source: 'guessit', + }); + } finally { + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = originalExecFile; + } +}); + +test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => { + const originalExecFile = childProcess.execFile; + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = ((...args: unknown[]) => { + const callback = args[args.length - 1]; + const cb = + typeof callback === 'function' + ? (callback as (error: Error | null, stdout: string, stderr: string) => void) + : null; + cb?.(new Error('guessit not found'), '', ''); + return {} as childProcess.ChildProcess; + }) as typeof childProcess.execFile; + + try { + const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null); + assert.deepEqual(result, { + title: 'My Anime', + episode: 3, + source: 'fallback', + }); + } finally { + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = originalExecFile; + } +}); + +test('updateAnilistPostWatchProgress updates progress when behind', async () => { + const originalFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Page: { + media: [ + { + id: 11, + episodes: 24, + title: { english: 'Demo Show', romaji: 'Demo Show' }, + }, + ], + }, + }, + }); + } + if (call === 2) { + return createJsonResponse({ + data: { + Media: { + id: 11, + mediaListEntry: { progress: 2, status: 'CURRENT' }, + }, + }, + }); + } + return createJsonResponse({ + data: { SaveMediaListEntry: { progress: 3, status: 'CURRENT' } }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3); + assert.equal(result.status, 'updated'); + assert.match(result.message, /episode 3/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('updateAnilistPostWatchProgress skips when progress already reached', async () => { + const originalFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Page: { + media: [{ id: 22, episodes: 12, title: { english: 'Skip Show' } }], + }, + }, + }); + } + return createJsonResponse({ + data: { + Media: { id: 22, mediaListEntry: { progress: 12, status: 'CURRENT' } }, + }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Skip Show', 10); + assert.equal(result.status, 'skipped'); + assert.match(result.message, /already at episode/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('updateAnilistPostWatchProgress returns error when search fails', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + createJsonResponse({ + errors: [{ message: 'bad request' }], + })) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Bad', 1); + assert.equal(result.status, 'error'); + assert.match(result.message, /search failed/i); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts new file mode 100644 index 0000000..4012651 --- /dev/null +++ b/src/core/services/anilist/anilist-updater.ts @@ -0,0 +1,299 @@ +import * as childProcess from 'child_process'; + +import { parseMediaInfo } from '../../../jimaku/utils'; + +const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; + +export interface AnilistMediaGuess { + title: string; + episode: number | null; + source: 'guessit' | 'fallback'; +} + +export interface AnilistPostWatchUpdateResult { + status: 'updated' | 'skipped' | 'error'; + message: string; +} + +interface AnilistGraphQlError { + message?: string; +} + +interface AnilistGraphQlResponse { + data?: T; + errors?: AnilistGraphQlError[]; +} + +interface AnilistSearchData { + Page?: { + media?: Array<{ + id: number; + episodes: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + }>; + }; +} + +interface AnilistMediaEntryData { + Media?: { + id: number; + mediaListEntry?: { + progress?: number | null; + status?: string | null; + } | null; + } | null; +} + +interface AnilistSaveEntryData { + SaveMediaListEntry?: { + progress?: number | null; + status?: string | null; + }; +} + +function runGuessit(target: string): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile( + 'guessit', + [target, '--json'], + { timeout: 5000, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); +} + +function firstString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (Array.isArray(value)) { + for (const item of value) { + const candidate = firstString(item); + if (candidate) return candidate; + } + } + return null; +} + +function firstPositiveInteger(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + } + if (Array.isArray(value)) { + for (const item of value) { + const candidate = firstPositiveInteger(item); + if (candidate !== null) return candidate; + } + } + return null; +} + +function normalizeTitle(text: string): string { + return text.trim().toLowerCase().replace(/\s+/g, ' '); +} + +async function anilistGraphQl( + accessToken: string, + query: string, + variables: Record, +): Promise> { + try { + const response = await fetch(ANILIST_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query, variables }), + }); + + const payload = (await response.json()) as AnilistGraphQlResponse; + return payload; + } catch (error) { + return { + errors: [ + { + message: error instanceof Error ? error.message : String(error), + }, + ], + }; + } +} + +function firstErrorMessage(response: AnilistGraphQlResponse): string | null { + const firstError = response.errors?.find((item) => Boolean(item?.message)); + return firstError?.message ?? null; +} + +function pickBestSearchResult( + title: string, + episode: number, + media: Array<{ + id: number; + episodes: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + }>, +): { id: number; title: string } | null { + const filtered = media.filter((item) => { + const totalEpisodes = item.episodes; + return totalEpisodes === null || totalEpisodes >= episode; + }); + const candidates = filtered.length > 0 ? filtered : media; + if (candidates.length === 0) return null; + + const normalizedTarget = normalizeTitle(title); + const exact = candidates.find((item) => { + const titles = [item.title?.romaji, item.title?.english, item.title?.native] + .filter((value): value is string => typeof value === 'string') + .map((value) => normalizeTitle(value)); + return titles.includes(normalizedTarget); + }); + + const selected = exact ?? candidates[0]!; + const selectedTitle = + selected.title?.english || selected.title?.romaji || selected.title?.native || title; + return { id: selected.id, title: selectedTitle }; +} + +export async function guessAnilistMediaInfo( + mediaPath: string | null, + mediaTitle: string | null, +): Promise { + const target = mediaPath ?? mediaTitle; + + if (target && target.trim().length > 0) { + try { + const stdout = await runGuessit(target); + const parsed = JSON.parse(stdout) as Record; + const title = firstString(parsed.title); + const episode = firstPositiveInteger(parsed.episode); + if (title) { + return { title, episode, source: 'guessit' }; + } + } catch { + // Ignore guessit failures and fall back to internal parser. + } + } + + const fallbackTarget = mediaPath ?? mediaTitle; + const parsed = parseMediaInfo(fallbackTarget); + if (!parsed.title.trim()) { + return null; + } + return { + title: parsed.title.trim(), + episode: parsed.episode, + source: 'fallback', + }; +} + +export async function updateAnilistPostWatchProgress( + accessToken: string, + title: string, + episode: number, +): Promise { + const searchResponse = await anilistGraphQl( + accessToken, + ` + query ($search: String!) { + Page(perPage: 5) { + media(search: $search, type: ANIME) { + id + episodes + title { + romaji + english + native + } + } + } + } + `, + { search: title }, + ); + const searchError = firstErrorMessage(searchResponse); + if (searchError) { + return { + status: 'error', + message: `AniList search failed: ${searchError}`, + }; + } + + const media = searchResponse.data?.Page?.media ?? []; + const picked = pickBestSearchResult(title, episode, media); + if (!picked) { + return { status: 'error', message: 'AniList search returned no matches.' }; + } + + const entryResponse = await anilistGraphQl( + accessToken, + ` + query ($mediaId: Int!) { + Media(id: $mediaId, type: ANIME) { + id + mediaListEntry { + progress + status + } + } + } + `, + { mediaId: picked.id }, + ); + const entryError = firstErrorMessage(entryResponse); + if (entryError) { + return { + status: 'error', + message: `AniList entry lookup failed: ${entryError}`, + }; + } + + const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0; + if (typeof currentProgress === 'number' && currentProgress >= episode) { + return { + status: 'skipped', + message: `AniList already at episode ${currentProgress} (${picked.title}).`, + }; + } + + const saveResponse = await anilistGraphQl( + accessToken, + ` + mutation ($mediaId: Int!, $progress: Int!) { + SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) { + progress + status + } + } + `, + { mediaId: picked.id, progress: episode }, + ); + const saveError = firstErrorMessage(saveResponse); + if (saveError) { + return { status: 'error', message: `AniList update failed: ${saveError}` }; + } + + return { + status: 'updated', + message: `AniList updated "${picked.title}" to episode ${episode}.`, + }; +} diff --git a/src/core/services/anki-jimaku-ipc.test.ts b/src/core/services/anki-jimaku-ipc.test.ts new file mode 100644 index 0000000..9f6afcc --- /dev/null +++ b/src/core/services/anki-jimaku-ipc.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerAnkiJimakuIpcHandlers } from './anki-jimaku-ipc'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; + +function createFakeRegistrar(): { + registrar: { + on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void; + handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void; + }; + onHandlers: Map void>; + handleHandlers: Map unknown>; +} { + const onHandlers = new Map void>(); + const handleHandlers = new Map unknown>(); + return { + registrar: { + on: (channel, listener) => { + onHandlers.set(channel, listener); + }, + handle: (channel, listener) => { + handleHandlers.set(channel, listener); + }, + }, + onHandlers, + handleHandlers, + }; +} + +test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => { + const { registrar, handleHandlers } = createFakeRegistrar(); + let previewCalls = 0; + registerAnkiJimakuIpcHandlers( + { + setAnkiConnectEnabled: () => {}, + clearAnkiHistory: () => {}, + refreshKnownWords: async () => {}, + respondFieldGrouping: () => {}, + buildKikuMergePreview: async () => { + previewCalls += 1; + return { ok: true }; + }, + getJimakuMediaInfo: () => ({ + title: 'x', + season: null, + episode: null, + confidence: 'high', + filename: 'x.mkv', + rawTitle: 'x', + }), + searchJimakuEntries: async () => ({ ok: true, data: [] }), + listJimakuFiles: async () => ({ ok: true, data: [] }), + resolveJimakuApiKey: async () => 'token', + getCurrentMediaPath: () => '/tmp/a.mkv', + isRemoteMediaPath: () => false, + downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }), + onDownloadedSubtitle: () => {}, + }, + registrar, + ); + + const previewHandler = handleHandlers.get(IPC_CHANNELS.request.kikuBuildMergePreview); + assert.ok(previewHandler); + const invalidPreviewResult = await previewHandler!({}, null); + assert.deepEqual(invalidPreviewResult, { + ok: false, + error: 'Invalid merge preview request payload', + }); + await previewHandler!({}, { keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: false }); + assert.equal(previewCalls, 1); + + const searchHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuSearchEntries); + assert.ok(searchHandler); + const invalidSearchResult = await searchHandler!({}, { query: 12 }); + assert.deepEqual(invalidSearchResult, { + ok: false, + error: { error: 'Invalid Jimaku search query payload', code: 400 }, + }); + + const filesHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuListFiles); + assert.ok(filesHandler); + const invalidFilesResult = await filesHandler!({}, { entryId: 'x' }); + assert.deepEqual(invalidFilesResult, { + ok: false, + error: { error: 'Invalid Jimaku files query payload', code: 400 }, + }); + + const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuDownloadFile); + assert.ok(downloadHandler); + const invalidDownloadResult = await downloadHandler!({}, { entryId: 1, url: '/x' }); + assert.deepEqual(invalidDownloadResult, { + ok: false, + error: { error: 'Invalid Jimaku download query payload', code: 400 }, + }); +}); + +test('anki/jimaku IPC command handlers ignore malformed payloads', () => { + const { registrar, onHandlers } = createFakeRegistrar(); + const fieldGroupingChoices: unknown[] = []; + const enabledStates: boolean[] = []; + registerAnkiJimakuIpcHandlers( + { + setAnkiConnectEnabled: (enabled) => { + enabledStates.push(enabled); + }, + clearAnkiHistory: () => {}, + refreshKnownWords: async () => {}, + respondFieldGrouping: (choice) => { + fieldGroupingChoices.push(choice); + }, + buildKikuMergePreview: async () => ({ ok: true }), + getJimakuMediaInfo: () => ({ + title: 'x', + season: null, + episode: null, + confidence: 'high', + filename: 'x.mkv', + rawTitle: 'x', + }), + searchJimakuEntries: async () => ({ ok: true, data: [] }), + listJimakuFiles: async () => ({ ok: true, data: [] }), + resolveJimakuApiKey: async () => 'token', + getCurrentMediaPath: () => '/tmp/a.mkv', + isRemoteMediaPath: () => false, + downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }), + onDownloadedSubtitle: () => {}, + }, + registrar, + ); + + onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, 'true'); + onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, true); + assert.deepEqual(enabledStates, [true]); + + onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!({}, null); + onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!( + {}, + { + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }, + ); + assert.deepEqual(fieldGroupingChoices, [ + { + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }, + ]); +}); diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts new file mode 100644 index 0000000..e318750 --- /dev/null +++ b/src/core/services/anki-jimaku-ipc.ts @@ -0,0 +1,185 @@ +import { ipcMain } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createLogger } from '../../logger'; +import { + JimakuApiResponse, + JimakuDownloadResult, + JimakuEntry, + JimakuFileEntry, + JimakuFilesQuery, + JimakuMediaInfo, + JimakuSearchQuery, + KikuFieldGroupingChoice, + KikuMergePreviewRequest, + KikuMergePreviewResponse, +} from '../../types'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { + parseJimakuDownloadQuery, + parseJimakuFilesQuery, + parseJimakuSearchQuery, + parseKikuFieldGroupingChoice, + parseKikuMergePreviewRequest, +} from '../../shared/ipc/validators'; + +const logger = createLogger('main:anki-jimaku-ipc'); + +export interface AnkiJimakuIpcDeps { + setAnkiConnectEnabled: (enabled: boolean) => void; + clearAnkiHistory: () => void; + refreshKnownWords: () => Promise | void; + respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void; + buildKikuMergePreview: (request: KikuMergePreviewRequest) => Promise; + getJimakuMediaInfo: () => JimakuMediaInfo; + searchJimakuEntries: (query: JimakuSearchQuery) => Promise>; + listJimakuFiles: (query: JimakuFilesQuery) => Promise>; + resolveJimakuApiKey: () => Promise; + getCurrentMediaPath: () => string | null; + isRemoteMediaPath: (mediaPath: string) => boolean; + downloadToFile: ( + url: string, + destPath: string, + headers: Record, + ) => Promise; + onDownloadedSubtitle: (pathToSubtitle: string) => void; +} + +interface IpcMainRegistrar { + on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void; + handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void; +} + +export function registerAnkiJimakuIpcHandlers( + deps: AnkiJimakuIpcDeps, + ipc: IpcMainRegistrar = ipcMain, +): void { + ipc.on(IPC_CHANNELS.command.setAnkiConnectEnabled, (_event: unknown, enabled: unknown) => { + if (typeof enabled !== 'boolean') return; + deps.setAnkiConnectEnabled(enabled); + }); + + ipc.on(IPC_CHANNELS.command.clearAnkiConnectHistory, () => { + deps.clearAnkiHistory(); + }); + + ipc.on(IPC_CHANNELS.command.refreshKnownWords, async () => { + await deps.refreshKnownWords(); + }); + + ipc.on(IPC_CHANNELS.command.kikuFieldGroupingRespond, (_event: unknown, choice: unknown) => { + const parsedChoice = parseKikuFieldGroupingChoice(choice); + if (!parsedChoice) return; + deps.respondFieldGrouping(parsedChoice); + }); + + ipc.handle( + IPC_CHANNELS.request.kikuBuildMergePreview, + async (_event, request: unknown): Promise => { + const parsedRequest = parseKikuMergePreviewRequest(request); + if (!parsedRequest) { + return { ok: false, error: 'Invalid merge preview request payload' }; + } + return deps.buildKikuMergePreview(parsedRequest); + }, + ); + + ipc.handle(IPC_CHANNELS.request.jimakuGetMediaInfo, (): JimakuMediaInfo => { + return deps.getJimakuMediaInfo(); + }); + + ipc.handle( + IPC_CHANNELS.request.jimakuSearchEntries, + async (_event, query: unknown): Promise> => { + const parsedQuery = parseJimakuSearchQuery(query); + if (!parsedQuery) { + return { ok: false, error: { error: 'Invalid Jimaku search query payload', code: 400 } }; + } + return deps.searchJimakuEntries(parsedQuery); + }, + ); + + ipc.handle( + IPC_CHANNELS.request.jimakuListFiles, + async (_event, query: unknown): Promise> => { + const parsedQuery = parseJimakuFilesQuery(query); + if (!parsedQuery) { + return { ok: false, error: { error: 'Invalid Jimaku files query payload', code: 400 } }; + } + return deps.listJimakuFiles(parsedQuery); + }, + ); + + ipc.handle( + IPC_CHANNELS.request.jimakuDownloadFile, + async (_event, query: unknown): Promise => { + const parsedQuery = parseJimakuDownloadQuery(query); + if (!parsedQuery) { + return { + ok: false, + error: { + error: 'Invalid Jimaku download query payload', + code: 400, + }, + }; + } + + const apiKey = await deps.resolveJimakuApiKey(); + if (!apiKey) { + return { + ok: false, + error: { + error: 'Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.', + code: 401, + }, + }; + } + + const currentMediaPath = deps.getCurrentMediaPath(); + if (!currentMediaPath) { + return { ok: false, error: { error: 'No media file loaded in MPV.' } }; + } + + const mediaDir = deps.isRemoteMediaPath(currentMediaPath) + ? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jimaku-')) + : path.dirname(path.resolve(currentMediaPath)); + const safeName = path.basename(parsedQuery.name); + if (!safeName) { + return { ok: false, error: { error: 'Invalid subtitle filename.' } }; + } + + const ext = path.extname(safeName); + const baseName = ext ? safeName.slice(0, -ext.length) : safeName; + let targetPath = path.join(mediaDir, safeName); + if (fs.existsSync(targetPath)) { + targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`); + let counter = 2; + while (fs.existsSync(targetPath)) { + targetPath = path.join( + mediaDir, + `${baseName} (jimaku-${parsedQuery.entryId}-${counter})${ext}`, + ); + counter += 1; + } + } + + logger.info( + `[jimaku] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`, + ); + const result = await deps.downloadToFile(parsedQuery.url, targetPath, { + Authorization: apiKey, + 'User-Agent': 'SubMiner', + }); + + if (result.ok) { + logger.info(`[jimaku] download-file saved to ${result.path}`); + deps.onDownloadedSubtitle(result.path); + } else { + logger.error(`[jimaku] download-file failed: ${result.error?.error ?? 'unknown error'}`); + } + + return result; + }, + ); +} diff --git a/src/core/services/anki-jimaku.test.ts b/src/core/services/anki-jimaku.test.ts new file mode 100644 index 0000000..8d6c143 --- /dev/null +++ b/src/core/services/anki-jimaku.test.ts @@ -0,0 +1,255 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { AnkiJimakuIpcRuntimeOptions, registerAnkiJimakuIpcRuntime } from './anki-jimaku'; + +interface RuntimeHarness { + options: AnkiJimakuIpcRuntimeOptions; + registered: Record unknown>; + state: { + ankiIntegration: unknown; + fieldGroupingResolver: ((choice: unknown) => void) | null; + patches: boolean[]; + broadcasts: number; + fetchCalls: Array<{ endpoint: string; query?: Record }>; + sentCommands: Array<{ command: string[] }>; + }; +} + +function createHarness(): RuntimeHarness { + const state = { + ankiIntegration: null as unknown, + fieldGroupingResolver: null as ((choice: unknown) => void) | null, + patches: [] as boolean[], + broadcasts: 0, + fetchCalls: [] as Array<{ + endpoint: string; + query?: Record; + }>, + sentCommands: [] as Array<{ command: string[] }>, + }; + + const options: AnkiJimakuIpcRuntimeOptions = { + patchAnkiConnectEnabled: (enabled) => { + state.patches.push(enabled); + }, + getResolvedConfig: () => ({}), + getRuntimeOptionsManager: () => null, + getSubtitleTimingTracker: () => null, + getMpvClient: () => ({ + connected: true, + send: (payload) => { + state.sentCommands.push(payload); + }, + }), + getAnkiIntegration: () => state.ankiIntegration as never, + setAnkiIntegration: (integration) => { + state.ankiIntegration = integration; + }, + getKnownWordCacheStatePath: () => '/tmp/subminer-known-words-cache.json', + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + broadcastRuntimeOptionsChanged: () => { + state.broadcasts += 1; + }, + getFieldGroupingResolver: () => state.fieldGroupingResolver as never, + setFieldGroupingResolver: (resolver) => { + state.fieldGroupingResolver = resolver as never; + }, + parseMediaInfo: () => ({ + title: 'video', + confidence: 'high', + rawTitle: 'video', + filename: 'video.mkv', + season: null, + episode: null, + }), + getCurrentMediaPath: () => '/tmp/video.mkv', + jimakuFetchJson: async (endpoint, query) => { + state.fetchCalls.push({ + endpoint, + query: query as Record, + }); + return { + ok: true, + data: [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + ] as never, + }; + }, + getJimakuMaxEntryResults: () => 2, + getJimakuLanguagePreference: () => 'ja', + resolveJimakuApiKey: async () => 'token', + isRemoteMediaPath: () => false, + downloadToFile: async (url, destPath) => ({ + ok: true, + path: `${destPath}:${url}`, + }), + }; + + let registered: Record unknown> = {}; + registerAnkiJimakuIpcRuntime(options, (deps) => { + registered = deps as unknown as Record unknown>; + }); + + return { options, registered, state }; +} + +test('registerAnkiJimakuIpcRuntime provides full handler surface', () => { + const { registered } = createHarness(); + const expected = [ + 'setAnkiConnectEnabled', + 'clearAnkiHistory', + 'refreshKnownWords', + 'respondFieldGrouping', + 'buildKikuMergePreview', + 'getJimakuMediaInfo', + 'searchJimakuEntries', + 'listJimakuFiles', + 'resolveJimakuApiKey', + 'getCurrentMediaPath', + 'isRemoteMediaPath', + 'downloadToFile', + 'onDownloadedSubtitle', + ]; + + for (const key of expected) { + assert.equal(typeof registered[key], 'function', `missing handler: ${key}`); + } +}); + +test('refreshKnownWords throws when integration is unavailable', async () => { + const { registered } = createHarness(); + + await assert.rejects( + async () => { + await registered.refreshKnownWords!(); + }, + { message: 'AnkiConnect integration not enabled' }, + ); +}); + +test('refreshKnownWords delegates to integration', async () => { + const { registered, state } = createHarness(); + let refreshed = 0; + state.ankiIntegration = { + refreshKnownWordCache: async () => { + refreshed += 1; + }, + }; + + await registered.refreshKnownWords!(); + + assert.equal(refreshed, 1); +}); + +test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => { + const { registered, state } = createHarness(); + let destroyed = 0; + state.ankiIntegration = { + destroy: () => { + destroyed += 1; + }, + }; + + registered.setAnkiConnectEnabled!(false); + + assert.deepEqual(state.patches, [false]); + assert.equal(destroyed, 1); + assert.equal(state.ankiIntegration, null); + assert.equal(state.broadcasts, 1); +}); + +test('clearAnkiHistory and respondFieldGrouping execute runtime callbacks', () => { + const { registered, state, options } = createHarness(); + let cleaned = 0; + let resolvedChoice: unknown = null; + state.fieldGroupingResolver = (choice) => { + resolvedChoice = choice; + }; + + const originalGetTracker = options.getSubtitleTimingTracker; + options.getSubtitleTimingTracker = () => + ({ + cleanup: () => { + cleaned += 1; + }, + }) as never; + + const choice = { + keepNoteId: 10, + deleteNoteId: 11, + deleteDuplicate: true, + cancelled: false, + }; + registered.clearAnkiHistory!(); + registered.respondFieldGrouping!(choice); + + options.getSubtitleTimingTracker = originalGetTracker; + + assert.equal(cleaned, 1); + assert.deepEqual(resolvedChoice, choice); + assert.equal(state.fieldGroupingResolver, null); +}); + +test('buildKikuMergePreview returns guard error when integration is missing', async () => { + const { registered } = createHarness(); + + const result = await registered.buildKikuMergePreview!({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + }); + + assert.deepEqual(result, { + ok: false, + error: 'AnkiConnect integration not enabled', + }); +}); + +test('buildKikuMergePreview delegates to integration when available', async () => { + const { registered, state } = createHarness(); + const calls: unknown[] = []; + state.ankiIntegration = { + buildFieldGroupingPreview: async ( + keepNoteId: number, + deleteNoteId: number, + deleteDuplicate: boolean, + ) => { + calls.push([keepNoteId, deleteNoteId, deleteDuplicate]); + return { ok: true }; + }, + }; + + const result = await registered.buildKikuMergePreview!({ + keepNoteId: 3, + deleteNoteId: 4, + deleteDuplicate: true, + }); + + assert.deepEqual(calls, [[3, 4, true]]); + assert.deepEqual(result, { ok: true }); +}); + +test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => { + const { registered, state } = createHarness(); + + const searchResult = await registered.searchJimakuEntries!({ query: 'test' }); + assert.deepEqual(state.fetchCalls, [ + { + endpoint: '/api/entries/search', + query: { anime: true, query: 'test' }, + }, + ]); + assert.equal((searchResult as { ok: boolean }).ok, true); + assert.equal((searchResult as { data: unknown[] }).data.length, 2); + + registered.onDownloadedSubtitle!('/tmp/subtitle.ass'); + assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]); +}); diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts new file mode 100644 index 0000000..71cb5f1 --- /dev/null +++ b/src/core/services/anki-jimaku.ts @@ -0,0 +1,185 @@ +import { AnkiIntegration } from '../../anki-integration'; +import { + AnkiConnectConfig, + JimakuApiResponse, + JimakuEntry, + JimakuFileEntry, + JimakuLanguagePreference, + JimakuMediaInfo, + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, +} from '../../types'; +import { sortJimakuFiles } from '../../jimaku/utils'; +import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc'; +import { createLogger } from '../../logger'; + +export type RegisterAnkiJimakuIpcRuntimeHandler = (deps: AnkiJimakuIpcDeps) => void; + +interface MpvClientLike { + connected: boolean; + send: (payload: { command: string[] }) => void; +} + +interface RuntimeOptionsManagerLike { + getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; +} + +interface SubtitleTimingTrackerLike { + cleanup: () => void; +} + +export interface AnkiJimakuIpcRuntimeOptions { + patchAnkiConnectEnabled: (enabled: boolean) => void; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; + getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; + getMpvClient: () => MpvClientLike | null; + getAnkiIntegration: () => AnkiIntegration | null; + setAnkiIntegration: (integration: AnkiIntegration | null) => void; + getKnownWordCacheStatePath: () => string; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + broadcastRuntimeOptionsChanged: () => void; + getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; + setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; + parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo; + getCurrentMediaPath: () => string | null; + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ) => Promise>; + getJimakuMaxEntryResults: () => number; + getJimakuLanguagePreference: () => JimakuLanguagePreference; + resolveJimakuApiKey: () => Promise; + isRemoteMediaPath: (mediaPath: string) => boolean; + downloadToFile: ( + url: string, + destPath: string, + headers: Record, + ) => Promise< + | { ok: true; path: string } + | { + ok: false; + error: { error: string; code?: number; retryAfter?: number }; + } + >; +} + +const logger = createLogger('main:anki-jimaku'); + +export function registerAnkiJimakuIpcRuntime( + options: AnkiJimakuIpcRuntimeOptions, + registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler, +): void { + registerHandlers({ + setAnkiConnectEnabled: (enabled) => { + options.patchAnkiConnectEnabled(enabled); + const config = options.getResolvedConfig(); + const subtitleTimingTracker = options.getSubtitleTimingTracker(); + const mpvClient = options.getMpvClient(); + const ankiIntegration = options.getAnkiIntegration(); + + if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) { + const runtimeOptionsManager = options.getRuntimeOptionsManager(); + const effectiveAnkiConfig = runtimeOptionsManager + ? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect) + : config.ankiConnect; + const integration = new AnkiIntegration( + effectiveAnkiConfig as never, + subtitleTimingTracker as never, + mpvClient as never, + (text: string) => { + if (mpvClient) { + mpvClient.send({ + command: ['show-text', text, '3000'], + }); + } + }, + options.showDesktopNotification, + options.createFieldGroupingCallback(), + options.getKnownWordCacheStatePath(), + ); + integration.start(); + options.setAnkiIntegration(integration); + logger.info('AnkiConnect integration enabled'); + } else if (!enabled && ankiIntegration) { + ankiIntegration.destroy(); + options.setAnkiIntegration(null); + logger.info('AnkiConnect integration disabled'); + } + + options.broadcastRuntimeOptionsChanged(); + }, + clearAnkiHistory: () => { + const subtitleTimingTracker = options.getSubtitleTimingTracker(); + if (subtitleTimingTracker) { + subtitleTimingTracker.cleanup(); + logger.info('AnkiConnect subtitle timing history cleared'); + } + }, + refreshKnownWords: async () => { + const integration = options.getAnkiIntegration(); + if (!integration) { + throw new Error('AnkiConnect integration not enabled'); + } + await integration.refreshKnownWordCache(); + }, + respondFieldGrouping: (choice) => { + const resolver = options.getFieldGroupingResolver(); + if (resolver) { + resolver(choice); + options.setFieldGroupingResolver(null); + } + }, + buildKikuMergePreview: async (request) => { + const integration = options.getAnkiIntegration(); + if (!integration) { + return { ok: false, error: 'AnkiConnect integration not enabled' }; + } + return integration.buildFieldGroupingPreview( + request.keepNoteId, + request.deleteNoteId, + request.deleteDuplicate, + ); + }, + getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()), + searchJimakuEntries: async (query) => { + logger.info(`[jimaku] search-entries query: "${query.query}"`); + const response = await options.jimakuFetchJson('/api/entries/search', { + anime: true, + query: query.query, + }); + if (!response.ok) return response; + const maxResults = options.getJimakuMaxEntryResults(); + logger.info( + `[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`, + ); + return { ok: true, data: response.data.slice(0, maxResults) }; + }, + listJimakuFiles: async (query) => { + logger.info(`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? 'all'}`); + const response = await options.jimakuFetchJson( + `/api/entries/${query.entryId}/files`, + { + episode: query.episode ?? undefined, + }, + ); + if (!response.ok) return response; + const sorted = sortJimakuFiles(response.data, options.getJimakuLanguagePreference()); + logger.info(`[jimaku] list-files returned ${sorted.length} files`); + return { ok: true, data: sorted }; + }, + resolveJimakuApiKey: () => options.resolveJimakuApiKey(), + getCurrentMediaPath: () => options.getCurrentMediaPath(), + isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath), + downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers), + onDownloadedSubtitle: (pathToSubtitle) => { + const mpvClient = options.getMpvClient(); + if (mpvClient && mpvClient.connected) { + mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] }); + } + }, + }); +} diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts new file mode 100644 index 0000000..da4536a --- /dev/null +++ b/src/core/services/app-lifecycle.ts @@ -0,0 +1,139 @@ +import { CliArgs, CliCommandSource } from '../../cli/args'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:app-lifecycle'); + +export interface AppLifecycleServiceDeps { + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + requestSingleInstanceLock: () => boolean; + quitApp: () => void; + onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void; + handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + whenReady: (handler: () => Promise) => void; + onWindowAllClosed: (handler: () => void) => void; + onWillQuit: (handler: () => void) => void; + onActivate: (handler: () => void) => void; + isDarwinPlatform: () => boolean; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; +} + +interface AppLike { + requestSingleInstanceLock: () => boolean; + quit: () => void; + on: (...args: any[]) => unknown; + whenReady: () => Promise; +} + +export interface AppLifecycleDepsRuntimeOptions { + app: AppLike; + platform: NodeJS.Platform; + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; +} + +export function createAppLifecycleDepsRuntime( + options: AppLifecycleDepsRuntimeOptions, +): AppLifecycleServiceDeps { + return { + shouldStartApp: options.shouldStartApp, + parseArgs: options.parseArgs, + requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), + quitApp: () => options.app.quit(), + onSecondInstance: (handler) => { + options.app.on('second-instance', handler as (...args: unknown[]) => void); + }, + handleCliCommand: options.handleCliCommand, + printHelp: options.printHelp, + logNoRunningInstance: options.logNoRunningInstance, + whenReady: (handler) => { + options.app + .whenReady() + .then(handler) + .catch((error) => { + logger.error('App ready handler failed:', error); + }); + }, + onWindowAllClosed: (handler) => { + options.app.on('window-all-closed', handler as (...args: unknown[]) => void); + }, + onWillQuit: (handler) => { + options.app.on('will-quit', handler as (...args: unknown[]) => void); + }, + onActivate: (handler) => { + options.app.on('activate', handler as (...args: unknown[]) => void); + }, + isDarwinPlatform: () => options.platform === 'darwin', + onReady: options.onReady, + onWillQuitCleanup: options.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: options.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: options.shouldQuitOnWindowAllClosed, + }; +} + +export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void { + const gotTheLock = deps.requestSingleInstanceLock(); + if (!gotTheLock) { + deps.quitApp(); + return; + } + + deps.onSecondInstance((_event, argv) => { + try { + deps.handleCliCommand(deps.parseArgs(argv), 'second-instance'); + } catch (error) { + logger.error('Failed to handle second-instance CLI command:', error); + } + }); + + if (initialArgs.help && !deps.shouldStartApp(initialArgs)) { + deps.printHelp(); + deps.quitApp(); + return; + } + + if (!deps.shouldStartApp(initialArgs)) { + if (initialArgs.stop && !initialArgs.start) { + deps.quitApp(); + } else { + deps.logNoRunningInstance(); + deps.quitApp(); + } + return; + } + + deps.whenReady(async () => { + await deps.onReady(); + }); + + deps.onWindowAllClosed(() => { + if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) { + deps.quitApp(); + } + }); + + deps.onWillQuit(() => { + deps.onWillQuitCleanup(); + }); + + deps.onActivate(() => { + if (deps.shouldRestoreWindowsOnActivate()) { + deps.restoreWindowsOnActivate(); + } + }); +} diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts new file mode 100644 index 0000000..eee41cb --- /dev/null +++ b/src/core/services/app-ready.test.ts @@ -0,0 +1,241 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup'; + +function makeDeps(overrides: Partial = {}) { + const calls: string[] = []; + const deps: AppReadyRuntimeDeps = { + loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), + resolveKeybindings: () => calls.push('resolveKeybindings'), + createMpvClient: () => calls.push('createMpvClient'), + reloadConfig: () => calls.push('reloadConfig'), + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + secondarySub: {}, + }), + getConfigWarnings: () => [], + logConfigWarning: () => calls.push('logConfigWarning'), + setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`), + initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'), + setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`), + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 9001, + hasMpvWebsocketPlugin: () => true, + startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`), + log: (message) => calls.push(`log:${message}`), + createMecabTokenizerAndCheck: async () => { + calls.push('createMecabTokenizerAndCheck'); + }, + createSubtitleTimingTracker: () => calls.push('createSubtitleTimingTracker'), + createImmersionTracker: () => calls.push('createImmersionTracker'), + startJellyfinRemoteSession: async () => { + calls.push('startJellyfinRemoteSession'); + }, + loadYomitanExtension: async () => { + calls.push('loadYomitanExtension'); + }, + prewarmSubtitleDictionaries: async () => { + calls.push('prewarmSubtitleDictionaries'); + }, + startBackgroundWarmups: () => { + calls.push('startBackgroundWarmups'); + }, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'), + handleInitialArgs: () => calls.push('handleInitialArgs'), + logDebug: (message) => calls.push(`debug:${message}`), + now: () => 1000, + ...overrides, + }; + return { deps, calls }; +} + +test('runAppReadyRuntime starts websocket in auto mode when plugin missing', async () => { + const { deps, calls } = makeDeps({ + hasMpvWebsocketPlugin: () => false, + }); + await runAppReadyRuntime(deps); + assert.ok(calls.includes('startSubtitleWebsocket:9001')); + assert.ok(calls.includes('initializeOverlayRuntime')); + assert.ok(calls.includes('createImmersionTracker')); + assert.ok(calls.includes('startBackgroundWarmups')); + assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.')); +}); + +test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { + const { deps, calls } = makeDeps({ + startJellyfinRemoteSession: undefined, + }); + + await runAppReadyRuntime(deps); + + assert.equal(calls.includes('startJellyfinRemoteSession'), false); + assert.ok(calls.includes('createMpvClient')); + assert.ok(calls.includes('createSubtitleTimingTracker')); + assert.ok(calls.includes('handleInitialArgs')); + assert.ok(calls.includes('startBackgroundWarmups')); + assert.ok( + calls.includes('initializeOverlayRuntime') || + calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'), + ); +}); + +test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => { + const { deps, calls } = makeDeps({ + createImmersionTracker: undefined, + }); + await runAppReadyRuntime(deps); + assert.ok(calls.includes('log:Runtime ready: createImmersionTracker dependency is missing.')); +}); + +test('runAppReadyRuntime logs and continues when createImmersionTracker throws', async () => { + const { deps, calls } = makeDeps({ + createImmersionTracker: () => { + calls.push('createImmersionTracker'); + throw new Error('immersion init failed'); + }, + }); + await runAppReadyRuntime(deps); + assert.ok(calls.includes('createImmersionTracker')); + assert.ok( + calls.includes('log:Runtime ready: createImmersionTracker failed: immersion init failed'), + ); + assert.ok(calls.includes('initializeOverlayRuntime')); + assert.ok(calls.includes('handleInitialArgs')); +}); + +test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => { + const { deps, calls } = makeDeps({ + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + }); + await runAppReadyRuntime(deps); + assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.')); +}); + +test('runAppReadyRuntime applies config logging level during app-ready', async () => { + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + secondarySub: {}, + logging: { level: 'warn' }, + }), + }); + await runAppReadyRuntime(deps); + assert.ok(calls.includes('setLogLevel:warn:config')); +}); + +test('runAppReadyRuntime does not await background warmups', async () => { + const calls: string[] = []; + let releaseWarmup: (() => void) | undefined; + const warmupGate = new Promise((resolve) => { + releaseWarmup = resolve; + }); + const { deps } = makeDeps({ + startBackgroundWarmups: () => { + calls.push('startBackgroundWarmups'); + void warmupGate.then(() => { + calls.push('warmupDone'); + }); + }, + handleInitialArgs: () => { + calls.push('handleInitialArgs'); + }, + }); + + await runAppReadyRuntime(deps); + assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']); + assert.equal(calls.includes('warmupDone'), false); + assert.ok(releaseWarmup); + releaseWarmup(); +}); + +test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => { + const capturedErrors: string[][] = []; + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + secondarySub: {}, + ankiConnect: { + enabled: true, + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: ' ', + miscInfo: 'MiscInfo', + translation: '', + }, + }, + }), + onCriticalConfigErrors: (errors) => { + capturedErrors.push(errors); + }, + }); + + await runAppReadyRuntime(deps); + + assert.equal(capturedErrors.length, 1); + assert.deepEqual(capturedErrors[0], [ + 'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.', + 'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.', + ]); + assert.ok(calls.includes('reloadConfig')); + assert.equal(calls.includes('createMpvClient'), false); + assert.equal(calls.includes('initRuntimeOptionsManager'), false); + assert.equal(calls.includes('startBackgroundWarmups'), false); +}); + +test('runAppReadyRuntime aggregates multiple critical anki mapping errors', async () => { + const capturedErrors: string[][] = []; + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + secondarySub: {}, + ankiConnect: { + enabled: true, + fields: { + audio: ' ', + image: '', + sentence: '\t', + miscInfo: ' ', + translation: '', + }, + }, + }), + onCriticalConfigErrors: (errors) => { + capturedErrors.push(errors); + }, + }); + + await runAppReadyRuntime(deps); + + const firstErrorSet = capturedErrors[0]!; + assert.equal(capturedErrors.length, 1); + assert.equal(firstErrorSet.length, 5); + assert.ok( + firstErrorSet.includes( + 'ankiConnect.fields.audio must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + firstErrorSet.includes( + 'ankiConnect.fields.image must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + firstErrorSet.includes( + 'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + firstErrorSet.includes( + 'ankiConnect.fields.miscInfo must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + firstErrorSet.includes( + 'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.equal(calls.includes('loadSubtitlePosition'), false); +}); diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts new file mode 100644 index 0000000..cd2ae62 --- /dev/null +++ b/src/core/services/cli-command.test.ts @@ -0,0 +1,469 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CliArgs } from '../../cli/args'; +import { CliCommandServiceDeps, handleCliCommand } from './cli-command'; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + background: false, + start: false, + stop: false, + toggle: false, + toggleVisibleOverlay: false, + toggleInvisibleOverlay: false, + settings: false, + show: false, + hide: false, + showVisibleOverlay: false, + hideVisibleOverlay: false, + showInvisibleOverlay: false, + hideInvisibleOverlay: false, + copySubtitle: false, + copySubtitleMultiple: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + toggleSecondarySub: false, + triggerFieldGrouping: false, + triggerSubsync: false, + markAudioCard: false, + refreshKnownWords: false, + openRuntimeOptions: false, + anilistStatus: false, + anilistLogout: false, + anilistSetup: false, + anilistRetryQueue: false, + jellyfin: false, + jellyfinLogin: false, + jellyfinLogout: false, + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + jellyfinSubtitleUrlsOnly: false, + jellyfinPlay: false, + jellyfinRemoteAnnounce: false, + texthooker: false, + help: false, + autoStartOverlay: false, + generateConfig: false, + backupOverwrite: false, + debug: false, + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}) { + const calls: string[] = []; + let mpvSocketPath = '/tmp/subminer.sock'; + let texthookerPort = 5174; + const osd: string[] = []; + + const deps: CliCommandServiceDeps = { + getMpvSocketPath: () => mpvSocketPath, + setMpvSocketPath: (socketPath) => { + mpvSocketPath = socketPath; + calls.push(`setMpvSocketPath:${socketPath}`); + }, + setMpvClientSocketPath: (socketPath) => { + calls.push(`setMpvClientSocketPath:${socketPath}`); + }, + hasMpvClient: () => true, + connectMpvClient: () => { + calls.push('connectMpvClient'); + }, + isTexthookerRunning: () => false, + setTexthookerPort: (port) => { + texthookerPort = port; + calls.push(`setTexthookerPort:${port}`); + }, + getTexthookerPort: () => texthookerPort, + shouldOpenTexthookerBrowser: () => true, + ensureTexthookerRunning: (port) => { + calls.push(`ensureTexthookerRunning:${port}`); + }, + openTexthookerInBrowser: (url) => { + calls.push(`openTexthookerInBrowser:${url}`); + }, + stopApp: () => { + calls.push('stopApp'); + }, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => { + calls.push('initializeOverlayRuntime'); + }, + toggleVisibleOverlay: () => { + calls.push('toggleVisibleOverlay'); + }, + toggleInvisibleOverlay: () => { + calls.push('toggleInvisibleOverlay'); + }, + openYomitanSettingsDelayed: (delayMs) => { + calls.push(`openYomitanSettingsDelayed:${delayMs}`); + }, + setVisibleOverlayVisible: (visible) => { + calls.push(`setVisibleOverlayVisible:${visible}`); + }, + setInvisibleOverlayVisible: (visible) => { + calls.push(`setInvisibleOverlayVisible:${visible}`); + }, + copyCurrentSubtitle: () => { + calls.push('copyCurrentSubtitle'); + }, + startPendingMultiCopy: (timeoutMs) => { + calls.push(`startPendingMultiCopy:${timeoutMs}`); + }, + mineSentenceCard: async () => { + calls.push('mineSentenceCard'); + }, + startPendingMineSentenceMultiple: (timeoutMs) => { + calls.push(`startPendingMineSentenceMultiple:${timeoutMs}`); + }, + updateLastCardFromClipboard: async () => { + calls.push('updateLastCardFromClipboard'); + }, + refreshKnownWords: async () => { + calls.push('refreshKnownWords'); + }, + cycleSecondarySubMode: () => { + calls.push('cycleSecondarySubMode'); + }, + triggerFieldGrouping: async () => { + calls.push('triggerFieldGrouping'); + }, + triggerSubsyncFromConfig: async () => { + calls.push('triggerSubsyncFromConfig'); + }, + markLastCardAsAudioCard: async () => { + calls.push('markLastCardAsAudioCard'); + }, + openRuntimeOptionsPalette: () => { + calls.push('openRuntimeOptionsPalette'); + }, + getAnilistStatus: () => ({ + tokenStatus: 'resolved', + tokenSource: 'stored', + tokenMessage: null, + tokenResolvedAt: 1, + tokenErrorAt: null, + queuePending: 2, + queueReady: 1, + queueDeadLetter: 0, + queueLastAttemptAt: 2, + queueLastError: null, + }), + clearAnilistToken: () => { + calls.push('clearAnilistToken'); + }, + openAnilistSetup: () => { + calls.push('openAnilistSetup'); + }, + openJellyfinSetup: () => { + calls.push('openJellyfinSetup'); + }, + getAnilistQueueStatus: () => ({ + pending: 2, + ready: 1, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }), + retryAnilistQueue: async () => { + calls.push('retryAnilistQueue'); + return { ok: true, message: 'AniList retry processed.' }; + }, + runJellyfinCommand: async () => { + calls.push('runJellyfinCommand'); + }, + printHelp: () => { + calls.push('printHelp'); + }, + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 2500, + showMpvOsd: (text) => { + osd.push(text); + }, + log: (message) => { + calls.push(`log:${message}`); + }, + warn: (message) => { + calls.push(`warn:${message}`); + }, + error: (message) => { + calls.push(`error:${message}`); + }, + ...overrides, + }; + + return { deps, calls, osd }; +} + +test('handleCliCommand ignores --start for second-instance when overlay runtime is already initialized', () => { + const { deps, calls } = createDeps({ + isOverlayRuntimeInitialized: () => true, + }); + const args = makeArgs({ start: true }); + + handleCliCommand(args, 'second-instance', deps); + + assert.ok(calls.includes('log:Ignoring --start because SubMiner is already running.')); + assert.equal( + calls.some((value) => value.includes('connectMpvClient')), + false, + ); +}); + +test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => { + const { deps, calls } = createDeps(); + const args = makeArgs({ start: true }); + + handleCliCommand(args, 'second-instance', deps); + + assert.equal( + calls.some((value) => value === 'log:Ignoring --start because SubMiner is already running.'), + false, + ); + assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock')); + assert.equal( + calls.some((value) => value.includes('connectMpvClient')), + true, + ); +}); + +test('handleCliCommand runs texthooker flow with browser open', () => { + const { deps, calls } = createDeps(); + const args = makeArgs({ texthooker: true }); + + handleCliCommand(args, 'initial', deps); + + assert.ok(calls.includes('ensureTexthookerRunning:5174')); + assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174')); +}); + +test('handleCliCommand reports async mine errors to OSD', async () => { + const { deps, calls, osd } = createDeps({ + mineSentenceCard: async () => { + throw new Error('boom'); + }, + }); + + handleCliCommand(makeArgs({ mineSentence: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.some((value) => value.startsWith('error:mineSentenceCard failed:'))); + assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom'))); +}); + +test('handleCliCommand applies socket path and connects on start', () => { + const { deps, calls } = createDeps(); + + handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps); + + assert.ok(calls.includes('initializeOverlayRuntime')); + assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock')); + assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock')); + assert.ok(calls.includes('connectMpvClient')); +}); + +test('handleCliCommand warns when texthooker port override used while running', () => { + const { deps, calls } = createDeps({ + isTexthookerRunning: () => true, + }); + + handleCliCommand(makeArgs({ texthookerPort: 9999, texthooker: true }), 'initial', deps); + + assert.ok( + calls.includes( + 'warn:Ignoring --port override because the texthooker server is already running.', + ), + ); + assert.equal( + calls.some((value) => value === 'setTexthookerPort:9999'), + false, + ); +}); + +test('handleCliCommand prints help and stops app when no window exists', () => { + const { deps, calls } = createDeps({ + hasMainWindow: () => false, + }); + + handleCliCommand(makeArgs({ help: true }), 'initial', deps); + + assert.ok(calls.includes('printHelp')); + assert.ok(calls.includes('stopApp')); +}); + +test('handleCliCommand reports async trigger-subsync errors to OSD', async () => { + const { deps, calls, osd } = createDeps({ + triggerSubsyncFromConfig: async () => { + throw new Error('subsync boom'); + }, + }); + + handleCliCommand(makeArgs({ triggerSubsync: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.some((value) => value.startsWith('error:triggerSubsyncFromConfig failed:'))); + assert.ok(osd.some((value) => value.includes('Subsync failed: subsync boom'))); +}); + +test('handleCliCommand stops app for --stop command', () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ stop: true }), 'initial', deps); + assert.ok(calls.includes('log:Stopping SubMiner...')); + assert.ok(calls.includes('stopApp')); +}); + +test('handleCliCommand still runs non-start actions on second-instance', () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ start: true, toggleVisibleOverlay: true }), 'second-instance', deps); + assert.ok(calls.includes('toggleVisibleOverlay')); + assert.equal( + calls.some((value) => value === 'connectMpvClient'), + true, + ); +}); + +test('handleCliCommand connects MPV for toggle on second-instance', () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps); + assert.ok(calls.includes('toggleVisibleOverlay')); + assert.equal( + calls.some((value) => value === 'connectMpvClient'), + true, + ); +}); + +test('handleCliCommand handles visibility and utility command dispatches', () => { + const cases: Array<{ + args: Partial; + expected: string; + }> = [ + { + args: { toggleInvisibleOverlay: true }, + expected: 'toggleInvisibleOverlay', + }, + { args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' }, + { + args: { showVisibleOverlay: true }, + expected: 'setVisibleOverlayVisible:true', + }, + { + args: { hideVisibleOverlay: true }, + expected: 'setVisibleOverlayVisible:false', + }, + { + args: { showInvisibleOverlay: true }, + expected: 'setInvisibleOverlayVisible:true', + }, + { + args: { hideInvisibleOverlay: true }, + expected: 'setInvisibleOverlayVisible:false', + }, + { args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' }, + { + args: { copySubtitleMultiple: true }, + expected: 'startPendingMultiCopy:2500', + }, + { + args: { mineSentenceMultiple: true }, + expected: 'startPendingMineSentenceMultiple:2500', + }, + { args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' }, + { + args: { openRuntimeOptions: true }, + expected: 'openRuntimeOptionsPalette', + }, + { args: { anilistLogout: true }, expected: 'clearAnilistToken' }, + { args: { anilistSetup: true }, expected: 'openAnilistSetup' }, + { args: { jellyfin: true }, expected: 'openJellyfinSetup' }, + ]; + + for (const entry of cases) { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs(entry.args), 'initial', deps); + assert.ok( + calls.includes(entry.expected), + `expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`, + ); + } +}); + +test('handleCliCommand logs AniList status details', () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); + assert.ok(calls.some((value) => value.startsWith('log:AniList token status:'))); + assert.ok(calls.some((value) => value.startsWith('log:AniList queue:'))); +}); + +test('handleCliCommand runs AniList retry command', async () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(calls.includes('retryAnilistQueue')); + assert.ok(calls.includes('log:AniList retry processed.')); +}); + +test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => { + const nonJellyfinArgs: Array> = [ + { start: true }, + { copySubtitle: true }, + { toggleVisibleOverlay: true }, + ]; + + for (const args of nonJellyfinArgs) { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs(args), 'initial', deps); + const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length; + assert.equal( + runJellyfinCallCount, + 0, + `Unexpected Jellyfin dispatch for args ${JSON.stringify(args)}`, + ); + } +}); + +test('handleCliCommand runs jellyfin command dispatcher', async () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps); + handleCliCommand(makeArgs({ jellyfinSubtitles: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + const runJellyfinCallCount = calls.filter((value) => value === 'runJellyfinCommand').length; + assert.equal(runJellyfinCallCount, 2); +}); + +test('handleCliCommand reports jellyfin command errors to OSD', async () => { + const { deps, calls, osd } = createDeps({ + runJellyfinCommand: async () => { + throw new Error('server offline'); + }, + }); + + handleCliCommand(makeArgs({ jellyfinLibraries: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.some((value) => value.startsWith('error:runJellyfinCommand failed:'))); + assert.ok(osd.some((value) => value.includes('Jellyfin command failed: server offline'))); +}); + +test('handleCliCommand runs refresh-known-words command', () => { + const { deps, calls } = createDeps(); + + handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps); + + assert.ok(calls.includes('refreshKnownWords')); +}); + +test('handleCliCommand reports async refresh-known-words errors to OSD', async () => { + const { deps, calls, osd } = createDeps({ + refreshKnownWords: async () => { + throw new Error('refresh boom'); + }, + }); + + handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.some((value) => value.startsWith('error:refreshKnownWords failed:'))); + assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom'))); +}); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts new file mode 100644 index 0000000..6f1902b --- /dev/null +++ b/src/core/services/cli-command.ts @@ -0,0 +1,458 @@ +import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args'; + +export interface CliCommandServiceDeps { + getMpvSocketPath: () => string; + setMpvSocketPath: (socketPath: string) => void; + setMpvClientSocketPath: (socketPath: string) => void; + hasMpvClient: () => boolean; + connectMpvClient: () => void; + isTexthookerRunning: () => boolean; + setTexthookerPort: (port: number) => void; + getTexthookerPort: () => number; + shouldOpenTexthookerBrowser: () => boolean; + ensureTexthookerRunning: (port: number) => void; + openTexthookerInBrowser: (url: string) => void; + stopApp: () => void; + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntime: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + openYomitanSettingsDelayed: (delayMs: number) => void; + setVisibleOverlayVisible: (visible: boolean) => void; + setInvisibleOverlayVisible: (visible: boolean) => void; + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWords: () => Promise; + cycleSecondarySubMode: () => void; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + openRuntimeOptionsPalette: () => void; + getAnilistStatus: () => { + tokenStatus: 'not_checked' | 'resolved' | 'error'; + tokenSource: 'none' | 'literal' | 'stored'; + tokenMessage: string | null; + tokenResolvedAt: number | null; + tokenErrorAt: number | null; + queuePending: number; + queueReady: number; + queueDeadLetter: number; + queueLastAttemptAt: number | null; + queueLastError: string | null; + }; + clearAnilistToken: () => void; + openAnilistSetup: () => void; + openJellyfinSetup: () => void; + getAnilistQueueStatus: () => { + pending: number; + ready: number; + deadLetter: number; + lastAttemptAt: number | null; + lastError: string | null; + }; + retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>; + runJellyfinCommand: (args: CliArgs) => Promise; + printHelp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + showMpvOsd: (text: string) => void; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string, err: unknown) => void; +} + +interface MpvClientLike { + setSocketPath: (socketPath: string) => void; + connect: () => void; +} + +interface TexthookerServiceLike { + isRunning: () => boolean; + start: (port: number) => void; +} + +interface MpvCliRuntime { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getClient: () => MpvClientLike | null; + showOsd: (text: string) => void; +} + +interface TexthookerCliRuntime { + service: TexthookerServiceLike; + getPort: () => number; + setPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openInBrowser: (url: string) => void; +} + +interface OverlayCliRuntime { + isInitialized: () => boolean; + initialize: () => void; + toggleVisible: () => void; + toggleInvisible: () => void; + setVisible: (visible: boolean) => void; + setInvisible: (visible: boolean) => void; +} + +interface MiningCliRuntime { + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWords: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; +} + +interface UiCliRuntime { + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; +} + +interface AnilistCliRuntime { + getStatus: CliCommandServiceDeps['getAnilistStatus']; + clearToken: CliCommandServiceDeps['clearAnilistToken']; + openSetup: CliCommandServiceDeps['openAnilistSetup']; + getQueueStatus: CliCommandServiceDeps['getAnilistQueueStatus']; + retryQueueNow: CliCommandServiceDeps['retryAnilistQueue']; +} + +interface AppCliRuntime { + stop: () => void; + hasMainWindow: () => boolean; +} + +export interface CliCommandDepsRuntimeOptions { + mpv: MpvCliRuntime; + texthooker: TexthookerCliRuntime; + overlay: OverlayCliRuntime; + mining: MiningCliRuntime; + anilist: AnilistCliRuntime; + jellyfin: { + openSetup: () => void; + runCommand: (args: CliArgs) => Promise; + }; + ui: UiCliRuntime; + app: AppCliRuntime; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => unknown; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string, err: unknown) => void; +} + +export function createCliCommandDepsRuntime( + options: CliCommandDepsRuntimeOptions, +): CliCommandServiceDeps { + return { + getMpvSocketPath: options.mpv.getSocketPath, + setMpvSocketPath: options.mpv.setSocketPath, + setMpvClientSocketPath: (socketPath) => { + const client = options.mpv.getClient(); + if (!client) return; + client.setSocketPath(socketPath); + }, + hasMpvClient: () => Boolean(options.mpv.getClient()), + connectMpvClient: () => { + const client = options.mpv.getClient(); + if (!client) return; + client.connect(); + }, + isTexthookerRunning: () => options.texthooker.service.isRunning(), + setTexthookerPort: options.texthooker.setPort, + getTexthookerPort: options.texthooker.getPort, + shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser, + ensureTexthookerRunning: (port) => { + if (!options.texthooker.service.isRunning()) { + options.texthooker.service.start(port); + } + }, + openTexthookerInBrowser: options.texthooker.openInBrowser, + stopApp: options.app.stop, + isOverlayRuntimeInitialized: options.overlay.isInitialized, + initializeOverlayRuntime: options.overlay.initialize, + toggleVisibleOverlay: options.overlay.toggleVisible, + toggleInvisibleOverlay: options.overlay.toggleInvisible, + openYomitanSettingsDelayed: (delayMs) => { + options.schedule(() => { + options.ui.openYomitanSettings(); + }, delayMs); + }, + setVisibleOverlayVisible: options.overlay.setVisible, + setInvisibleOverlayVisible: options.overlay.setInvisible, + copyCurrentSubtitle: options.mining.copyCurrentSubtitle, + startPendingMultiCopy: options.mining.startPendingMultiCopy, + mineSentenceCard: options.mining.mineSentenceCard, + startPendingMineSentenceMultiple: options.mining.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard, + refreshKnownWords: options.mining.refreshKnownWords, + cycleSecondarySubMode: options.ui.cycleSecondarySubMode, + triggerFieldGrouping: options.mining.triggerFieldGrouping, + triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, + markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, + openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, + getAnilistStatus: options.anilist.getStatus, + clearAnilistToken: options.anilist.clearToken, + openAnilistSetup: options.anilist.openSetup, + openJellyfinSetup: options.jellyfin.openSetup, + getAnilistQueueStatus: options.anilist.getQueueStatus, + retryAnilistQueue: options.anilist.retryQueueNow, + runJellyfinCommand: options.jellyfin.runCommand, + printHelp: options.ui.printHelp, + hasMainWindow: options.app.hasMainWindow, + getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, + showMpvOsd: options.mpv.showOsd, + log: options.log, + warn: options.warn, + error: options.error, + }; +} + +function formatTimestamp(value: number | null): string { + if (!value) return 'never'; + return new Date(value).toISOString(); +} + +function runAsyncWithOsd( + task: () => Promise, + deps: CliCommandServiceDeps, + logLabel: string, + osdLabel: string, +): void { + task().catch((err) => { + deps.error(`${logLabel} failed:`, err); + deps.showMpvOsd(`${osdLabel}: ${(err as Error).message}`); + }); +} + +export function handleCliCommand( + args: CliArgs, + source: CliCommandSource = 'initial', + deps: CliCommandServiceDeps, +): void { + const hasNonStartAction = + args.stop || + args.toggle || + args.toggleVisibleOverlay || + args.toggleInvisibleOverlay || + args.settings || + args.show || + args.hide || + args.showVisibleOverlay || + args.hideVisibleOverlay || + args.showInvisibleOverlay || + args.hideInvisibleOverlay || + args.copySubtitle || + args.copySubtitleMultiple || + args.mineSentence || + args.mineSentenceMultiple || + args.updateLastCardFromClipboard || + args.refreshKnownWords || + args.toggleSecondarySub || + args.triggerFieldGrouping || + args.triggerSubsync || + args.markAudioCard || + args.openRuntimeOptions || + args.anilistStatus || + args.anilistLogout || + args.anilistSetup || + args.anilistRetryQueue || + args.jellyfin || + args.jellyfinLogin || + args.jellyfinLogout || + args.jellyfinLibraries || + args.jellyfinItems || + args.jellyfinSubtitles || + args.jellyfinPlay || + args.jellyfinRemoteAnnounce || + args.texthooker || + args.help; + const ignoreStartOnly = + source === 'second-instance' && + args.start && + !hasNonStartAction && + deps.isOverlayRuntimeInitialized(); + if (ignoreStartOnly) { + deps.log('Ignoring --start because SubMiner is already running.'); + return; + } + + const shouldStart = + args.start || + args.toggle || + args.toggleVisibleOverlay || + args.toggleInvisibleOverlay; + const needsOverlayRuntime = commandNeedsOverlayRuntime(args); + const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start; + + if (args.socketPath !== undefined) { + deps.setMpvSocketPath(args.socketPath); + deps.setMpvClientSocketPath(args.socketPath); + } + + if (args.texthookerPort !== undefined) { + if (deps.isTexthookerRunning()) { + deps.warn('Ignoring --port override because the texthooker server is already running.'); + } else { + deps.setTexthookerPort(args.texthookerPort); + } + } + + if (args.stop) { + deps.log('Stopping SubMiner...'); + deps.stopApp(); + return; + } + + if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + + if (shouldStart && deps.hasMpvClient()) { + const socketPath = deps.getMpvSocketPath(); + deps.setMpvClientSocketPath(socketPath); + deps.connectMpvClient(); + deps.log(`Starting MPV IPC connection on socket: ${socketPath}`); + } + + if (args.toggle || args.toggleVisibleOverlay) { + deps.toggleVisibleOverlay(); + } else if (args.toggleInvisibleOverlay) { + deps.toggleInvisibleOverlay(); + } else if (args.settings) { + deps.openYomitanSettingsDelayed(1000); + } else if (args.show || args.showVisibleOverlay) { + deps.setVisibleOverlayVisible(true); + } else if (args.hide || args.hideVisibleOverlay) { + deps.setVisibleOverlayVisible(false); + } else if (args.showInvisibleOverlay) { + deps.setInvisibleOverlayVisible(true); + } else if (args.hideInvisibleOverlay) { + deps.setInvisibleOverlayVisible(false); + } else if (args.copySubtitle) { + deps.copyCurrentSubtitle(); + } else if (args.copySubtitleMultiple) { + deps.startPendingMultiCopy(deps.getMultiCopyTimeoutMs()); + } else if (args.mineSentence) { + runAsyncWithOsd( + () => deps.mineSentenceCard(), + deps, + 'mineSentenceCard', + 'Mine sentence failed', + ); + } else if (args.mineSentenceMultiple) { + deps.startPendingMineSentenceMultiple(deps.getMultiCopyTimeoutMs()); + } else if (args.updateLastCardFromClipboard) { + runAsyncWithOsd( + () => deps.updateLastCardFromClipboard(), + deps, + 'updateLastCardFromClipboard', + 'Update failed', + ); + } else if (args.refreshKnownWords) { + runAsyncWithOsd( + () => deps.refreshKnownWords(), + deps, + 'refreshKnownWords', + 'Refresh known words failed', + ); + } else if (args.toggleSecondarySub) { + deps.cycleSecondarySubMode(); + } else if (args.triggerFieldGrouping) { + runAsyncWithOsd( + () => deps.triggerFieldGrouping(), + deps, + 'triggerFieldGrouping', + 'Field grouping failed', + ); + } else if (args.triggerSubsync) { + runAsyncWithOsd( + () => deps.triggerSubsyncFromConfig(), + deps, + 'triggerSubsyncFromConfig', + 'Subsync failed', + ); + } else if (args.markAudioCard) { + runAsyncWithOsd( + () => deps.markLastCardAsAudioCard(), + deps, + 'markLastCardAsAudioCard', + 'Audio card failed', + ); + } else if (args.openRuntimeOptions) { + deps.openRuntimeOptionsPalette(); + } else if (args.anilistStatus) { + const status = deps.getAnilistStatus(); + deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`); + if (status.tokenMessage) { + deps.log(`AniList token message: ${status.tokenMessage}`); + } + deps.log( + `AniList token timestamps: resolved=${formatTimestamp(status.tokenResolvedAt)}, error=${formatTimestamp(status.tokenErrorAt)}`, + ); + deps.log( + `AniList queue: pending=${status.queuePending}, ready=${status.queueReady}, deadLetter=${status.queueDeadLetter}`, + ); + deps.log(`AniList queue timestamps: lastAttempt=${formatTimestamp(status.queueLastAttemptAt)}`); + if (status.queueLastError) { + deps.warn(`AniList queue last error: ${status.queueLastError}`); + } + } else if (args.anilistLogout) { + deps.clearAnilistToken(); + deps.log('Cleared stored AniList token.'); + } else if (args.anilistSetup) { + deps.openAnilistSetup(); + deps.log('Opened AniList setup flow.'); + } else if (args.jellyfin) { + deps.openJellyfinSetup(); + deps.log('Opened Jellyfin setup flow.'); + } else if (args.anilistRetryQueue) { + const queueStatus = deps.getAnilistQueueStatus(); + deps.log( + `AniList queue before retry: pending=${queueStatus.pending}, ready=${queueStatus.ready}, deadLetter=${queueStatus.deadLetter}`, + ); + runAsyncWithOsd( + async () => { + const result = await deps.retryAnilistQueue(); + if (result.ok) deps.log(result.message); + else deps.warn(result.message); + }, + deps, + 'retryAnilistQueue', + 'AniList retry failed', + ); + } else if ( + args.jellyfinLogin || + args.jellyfinLogout || + args.jellyfinLibraries || + args.jellyfinItems || + args.jellyfinSubtitles || + args.jellyfinPlay || + args.jellyfinRemoteAnnounce + ) { + runAsyncWithOsd( + () => deps.runJellyfinCommand(args), + deps, + 'runJellyfinCommand', + 'Jellyfin command failed', + ); + } else if (args.texthooker) { + const texthookerPort = deps.getTexthookerPort(); + deps.ensureTexthookerRunning(texthookerPort); + if (deps.shouldOpenTexthookerBrowser()) { + deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`); + } + deps.log(`Texthooker available at http://127.0.0.1:${texthookerPort}`); + } else if (args.help) { + deps.printHelp(); + if (!deps.hasMainWindow()) deps.stopApp(); + } +} diff --git a/src/core/services/config-hot-reload.test.ts b/src/core/services/config-hot-reload.test.ts new file mode 100644 index 0000000..f5a9f48 --- /dev/null +++ b/src/core/services/config-hot-reload.test.ts @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_CONFIG, deepCloneConfig } from '../../config'; +import { + classifyConfigHotReloadDiff, + createConfigHotReloadRuntime, + type ConfigHotReloadRuntimeDeps, +} from './config-hot-reload'; + +test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => { + const prev = deepCloneConfig(DEFAULT_CONFIG); + const next = deepCloneConfig(DEFAULT_CONFIG); + next.subtitleStyle.fontSize = prev.subtitleStyle.fontSize + 2; + next.websocket.port = prev.websocket.port + 1; + + const diff = classifyConfigHotReloadDiff(prev, next); + assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']); + assert.deepEqual(diff.restartRequiredFields, ['websocket']); +}); + +test('config hot reload runtime debounces rapid watch events', () => { + let watchedChangeCallback: (() => void) | null = null; + const pendingTimers = new Map void>(); + let nextTimerId = 1; + let reloadCalls = 0; + + const deps: ConfigHotReloadRuntimeDeps = { + getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG), + reloadConfigStrict: () => { + reloadCalls += 1; + return { + ok: true, + config: deepCloneConfig(DEFAULT_CONFIG), + warnings: [], + path: '/tmp/config.jsonc', + }; + }, + watchConfigPath: (_path, onChange) => { + watchedChangeCallback = onChange; + return { close: () => {} }; + }, + setTimeout: (callback) => { + const id = nextTimerId; + nextTimerId += 1; + pendingTimers.set(id, callback); + return id as unknown as NodeJS.Timeout; + }, + clearTimeout: (timeout) => { + pendingTimers.delete(timeout as unknown as number); + }, + debounceMs: 25, + onHotReloadApplied: () => {}, + onRestartRequired: () => {}, + onInvalidConfig: () => {}, + onValidationWarnings: () => {}, + }; + + const runtime = createConfigHotReloadRuntime(deps); + runtime.start(); + assert.equal(reloadCalls, 1); + if (!watchedChangeCallback) { + throw new Error('Expected watch callback to be registered.'); + } + const trigger = watchedChangeCallback as () => void; + + trigger(); + trigger(); + trigger(); + assert.equal(pendingTimers.size, 1); + + for (const callback of pendingTimers.values()) { + callback(); + } + assert.equal(reloadCalls, 2); +}); + +test('config hot reload runtime reports invalid config and skips apply', () => { + const invalidMessages: string[] = []; + let watchedChangeCallback: (() => void) | null = null; + + const runtime = createConfigHotReloadRuntime({ + getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG), + reloadConfigStrict: () => ({ + ok: false, + error: 'Invalid JSON', + path: '/tmp/config.jsonc', + }), + watchConfigPath: (_path, onChange) => { + watchedChangeCallback = onChange; + return { close: () => {} }; + }, + setTimeout: (callback) => { + callback(); + return 1 as unknown as NodeJS.Timeout; + }, + clearTimeout: () => {}, + debounceMs: 0, + onHotReloadApplied: () => { + throw new Error('Hot reload should not apply for invalid config.'); + }, + onRestartRequired: () => { + throw new Error('Restart warning should not trigger for invalid config.'); + }, + onInvalidConfig: (message) => { + invalidMessages.push(message); + }, + onValidationWarnings: () => { + throw new Error('Validation warnings should not trigger for invalid config.'); + }, + }); + + runtime.start(); + assert.equal(watchedChangeCallback, null); + assert.equal(invalidMessages.length, 1); +}); + +test('config hot reload runtime reports validation warnings from reload', () => { + let watchedChangeCallback: (() => void) | null = null; + const warningCalls: Array<{ path: string; count: number }> = []; + + const runtime = createConfigHotReloadRuntime({ + getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG), + reloadConfigStrict: () => ({ + ok: true, + config: deepCloneConfig(DEFAULT_CONFIG), + warnings: [ + { + path: 'ankiConnect.openRouter', + message: 'Deprecated key; use ankiConnect.ai instead.', + value: { enabled: true }, + fallback: {}, + }, + ], + path: '/tmp/config.jsonc', + }), + watchConfigPath: (_path, onChange) => { + watchedChangeCallback = onChange; + return { close: () => {} }; + }, + setTimeout: (callback) => { + callback(); + return 1 as unknown as NodeJS.Timeout; + }, + clearTimeout: () => {}, + debounceMs: 0, + onHotReloadApplied: () => {}, + onRestartRequired: () => {}, + onInvalidConfig: () => {}, + onValidationWarnings: (path, warnings) => { + warningCalls.push({ path, count: warnings.length }); + }, + }); + + runtime.start(); + assert.equal(warningCalls.length, 0); + if (!watchedChangeCallback) { + throw new Error('Expected watch callback to be registered.'); + } + const trigger = watchedChangeCallback as () => void; + trigger(); + assert.deepEqual(warningCalls, [{ path: '/tmp/config.jsonc', count: 1 }]); +}); diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts new file mode 100644 index 0000000..a319425 --- /dev/null +++ b/src/core/services/config-hot-reload.ts @@ -0,0 +1,165 @@ +import { type ReloadConfigStrictResult } from '../../config'; +import type { ConfigValidationWarning } from '../../types'; +import type { ResolvedConfig } from '../../types'; + +export interface ConfigHotReloadDiff { + hotReloadFields: string[]; + restartRequiredFields: string[]; +} + +export interface ConfigHotReloadRuntimeDeps { + getCurrentConfig: () => ResolvedConfig; + reloadConfigStrict: () => ReloadConfigStrictResult; + watchConfigPath: (configPath: string, onChange: () => void) => { close: () => void }; + setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout; + clearTimeout: (timeout: NodeJS.Timeout) => void; + debounceMs?: number; + onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void; + onRestartRequired: (fields: string[]) => void; + onInvalidConfig: (message: string) => void; + onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void; +} + +export interface ConfigHotReloadRuntime { + start: () => void; + stop: () => void; +} + +function isEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff { + const hotReloadFields: string[] = []; + const restartRequiredFields: string[] = []; + + if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) { + hotReloadFields.push('subtitleStyle'); + } + if (!isEqual(prev.keybindings, next.keybindings)) { + hotReloadFields.push('keybindings'); + } + if (!isEqual(prev.shortcuts, next.shortcuts)) { + hotReloadFields.push('shortcuts'); + } + if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) { + hotReloadFields.push('secondarySub.defaultMode'); + } + if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) { + hotReloadFields.push('ankiConnect.ai'); + } + + const keys = new Set([ + ...(Object.keys(prev) as Array), + ...(Object.keys(next) as Array), + ]); + + for (const key of keys) { + if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') { + continue; + } + + if (key === 'secondarySub') { + const normalizedPrev = { + ...prev.secondarySub, + defaultMode: next.secondarySub.defaultMode, + }; + if (!isEqual(normalizedPrev, next.secondarySub)) { + restartRequiredFields.push('secondarySub'); + } + continue; + } + + if (key === 'ankiConnect') { + const normalizedPrev = { + ...prev.ankiConnect, + ai: next.ankiConnect.ai, + }; + if (!isEqual(normalizedPrev, next.ankiConnect)) { + restartRequiredFields.push('ankiConnect'); + } + continue; + } + + if (!isEqual(prev[key], next[key])) { + restartRequiredFields.push(String(key)); + } + } + + return { hotReloadFields, restartRequiredFields }; +} + +export function createConfigHotReloadRuntime( + deps: ConfigHotReloadRuntimeDeps, +): ConfigHotReloadRuntime { + let watcher: { close: () => void } | null = null; + let timer: NodeJS.Timeout | null = null; + let watchedPath: string | null = null; + const debounceMs = deps.debounceMs ?? 250; + + const reloadWithDiff = () => { + const prev = deps.getCurrentConfig(); + const result = deps.reloadConfigStrict(); + if (!result.ok) { + deps.onInvalidConfig(`Config reload failed: ${result.error}`); + return; + } + + if (watchedPath !== result.path) { + watchPath(result.path); + } + + if (result.warnings.length > 0) { + deps.onValidationWarnings(result.path, result.warnings); + } + + const diff = classifyDiff(prev, result.config); + if (diff.hotReloadFields.length > 0) { + deps.onHotReloadApplied(diff, result.config); + } + if (diff.restartRequiredFields.length > 0) { + deps.onRestartRequired(diff.restartRequiredFields); + } + }; + + const scheduleReload = () => { + if (timer) { + deps.clearTimeout(timer); + } + timer = deps.setTimeout(() => { + timer = null; + reloadWithDiff(); + }, debounceMs); + }; + + const watchPath = (configPath: string) => { + watcher?.close(); + watcher = deps.watchConfigPath(configPath, scheduleReload); + watchedPath = configPath; + }; + + return { + start: () => { + if (watcher) { + return; + } + const result = deps.reloadConfigStrict(); + if (!result.ok) { + deps.onInvalidConfig(`Config watcher startup failed: ${result.error}`); + return; + } + watchPath(result.path); + }, + stop: () => { + if (timer) { + deps.clearTimeout(timer); + timer = null; + } + watcher?.close(); + watcher = null; + watchedPath = null; + }, + }; +} + +export { classifyDiff as classifyConfigHotReloadDiff }; diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts new file mode 100644 index 0000000..cefa47c --- /dev/null +++ b/src/core/services/discord-presence.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildDiscordPresenceActivity, + createDiscordPresenceService, + type DiscordActivityPayload, + type DiscordPresenceSnapshot, +} from './discord-presence'; + +const baseConfig = { + enabled: true, + updateIntervalMs: 10_000, + debounceMs: 200, +} as const; + +const baseSnapshot: DiscordPresenceSnapshot = { + mediaTitle: 'Sousou no Frieren E01', + mediaPath: '/media/Frieren/E01.mkv', + subtitleText: '旅立ち', + currentTimeSec: 95, + mediaDurationSec: 1450, + paused: false, + connected: true, + sessionStartedAtMs: 1_700_000_000_000, +}; + +test('buildDiscordPresenceActivity maps polished payload fields', () => { + const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot); + assert.equal(payload.details, 'Sousou no Frieren E01'); + assert.equal(payload.state, 'Playing 01:35 / 24:10'); + assert.equal(payload.largeImageKey, 'subminer-logo'); + assert.equal(payload.smallImageKey, 'study'); + assert.equal(payload.buttons, undefined); + assert.equal(payload.startTimestamp, 1_700_000_000); +}); + +test('buildDiscordPresenceActivity falls back to idle when disconnected', () => { + const payload = buildDiscordPresenceActivity(baseConfig, { + ...baseSnapshot, + connected: false, + mediaPath: null, + }); + assert.equal(payload.state, 'Idle'); + assert.equal(payload.details, 'Mining and crafting (Anki cards)'); +}); + +test('service deduplicates identical updates and sends changed timeline', async () => { + const sent: DiscordActivityPayload[] = []; + const timers = new Map void>(); + let timerId = 0; + let nowMs = 100_000; + + const service = createDiscordPresenceService({ + config: baseConfig, + createClient: () => ({ + login: async () => {}, + setActivity: async (activity) => { + sent.push(activity); + }, + clearActivity: async () => {}, + destroy: () => {}, + }), + now: () => nowMs, + setTimeoutFn: (callback) => { + const id = ++timerId; + timers.set(id, callback); + return id as unknown as ReturnType; + }, + clearTimeoutFn: (id) => { + timers.delete(id as unknown as number); + }, + }); + + await service.start(); + service.publish(baseSnapshot); + timers.get(1)?.(); + await Promise.resolve(); + assert.equal(sent.length, 1); + + service.publish(baseSnapshot); + timers.get(2)?.(); + await Promise.resolve(); + assert.equal(sent.length, 1); + + nowMs += 10_001; + service.publish({ ...baseSnapshot, paused: true, currentTimeSec: 100 }); + timers.get(3)?.(); + await Promise.resolve(); + assert.equal(sent.length, 2); + assert.equal(sent[1]?.state, 'Paused 01:40 / 24:10'); +}); + +test('service handles login failure and stop without throwing', async () => { + let destroyed = false; + const service = createDiscordPresenceService({ + config: baseConfig, + createClient: () => ({ + login: async () => { + throw new Error('discord not running'); + }, + setActivity: async () => {}, + clearActivity: async () => {}, + destroy: () => { + destroyed = true; + }, + }), + }); + + await assert.doesNotReject(async () => service.start()); + await assert.doesNotReject(async () => service.stop()); + assert.equal(destroyed, false); +}); diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts new file mode 100644 index 0000000..5876a34 --- /dev/null +++ b/src/core/services/discord-presence.ts @@ -0,0 +1,223 @@ +import type { ResolvedConfig } from '../../types'; + +export interface DiscordPresenceSnapshot { + mediaTitle: string | null; + mediaPath: string | null; + subtitleText: string; + currentTimeSec?: number | null; + mediaDurationSec?: number | null; + paused: boolean | null; + connected: boolean; + sessionStartedAtMs: number; +} + +type DiscordPresenceConfig = ResolvedConfig['discordPresence']; + +export interface DiscordActivityPayload { + details: string; + state: string; + startTimestamp: number; + largeImageKey?: string; + largeImageText?: string; + smallImageKey?: string; + smallImageText?: string; + buttons?: Array<{ label: string; url: string }>; +} + +type DiscordClient = { + login: () => Promise; + setActivity: (activity: DiscordActivityPayload) => Promise; + clearActivity: () => Promise; + destroy: () => void; +}; + +type TimeoutLike = ReturnType; + +const DISCORD_PRESENCE_STYLE = { + fallbackDetails: 'Mining and crafting (Anki cards)', + largeImageKey: 'subminer-logo', + largeImageText: 'SubMiner', + smallImageKey: 'study', + smallImageText: 'Sentence Mining', + buttonLabel: '', + buttonUrl: '', +} as const; + +function trimField(value: string, maxLength = 128): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function sanitizeText(value: string | null | undefined, fallback: string): string { + const text = value?.trim(); + if (!text) return fallback; + return text; +} + +function basename(filePath: string | null): string { + if (!filePath) return ''; + const parts = filePath.split(/[\\/]/); + return parts[parts.length - 1] ?? ''; +} + +function buildStatus(snapshot: DiscordPresenceSnapshot): string { + if (!snapshot.connected || !snapshot.mediaPath) return 'Idle'; + if (snapshot.paused) return 'Paused'; + return 'Playing'; +} + +function formatClock(totalSeconds: number | null | undefined): string { + if (!Number.isFinite(totalSeconds) || (totalSeconds ?? -1) < 0) return '--:--'; + const rounded = Math.floor(totalSeconds as number); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const seconds = rounded % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +export function buildDiscordPresenceActivity( + _config: DiscordPresenceConfig, + snapshot: DiscordPresenceSnapshot, +): DiscordActivityPayload { + const status = buildStatus(snapshot); + const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); + const details = + snapshot.connected && snapshot.mediaPath + ? trimField(title) + : DISCORD_PRESENCE_STYLE.fallbackDetails; + const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; + const state = + snapshot.connected && snapshot.mediaPath + ? trimField(`${status} ${timeline}`) + : trimField(status); + + const activity: DiscordActivityPayload = { + details, + state, + startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000), + }; + + if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) { + activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim(); + } + if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) { + activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim()); + } + if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) { + activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim(); + } + if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) { + activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim()); + } + if ( + DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 && + /^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim()) + ) { + activity.buttons = [ + { + label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32), + url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(), + }, + ]; + } + + return activity; +} + +export function createDiscordPresenceService(deps: { + config: DiscordPresenceConfig; + createClient: () => DiscordClient; + now?: () => number; + setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike; + clearTimeoutFn?: (timer: TimeoutLike) => void; + logDebug?: (message: string, meta?: unknown) => void; +}) { + const now = deps.now ?? (() => Date.now()); + const setTimeoutFn = deps.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + const clearTimeoutFn = deps.clearTimeoutFn ?? ((timer) => clearTimeout(timer)); + const logDebug = deps.logDebug ?? (() => {}); + + let client: DiscordClient | null = null; + let pendingSnapshot: DiscordPresenceSnapshot | null = null; + let debounceTimer: TimeoutLike | null = null; + let intervalTimer: TimeoutLike | null = null; + let lastActivityKey = ''; + let lastSentAtMs = 0; + + async function flush(): Promise { + if (!client || !pendingSnapshot) return; + const elapsed = now() - lastSentAtMs; + if (elapsed < deps.config.updateIntervalMs) { + const delay = Math.max(0, deps.config.updateIntervalMs - elapsed); + if (intervalTimer) clearTimeoutFn(intervalTimer); + intervalTimer = setTimeoutFn(() => { + void flush(); + }, delay); + return; + } + + const payload = buildDiscordPresenceActivity(deps.config, pendingSnapshot); + const activityKey = JSON.stringify(payload); + if (activityKey === lastActivityKey) return; + + try { + await client.setActivity(payload); + lastSentAtMs = now(); + lastActivityKey = activityKey; + } catch (error) { + logDebug('[discord-presence] failed to set activity', error); + } + } + + function scheduleFlush(snapshot: DiscordPresenceSnapshot): void { + pendingSnapshot = snapshot; + if (debounceTimer) { + clearTimeoutFn(debounceTimer); + } + debounceTimer = setTimeoutFn(() => { + debounceTimer = null; + void flush(); + }, deps.config.debounceMs); + } + + return { + async start(): Promise { + if (!deps.config.enabled) return; + try { + client = deps.createClient(); + await client.login(); + } catch (error) { + client = null; + logDebug('[discord-presence] login failed', error); + } + }, + publish(snapshot: DiscordPresenceSnapshot): void { + if (!client) return; + scheduleFlush(snapshot); + }, + async stop(): Promise { + if (debounceTimer) { + clearTimeoutFn(debounceTimer); + debounceTimer = null; + } + if (intervalTimer) { + clearTimeoutFn(intervalTimer); + intervalTimer = null; + } + pendingSnapshot = null; + lastActivityKey = ''; + lastSentAtMs = 0; + if (!client) return; + try { + await client.clearActivity(); + } catch (error) { + logDebug('[discord-presence] clear activity failed', error); + } + client.destroy(); + client = null; + }, + }; +} diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts new file mode 100644 index 0000000..47921f6 --- /dev/null +++ b/src/core/services/field-grouping-overlay.test.ts @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { KikuFieldGroupingChoice } from '../../types'; +import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay'; + +test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => { + const sent: unknown[][] = []; + let visible = false; + const restore = new Set<'runtime-options' | 'subsync'>(); + + const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ + getMainWindow: () => ({ + isDestroyed: () => false, + webContents: { + isLoading: () => false, + send: (...args: unknown[]) => { + sent.push(args); + }, + }, + }), + getVisibleOverlayVisible: () => visible, + getInvisibleOverlayVisible: () => false, + setVisibleOverlayVisible: (next) => { + visible = next; + }, + setInvisibleOverlayVisible: () => {}, + getResolver: () => null, + setResolver: () => {}, + getRestoreVisibleOverlayOnModalClose: () => restore, + }); + + const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + + assert.equal(ok, true); + assert.equal(visible, true); + assert.equal(restore.has('runtime-options'), true); + assert.deepEqual(sent, [['runtime-options:open']]); +}); + +test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => { + let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; + const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ + getMainWindow: () => null, + getVisibleOverlayVisible: () => false, + getInvisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + setInvisibleOverlayVisible: () => {}, + getResolver: () => resolver, + setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => { + resolver = next; + }, + getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(), + }); + + const callback = runtime.createFieldGroupingCallback(); + const result = await callback({ + original: { + noteId: 1, + expression: 'a', + sentencePreview: 'a', + hasAudio: false, + hasImage: false, + isOriginal: true, + }, + duplicate: { + noteId: 2, + expression: 'b', + sentencePreview: 'b', + hasAudio: false, + hasImage: false, + isOriginal: false, + }, + }); + + assert.equal(result.cancelled, true); + assert.equal(result.keepNoteId, 0); + assert.equal(result.deleteNoteId, 0); +}); + +test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => { + let resolver: unknown = null; + let visible = false; + const visibilityTransitions: boolean[] = []; + + const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ + getMainWindow: () => null, + getVisibleOverlayVisible: () => visible, + getInvisibleOverlayVisible: () => false, + setVisibleOverlayVisible: (nextVisible) => { + visible = nextVisible; + visibilityTransitions.push(nextVisible); + }, + setInvisibleOverlayVisible: () => {}, + getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null, + setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => { + resolver = nextResolver; + }, + getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(), + sendToVisibleOverlay: () => true, + }); + + const callback = runtime.createFieldGroupingCallback(); + const pendingChoice = callback({ + original: { + noteId: 1, + expression: 'a', + sentencePreview: 'a', + hasAudio: false, + hasImage: false, + isOriginal: true, + }, + duplicate: { + noteId: 2, + expression: 'b', + sentencePreview: 'b', + hasAudio: false, + hasImage: false, + isOriginal: false, + }, + }); + + assert.equal(visible, true); + assert.ok(resolver); + + if (typeof resolver !== 'function') { + throw new Error('expected field grouping resolver to be assigned'); + } + + (resolver as (choice: KikuFieldGroupingChoice) => void)({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: true, + cancelled: false, + }); + await pendingChoice; + + assert.equal(visible, false); + assert.deepEqual(visibilityTransitions, [true, false]); +}); diff --git a/src/core/services/field-grouping-overlay.ts b/src/core/services/field-grouping-overlay.ts new file mode 100644 index 0000000..df5feb5 --- /dev/null +++ b/src/core/services/field-grouping-overlay.ts @@ -0,0 +1,81 @@ +import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types'; +import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge'; + +interface WindowLike { + isDestroyed: () => boolean; + webContents: { + send: (channel: string, payload?: unknown) => void; + }; +} + +export interface FieldGroupingOverlayRuntimeOptions { + getMainWindow: () => WindowLike | null; + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + setInvisibleOverlayVisible: (visible: boolean) => void; + getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; + setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; + getRestoreVisibleOverlayOnModalClose: () => Set; + sendToVisibleOverlay?: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: T }, + ) => boolean; +} + +export function createFieldGroupingOverlayRuntime( + options: FieldGroupingOverlayRuntimeOptions, +): { + sendToVisibleOverlay: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: T }, + ) => boolean; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; +} { + const sendToVisibleOverlay = ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: T }, + ): boolean => { + if (options.sendToVisibleOverlay) { + const wasVisible = options.getVisibleOverlayVisible(); + const sent = options.sendToVisibleOverlay(channel, payload, runtimeOptions); + if (sent && !wasVisible && !options.getVisibleOverlayVisible()) { + options.setVisibleOverlayVisible(true); + } + return sent; + } + return sendToVisibleOverlayRuntime({ + mainWindow: options.getMainWindow() as never, + visibleOverlayVisible: options.getVisibleOverlayVisible(), + setVisibleOverlayVisible: options.setVisibleOverlayVisible, + channel, + payload, + restoreOnModalClose: runtimeOptions?.restoreOnModalClose, + restoreVisibleOverlayOnModalClose: options.getRestoreVisibleOverlayOnModalClose(), + }); + }; + + const createFieldGroupingCallback = (): (( + data: KikuFieldGroupingRequestData, + ) => Promise) => { + return createFieldGroupingCallbackRuntime({ + getVisibleOverlayVisible: options.getVisibleOverlayVisible, + getInvisibleOverlayVisible: options.getInvisibleOverlayVisible, + setVisibleOverlayVisible: options.setVisibleOverlayVisible, + setInvisibleOverlayVisible: options.setInvisibleOverlayVisible, + getResolver: options.getResolver, + setResolver: options.setResolver, + sendToVisibleOverlay, + }); + }; + + return { + sendToVisibleOverlay, + createFieldGroupingCallback, + }; +} diff --git a/src/core/services/field-grouping.ts b/src/core/services/field-grouping.ts new file mode 100644 index 0000000..4d6d6ec --- /dev/null +++ b/src/core/services/field-grouping.ts @@ -0,0 +1,66 @@ +import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types'; + +export function createFieldGroupingCallback(options: { + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + setInvisibleOverlayVisible: (visible: boolean) => void; + getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; + setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; + sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean; +}): (data: KikuFieldGroupingRequestData) => Promise { + return async (data: KikuFieldGroupingRequestData): Promise => { + return new Promise((resolve) => { + if (options.getResolver()) { + resolve({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + return; + } + + const previousVisibleOverlay = options.getVisibleOverlayVisible(); + const previousInvisibleOverlay = options.getInvisibleOverlayVisible(); + let settled = false; + + const finish = (choice: KikuFieldGroupingChoice): void => { + if (settled) return; + settled = true; + if (options.getResolver() === finish) { + options.setResolver(null); + } + resolve(choice); + + if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) { + options.setVisibleOverlayVisible(false); + } + if (options.getInvisibleOverlayVisible() !== previousInvisibleOverlay) { + options.setInvisibleOverlayVisible(previousInvisibleOverlay); + } + }; + + options.setResolver(finish); + if (!options.sendRequestToVisibleOverlay(data)) { + finish({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + return; + } + setTimeout(() => { + if (!settled) { + finish({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + } + }, 90000); + }); + }; +} diff --git a/src/core/services/frequency-dictionary.test.ts b/src/core/services/frequency-dictionary.test.ts new file mode 100644 index 0000000..baca354 --- /dev/null +++ b/src/core/services/frequency-dictionary.test.ts @@ -0,0 +1,81 @@ +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 { createFrequencyDictionaryLookup } from './frequency-dictionary'; + +test('createFrequencyDictionaryLookup logs parse errors and returns no-op for invalid dictionaries', async () => { + const logs: string[] = []; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-')); + const bankPath = path.join(tempDir, 'term_meta_bank_1.json'); + fs.writeFileSync(bankPath, '{ invalid json'); + + const lookup = await createFrequencyDictionaryLookup({ + searchPaths: [tempDir], + log: (message) => { + logs.push(message); + }, + }); + + const rank = lookup('猫'); + + assert.equal(rank, null); + assert.equal( + logs.some( + (entry) => + entry.includes('Failed to parse frequency dictionary file as JSON') && + entry.includes('term_meta_bank_1.json'), + ), + true, + ); +}); + +test('createFrequencyDictionaryLookup continues with no-op lookup when search path is missing', async () => { + const logs: string[] = []; + const missingPath = path.join(os.tmpdir(), 'subminer-frequency-dict-missing-dir'); + const lookup = await createFrequencyDictionaryLookup({ + searchPaths: [missingPath], + log: (message) => { + logs.push(message); + }, + }); + + assert.equal(lookup('猫'), null); + assert.equal( + logs.some((entry) => entry.includes(`Frequency dictionary not found.`)), + true, + ); +}); + +test('createFrequencyDictionaryLookup aggregates duplicate-term logs into a single summary', async () => { + const logs: string[] = []; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-')); + const bankPath = path.join(tempDir, 'term_meta_bank_1.json'); + fs.writeFileSync( + bankPath, + JSON.stringify([ + ['猫', 1, { frequency: { displayValue: 100 } }], + ['猫', 2, { frequency: { displayValue: 120 } }], + ['猫', 3, { frequency: { displayValue: 110 } }], + ]), + ); + + const lookup = await createFrequencyDictionaryLookup({ + searchPaths: [tempDir], + log: (message) => { + logs.push(message); + }, + }); + + assert.equal(lookup('猫'), 100); + assert.equal( + logs.filter((entry) => entry.includes('Frequency dictionary ignored 2 duplicate term entries')).length, + 1, + ); + assert.equal( + logs.some((entry) => entry.includes('Frequency dictionary duplicate term')), + false, + ); +}); diff --git a/src/core/services/frequency-dictionary.ts b/src/core/services/frequency-dictionary.ts new file mode 100644 index 0000000..b8c84af --- /dev/null +++ b/src/core/services/frequency-dictionary.ts @@ -0,0 +1,195 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface FrequencyDictionaryLookupOptions { + searchPaths: string[]; + log: (message: string) => void; +} + +interface FrequencyDictionaryEntry { + rank: number; + term: string; +} + +const FREQUENCY_BANK_FILE_GLOB = /^term_meta_bank_.*\.json$/; +const NOOP_LOOKUP = (): null => null; + +function normalizeFrequencyTerm(value: string): string { + return value.trim().toLowerCase(); +} + +function extractFrequencyDisplayValue(meta: unknown): number | null { + if (!meta || typeof meta !== 'object') return null; + const frequency = (meta as { frequency?: unknown }).frequency; + if (!frequency || typeof frequency !== 'object') return null; + const displayValue = (frequency as { displayValue?: unknown }).displayValue; + if (typeof displayValue === 'number') { + if (!Number.isFinite(displayValue) || displayValue <= 0) return null; + return Math.floor(displayValue); + } + if (typeof displayValue === 'string') { + const normalized = displayValue.trim().replace(/,/g, ''); + const parsed = Number.parseInt(normalized, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return parsed; + } + + return null; +} + +function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry | null { + if (!Array.isArray(entry) || entry.length < 3) { + return null; + } + + const [term, _id, meta] = entry as [unknown, unknown, unknown]; + if (typeof term !== 'string') { + return null; + } + + const frequency = extractFrequencyDisplayValue(meta); + if (frequency === null) return null; + + const normalizedTerm = normalizeFrequencyTerm(term); + if (!normalizedTerm) return null; + + return { + term: normalizedTerm, + rank: frequency, + }; +} + +function addEntriesToMap( + rawEntries: unknown, + terms: Map, +): { duplicateCount: number } { + if (!Array.isArray(rawEntries)) { + return { duplicateCount: 0 }; + } + + let duplicateCount = 0; + for (const rawEntry of rawEntries) { + const entry = asFrequencyDictionaryEntry(rawEntry); + if (!entry) { + continue; + } + const currentRank = terms.get(entry.term); + if (currentRank === undefined || entry.rank < currentRank) { + terms.set(entry.term, entry.rank); + continue; + } + + duplicateCount += 1; + } + + return { duplicateCount }; +} + +function collectDictionaryFromPath( + dictionaryPath: string, + log: (message: string) => void, +): Map { + const terms = new Map(); + + let fileNames: string[]; + try { + fileNames = fs.readdirSync(dictionaryPath); + } catch (error) { + log(`Failed to read frequency dictionary directory ${dictionaryPath}: ${String(error)}`); + return terms; + } + + const bankFiles = fileNames.filter((name) => FREQUENCY_BANK_FILE_GLOB.test(name)).sort(); + + if (bankFiles.length === 0) { + return terms; + } + + for (const bankFile of bankFiles) { + const bankPath = path.join(dictionaryPath, bankFile); + let rawText: string; + try { + rawText = fs.readFileSync(bankPath, 'utf-8'); + } catch { + log(`Failed to read frequency dictionary file ${bankPath}`); + continue; + } + + let rawEntries: unknown; + try { + rawEntries = JSON.parse(rawText) as unknown; + } catch { + log(`Failed to parse frequency dictionary file as JSON: ${bankPath}`); + continue; + } + + const beforeSize = terms.size; + const { duplicateCount } = addEntriesToMap(rawEntries, terms); + if (duplicateCount > 0) { + log( + `Frequency dictionary ignored ${duplicateCount} duplicate term entr${ + duplicateCount === 1 ? 'y' : 'ies' + } in ${bankPath} (kept strongest rank per term).`, + ); + } + if (terms.size === beforeSize) { + log(`Frequency dictionary file contained no extractable entries: ${bankPath}`); + } + } + + return terms; +} + +export async function createFrequencyDictionaryLookup( + options: FrequencyDictionaryLookupOptions, +): Promise<(term: string) => number | null> { + const attemptedPaths: string[] = []; + let foundDictionaryPathCount = 0; + + for (const dictionaryPath of options.searchPaths) { + attemptedPaths.push(dictionaryPath); + let isDirectory = false; + + try { + if (!fs.existsSync(dictionaryPath)) { + continue; + } + isDirectory = fs.statSync(dictionaryPath).isDirectory(); + } catch (error) { + options.log( + `Failed to inspect frequency dictionary path ${dictionaryPath}: ${String(error)}`, + ); + continue; + } + + if (!isDirectory) { + continue; + } + + foundDictionaryPathCount += 1; + const terms = collectDictionaryFromPath(dictionaryPath, options.log); + if (terms.size > 0) { + options.log(`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`); + return (term: string): number | null => { + const normalized = normalizeFrequencyTerm(term); + if (!normalized) return null; + return terms.get(normalized) ?? null; + }; + } + + options.log( + `Frequency dictionary directory exists but contains no readable term_meta_bank_*.json files: ${dictionaryPath}`, + ); + } + + options.log( + `Frequency dictionary not found. Searched ${attemptedPaths.length} candidate path(s): ${attemptedPaths.join(', ')}`, + ); + if (foundDictionaryPathCount > 0) { + options.log( + 'Frequency dictionary directories found, but no usable term_meta_bank_*.json files were loaded.', + ); + } + + return NOOP_LOOKUP; +} diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts new file mode 100644 index 0000000..9e98128 --- /dev/null +++ b/src/core/services/immersion-tracker-service.test.ts @@ -0,0 +1,560 @@ +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 type { DatabaseSync as NodeDatabaseSync } from 'node:sqlite'; +import { toMonthKey } from './immersion-tracker/maintenance'; +import { enqueueWrite } from './immersion-tracker/queue'; +import { + deriveCanonicalTitle, + normalizeText, + resolveBoundedInt, +} from './immersion-tracker/reducer'; +import type { QueuedWrite } from './immersion-tracker/types'; + +type ImmersionTrackerService = import('./immersion-tracker-service').ImmersionTrackerService; +type ImmersionTrackerServiceCtor = + typeof import('./immersion-tracker-service').ImmersionTrackerService; + +type DatabaseSyncCtor = typeof NodeDatabaseSync; +const DatabaseSync: DatabaseSyncCtor | null = (() => { + try { + return (require('node:sqlite') as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null; + } catch { + return null; + } +})(); +const testIfSqlite = DatabaseSync ? test : test.skip; + +let trackerCtor: ImmersionTrackerServiceCtor | null = null; + +async function loadTrackerCtor(): Promise { + if (trackerCtor) return trackerCtor; + const mod = await import('./immersion-tracker-service'); + trackerCtor = mod.ImmersionTrackerService; + return trackerCtor; +} + +function makeDbPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-immersion-test-')); + return path.join(dir, 'immersion.sqlite'); +} + +function cleanupDbPath(dbPath: string): void { + const dir = path.dirname(dbPath); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('seam: resolveBoundedInt keeps fallback for invalid values', () => { + assert.equal(resolveBoundedInt(undefined, 25, 1, 100), 25); + assert.equal(resolveBoundedInt(0, 25, 1, 100), 25); + assert.equal(resolveBoundedInt(101, 25, 1, 100), 25); + assert.equal(resolveBoundedInt(44.8, 25, 1, 100), 44); +}); + +test('seam: reducer title normalization covers local and remote paths', () => { + assert.equal(normalizeText(' hello\n world '), 'hello world'); + assert.equal(deriveCanonicalTitle('/tmp/Episode 01.mkv'), 'Episode 01'); + assert.equal( + deriveCanonicalTitle('https://cdn.example.com/show/%E7%AC%AC1%E8%A9%B1.mp4'), + '\u7b2c1\u8a71', + ); +}); + +test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () => { + const queue: QueuedWrite[] = [ + { kind: 'event', sessionId: 1, eventType: 1, sampleMs: 1000 }, + { kind: 'event', sessionId: 1, eventType: 2, sampleMs: 1001 }, + ]; + const incoming: QueuedWrite = { kind: 'event', sessionId: 1, eventType: 3, sampleMs: 1002 }; + + const result = enqueueWrite(queue, incoming, 2); + assert.equal(result.dropped, 1); + assert.equal(queue.length, 2); + assert.equal(queue[0]!.eventType, 2); + assert.equal(queue[1]!.eventType, 3); +}); + +test('seam: toMonthKey uses UTC calendar month', () => { + assert.equal(toMonthKey(Date.UTC(2026, 0, 31, 23, 59, 59, 999)), 202601); + assert.equal(toMonthKey(Date.UTC(2026, 1, 1, 0, 0, 0, 0)), 202602); +}); + +testIfSqlite('startSession generates UUID-like session identifiers', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + tracker.handleMediaChange('/tmp/episode.mkv', 'Episode'); + + const privateApi = tracker as unknown as { + flushTelemetry: (force?: boolean) => void; + flushNow: () => void; + }; + privateApi.flushTelemetry(true); + privateApi.flushNow(); + + const db = new DatabaseSync!(dbPath); + const row = db.prepare('SELECT session_uuid FROM imm_sessions LIMIT 1').get() as { + session_uuid: string; + } | null; + db.close(); + + assert.equal(typeof row?.session_uuid, 'string'); + assert.equal(row?.session_uuid?.startsWith('session-'), false); + assert.ok(/^[0-9a-fA-F-]{36}$/.test(row?.session_uuid || '')); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +testIfSqlite('destroy finalizes active session and persists final telemetry', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('/tmp/episode-2.mkv', 'Episode 2'); + tracker.recordSubtitleLine('Hello immersion', 0, 1); + tracker.destroy(); + + const db = new DatabaseSync!(dbPath); + const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as { + ended_at_ms: number | null; + } | null; + const telemetryCountRow = db + .prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry') + .get() as { total: number }; + db.close(); + + assert.ok(sessionRow); + assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0); + assert.ok(Number(telemetryCountRow.total) >= 2); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +testIfSqlite('persists and retrieves minimum immersion tracking fields', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('/tmp/episode-3.mkv', 'Episode 3'); + tracker.recordSubtitleLine('alpha beta', 0, 1.2); + tracker.recordCardsMined(2); + tracker.recordLookup(true); + tracker.recordPlaybackPosition(12.5); + + const privateApi = tracker as unknown as { + flushTelemetry: (force?: boolean) => void; + flushNow: () => void; + }; + privateApi.flushTelemetry(true); + privateApi.flushNow(); + + const summaries = await tracker.getSessionSummaries(10); + assert.ok(summaries.length >= 1); + assert.ok(summaries[0]!.linesSeen >= 1); + assert.ok(summaries[0]!.cardsMined >= 2); + + tracker.destroy(); + + const db = new DatabaseSync!(dbPath); + const videoRow = db + .prepare('SELECT canonical_title, source_path, duration_ms FROM imm_videos LIMIT 1') + .get() as { + canonical_title: string; + source_path: string | null; + duration_ms: number; + } | null; + const telemetryRow = db + .prepare( + `SELECT lines_seen, words_seen, tokens_seen, cards_mined + FROM imm_session_telemetry + ORDER BY sample_ms DESC + LIMIT 1`, + ) + .get() as { + lines_seen: number; + words_seen: number; + tokens_seen: number; + cards_mined: number; + } | null; + db.close(); + + assert.ok(videoRow); + assert.equal(videoRow?.canonical_title, 'Episode 3'); + assert.equal(videoRow?.source_path, '/tmp/episode-3.mkv'); + assert.ok(Number(videoRow?.duration_ms ?? -1) >= 0); + + assert.ok(telemetryRow); + assert.ok(Number(telemetryRow?.lines_seen ?? 0) >= 1); + assert.ok(Number(telemetryRow?.words_seen ?? 0) >= 2); + assert.ok(Number(telemetryRow?.tokens_seen ?? 0) >= 2); + assert.ok(Number(telemetryRow?.cards_mined ?? 0) >= 2); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +testIfSqlite('applies configurable queue, flush, and retention policy', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ + dbPath, + policy: { + batchSize: 10, + flushIntervalMs: 250, + queueCap: 1500, + payloadCapBytes: 512, + maintenanceIntervalMs: 2 * 60 * 60 * 1000, + retention: { + eventsDays: 14, + telemetryDays: 45, + dailyRollupsDays: 730, + monthlyRollupsDays: 3650, + vacuumIntervalDays: 14, + }, + }, + }); + + const privateApi = tracker as unknown as { + batchSize: number; + flushIntervalMs: number; + queueCap: number; + maxPayloadBytes: number; + maintenanceIntervalMs: number; + eventsRetentionMs: number; + telemetryRetentionMs: number; + dailyRollupRetentionMs: number; + monthlyRollupRetentionMs: number; + vacuumIntervalMs: number; + }; + + assert.equal(privateApi.batchSize, 10); + assert.equal(privateApi.flushIntervalMs, 250); + assert.equal(privateApi.queueCap, 1500); + assert.equal(privateApi.maxPayloadBytes, 512); + assert.equal(privateApi.maintenanceIntervalMs, 7_200_000); + assert.equal(privateApi.eventsRetentionMs, 14 * 86_400_000); + assert.equal(privateApi.telemetryRetentionMs, 45 * 86_400_000); + assert.equal(privateApi.dailyRollupRetentionMs, 730 * 86_400_000); + assert.equal(privateApi.monthlyRollupRetentionMs, 3650 * 86_400_000); + assert.equal(privateApi.vacuumIntervalMs, 14 * 86_400_000); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +testIfSqlite('monthly rollups are grouped by calendar month', 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: NodeDatabaseSync; + runRollupMaintenance: () => void; + }; + + const januaryStartedAtMs = Date.UTC(2026, 0, 31, 23, 59, 59, 0); + const februaryStartedAtMs = Date.UTC(2026, 1, 1, 0, 0, 1, 0); + + privateApi.db.exec(` + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + source_type, + duration_ms, + created_at_ms, + updated_at_ms + ) VALUES ( + 1, + 'local:/tmp/video.mkv', + 'Episode', + 1, + 0, + ${januaryStartedAtMs}, + ${januaryStartedAtMs} + ) + `); + + privateApi.db.exec(` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + created_at_ms, + updated_at_ms, + ended_at_ms + ) VALUES ( + 1, + '11111111-1111-1111-1111-111111111111', + 1, + ${januaryStartedAtMs}, + 2, + ${januaryStartedAtMs}, + ${januaryStartedAtMs}, + ${januaryStartedAtMs + 5000} + ) + `); + privateApi.db.exec(` + INSERT INTO imm_session_telemetry ( + session_id, + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + words_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + pause_count, + pause_ms, + seek_forward_count, + seek_backward_count, + media_buffer_events + ) VALUES ( + 1, + ${januaryStartedAtMs + 1000}, + 5000, + 5000, + 1, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ) + `); + + privateApi.db.exec(` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + created_at_ms, + updated_at_ms, + ended_at_ms + ) VALUES ( + 2, + '22222222-2222-2222-2222-222222222222', + 1, + ${februaryStartedAtMs}, + 2, + ${februaryStartedAtMs}, + ${februaryStartedAtMs}, + ${februaryStartedAtMs + 5000} + ) + `); + privateApi.db.exec(` + INSERT INTO imm_session_telemetry ( + session_id, + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + words_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + pause_count, + pause_ms, + seek_forward_count, + seek_backward_count, + media_buffer_events + ) VALUES ( + 2, + ${februaryStartedAtMs + 1000}, + 4000, + 4000, + 2, + 3, + 3, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0 + ) + `); + + privateApi.runRollupMaintenance(); + + const rows = await tracker.getMonthlyRollups(10); + const videoRows = rows.filter((row) => row.videoId === 1); + + assert.equal(videoRows.length, 2); + assert.equal(videoRows[0]!.rollupDayOrMonth, 202602); + assert.equal(videoRows[1]!.rollupDayOrMonth, 202601); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +testIfSqlite('flushSingle reuses cached prepared statements', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let originalPrepare: NodeDatabaseSync['prepare'] | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const privateApi = tracker as unknown as { + db: NodeDatabaseSync; + flushSingle: (write: { + kind: 'telemetry' | 'event'; + sessionId: number; + sampleMs: number; + eventType?: number; + lineIndex?: number | null; + segmentStartMs?: number | null; + segmentEndMs?: number | null; + wordsDelta?: number; + cardsDelta?: number; + payloadJson?: string | null; + totalWatchedMs?: number; + activeWatchedMs?: number; + linesSeen?: number; + wordsSeen?: number; + tokensSeen?: number; + cardsMined?: number; + lookupCount?: number; + lookupHits?: number; + pauseCount?: number; + pauseMs?: number; + seekForwardCount?: number; + seekBackwardCount?: number; + mediaBufferEvents?: number; + }) => void; + }; + + originalPrepare = privateApi.db.prepare; + let prepareCalls = 0; + privateApi.db.prepare = (...args: Parameters) => { + prepareCalls += 1; + return originalPrepare!.apply(privateApi.db, args); + }; + const preparedRestore = originalPrepare; + + privateApi.db.exec(` + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + source_type, + duration_ms, + created_at_ms, + updated_at_ms + ) VALUES ( + 1, + 'local:/tmp/prepared.mkv', + 'Prepared', + 1, + 0, + 1000, + 1000 + ) + `); + + privateApi.db.exec(` + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + status, + created_at_ms, + updated_at_ms, + ended_at_ms + ) VALUES ( + 1, + '33333333-3333-3333-3333-333333333333', + 1, + 1000, + 2, + 1000, + 1000, + 2000 + ) + `); + + privateApi.flushSingle({ + kind: 'telemetry', + sessionId: 1, + sampleMs: 1500, + totalWatchedMs: 1000, + activeWatchedMs: 1000, + linesSeen: 1, + wordsSeen: 2, + tokensSeen: 2, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + pauseCount: 0, + pauseMs: 0, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + }); + + privateApi.flushSingle({ + kind: 'event', + sessionId: 1, + sampleMs: 1600, + eventType: 1, + lineIndex: 1, + segmentStartMs: 0, + segmentEndMs: 1000, + wordsDelta: 2, + cardsDelta: 0, + payloadJson: '{"event":"subtitle-line"}', + }); + + privateApi.db.prepare = preparedRestore; + + assert.equal(prepareCalls, 0); + } finally { + if (tracker && originalPrepare) { + const privateApi = tracker as unknown as { db: NodeDatabaseSync }; + privateApi.db.prepare = originalPrepare; + } + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts new file mode 100644 index 0000000..a61124f --- /dev/null +++ b/src/core/services/immersion-tracker-service.ts @@ -0,0 +1,654 @@ +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import * as fs from 'node:fs'; +import { createLogger } from '../../logger'; +import { getLocalVideoMetadata } from './immersion-tracker/metadata'; +import { pruneRetention, runRollupMaintenance } from './immersion-tracker/maintenance'; +import { finalizeSessionRecord, startSessionRecord } from './immersion-tracker/session'; +import { + applyPragmas, + createTrackerPreparedStatements, + ensureSchema, + executeQueuedWrite, + getOrCreateVideoRecord, + type TrackerPreparedStatements, + updateVideoMetadataRecord, + updateVideoTitleRecord, +} from './immersion-tracker/storage'; +import { + getDailyRollups, + getMonthlyRollups, + getQueryHints, + getSessionSummaries, + getSessionTimeline, +} from './immersion-tracker/query'; +import { + buildVideoKey, + calculateTextMetrics, + deriveCanonicalTitle, + isRemoteSource, + normalizeMediaPath, + normalizeText, + resolveBoundedInt, + sanitizePayload, + secToMs, +} from './immersion-tracker/reducer'; +import { enqueueWrite } from './immersion-tracker/queue'; +import { + DEFAULT_BATCH_SIZE, + DEFAULT_DAILY_ROLLUP_RETENTION_MS, + DEFAULT_EVENTS_RETENTION_MS, + DEFAULT_FLUSH_INTERVAL_MS, + DEFAULT_MAINTENANCE_INTERVAL_MS, + DEFAULT_MAX_PAYLOAD_BYTES, + DEFAULT_MONTHLY_ROLLUP_RETENTION_MS, + DEFAULT_QUEUE_CAP, + DEFAULT_TELEMETRY_RETENTION_MS, + DEFAULT_VACUUM_INTERVAL_MS, + EVENT_CARD_MINED, + EVENT_LOOKUP, + EVENT_MEDIA_BUFFER, + EVENT_PAUSE_END, + EVENT_PAUSE_START, + EVENT_SEEK_BACKWARD, + EVENT_SEEK_FORWARD, + EVENT_SUBTITLE_LINE, + SOURCE_TYPE_LOCAL, + SOURCE_TYPE_REMOTE, + type ImmersionSessionRollupRow, + type ImmersionTrackerOptions, + type QueuedWrite, + type SessionState, + type SessionSummaryQueryRow, + type SessionTimelineRow, +} from './immersion-tracker/types'; + +export type { + ImmersionSessionRollupRow, + ImmersionTrackerOptions, + ImmersionTrackerPolicy, + SessionSummaryQueryRow, + SessionTimelineRow, +} from './immersion-tracker/types'; + +export class ImmersionTrackerService { + private readonly logger = createLogger('main:immersion-tracker'); + private readonly db: DatabaseSync; + private readonly queue: QueuedWrite[] = []; + private readonly queueCap: number; + private readonly batchSize: number; + private readonly flushIntervalMs: number; + private readonly maintenanceIntervalMs: number; + private readonly maxPayloadBytes: number; + private readonly eventsRetentionMs: number; + private readonly telemetryRetentionMs: number; + private readonly dailyRollupRetentionMs: number; + private readonly monthlyRollupRetentionMs: number; + private readonly vacuumIntervalMs: number; + private readonly dbPath: string; + private readonly writeLock = { locked: false }; + private flushTimer: ReturnType | null = null; + private maintenanceTimer: ReturnType | null = null; + private flushScheduled = false; + private droppedWriteCount = 0; + private lastVacuumMs = 0; + private isDestroyed = false; + private sessionState: SessionState | null = null; + private currentVideoKey = ''; + private currentMediaPathOrUrl = ''; + private readonly preparedStatements: TrackerPreparedStatements; + + constructor(options: ImmersionTrackerOptions) { + this.dbPath = options.dbPath; + const parentDir = path.dirname(this.dbPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + const policy = options.policy ?? {}; + this.queueCap = resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000); + this.batchSize = resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000); + this.flushIntervalMs = resolveBoundedInt( + policy.flushIntervalMs, + DEFAULT_FLUSH_INTERVAL_MS, + 50, + 60_000, + ); + this.maintenanceIntervalMs = resolveBoundedInt( + policy.maintenanceIntervalMs, + DEFAULT_MAINTENANCE_INTERVAL_MS, + 60_000, + 7 * 24 * 60 * 60 * 1000, + ); + this.maxPayloadBytes = resolveBoundedInt( + policy.payloadCapBytes, + DEFAULT_MAX_PAYLOAD_BYTES, + 64, + 8192, + ); + + const retention = policy.retention ?? {}; + this.eventsRetentionMs = + resolveBoundedInt( + retention.eventsDays, + Math.floor(DEFAULT_EVENTS_RETENTION_MS / 86_400_000), + 1, + 3650, + ) * 86_400_000; + this.telemetryRetentionMs = + resolveBoundedInt( + retention.telemetryDays, + Math.floor(DEFAULT_TELEMETRY_RETENTION_MS / 86_400_000), + 1, + 3650, + ) * 86_400_000; + this.dailyRollupRetentionMs = + resolveBoundedInt( + retention.dailyRollupsDays, + Math.floor(DEFAULT_DAILY_ROLLUP_RETENTION_MS / 86_400_000), + 1, + 36500, + ) * 86_400_000; + this.monthlyRollupRetentionMs = + resolveBoundedInt( + retention.monthlyRollupsDays, + Math.floor(DEFAULT_MONTHLY_ROLLUP_RETENTION_MS / 86_400_000), + 1, + 36500, + ) * 86_400_000; + this.vacuumIntervalMs = + resolveBoundedInt( + retention.vacuumIntervalDays, + Math.floor(DEFAULT_VACUUM_INTERVAL_MS / 86_400_000), + 1, + 3650, + ) * 86_400_000; + this.db = new DatabaseSync(this.dbPath); + applyPragmas(this.db); + ensureSchema(this.db); + this.preparedStatements = createTrackerPreparedStatements(this.db); + this.scheduleMaintenance(); + this.scheduleFlush(); + } + + destroy(): void { + if (this.isDestroyed) return; + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.maintenanceTimer) { + clearInterval(this.maintenanceTimer); + this.maintenanceTimer = null; + } + this.finalizeActiveSession(); + this.isDestroyed = true; + this.db.close(); + } + + async getSessionSummaries(limit = 50): Promise { + return getSessionSummaries(this.db, limit); + } + + async getSessionTimeline(sessionId: number, limit = 200): Promise { + return getSessionTimeline(this.db, sessionId, limit); + } + + async getQueryHints(): Promise<{ + totalSessions: number; + activeSessions: number; + }> { + return getQueryHints(this.db); + } + + async getDailyRollups(limit = 60): Promise { + return getDailyRollups(this.db, limit); + } + + async getMonthlyRollups(limit = 24): Promise { + return getMonthlyRollups(this.db, limit); + } + + handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { + const normalizedPath = normalizeMediaPath(mediaPath); + const normalizedTitle = normalizeText(mediaTitle); + this.logger.info( + `handleMediaChange called with path=${normalizedPath || ''} title=${normalizedTitle || ''}`, + ); + if (normalizedPath === this.currentMediaPathOrUrl) { + if (normalizedTitle && normalizedTitle !== this.currentVideoKey) { + this.currentVideoKey = normalizedTitle; + this.updateVideoTitleForActiveSession(normalizedTitle); + this.logger.debug('Media title updated for existing session'); + } else { + this.logger.debug('Media change ignored; path unchanged'); + } + return; + } + this.finalizeActiveSession(); + this.currentMediaPathOrUrl = normalizedPath; + this.currentVideoKey = normalizedTitle; + if (!normalizedPath) { + this.logger.info('Media path cleared; immersion session tracking paused'); + return; + } + + const sourceType = isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL; + const videoKey = buildVideoKey(normalizedPath, sourceType); + const canonicalTitle = normalizedTitle || deriveCanonicalTitle(normalizedPath); + const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null; + const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? normalizedPath : null; + + const sessionInfo = { + videoId: getOrCreateVideoRecord(this.db, videoKey, { + canonicalTitle, + sourcePath, + sourceUrl, + sourceType, + }), + startedAtMs: Date.now(), + }; + + this.logger.info( + `Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`, + ); + this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs); + this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); + } + + handleMediaTitleUpdate(mediaTitle: string | null): void { + if (!this.sessionState) return; + const normalizedTitle = normalizeText(mediaTitle); + if (!normalizedTitle) return; + this.currentVideoKey = normalizedTitle; + this.updateVideoTitleForActiveSession(normalizedTitle); + } + + recordSubtitleLine(text: string, startSec: number, endSec: number): void { + if (!this.sessionState || !text.trim()) return; + const cleaned = normalizeText(text); + if (!cleaned) return; + + const metrics = calculateTextMetrics(cleaned); + this.sessionState.currentLineIndex += 1; + this.sessionState.linesSeen += 1; + this.sessionState.wordsSeen += metrics.words; + this.sessionState.tokensSeen += metrics.tokens; + this.sessionState.pendingTelemetry = true; + + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + lineIndex: this.sessionState.currentLineIndex, + segmentStartMs: secToMs(startSec), + segmentEndMs: secToMs(endSec), + wordsDelta: metrics.words, + cardsDelta: 0, + eventType: EVENT_SUBTITLE_LINE, + payloadJson: sanitizePayload( + { + event: 'subtitle-line', + text: cleaned, + words: metrics.words, + }, + this.maxPayloadBytes, + ), + }); + } + + recordPlaybackPosition(mediaTimeSec: number | null): void { + if (!this.sessionState || mediaTimeSec === null || !Number.isFinite(mediaTimeSec)) { + return; + } + const nowMs = Date.now(); + const mediaMs = Math.round(mediaTimeSec * 1000); + if (this.sessionState.lastWallClockMs <= 0) { + this.sessionState.lastWallClockMs = nowMs; + this.sessionState.lastMediaMs = mediaMs; + return; + } + + const wallDeltaMs = nowMs - this.sessionState.lastWallClockMs; + if (wallDeltaMs > 0 && wallDeltaMs < 60_000) { + this.sessionState.totalWatchedMs += wallDeltaMs; + if (!this.sessionState.isPaused) { + this.sessionState.activeWatchedMs += wallDeltaMs; + } + } + + if (this.sessionState.lastMediaMs !== null) { + const mediaDeltaMs = mediaMs - this.sessionState.lastMediaMs; + if (Math.abs(mediaDeltaMs) >= 1_000) { + if (mediaDeltaMs > 0) { + this.sessionState.seekForwardCount += 1; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_SEEK_FORWARD, + wordsDelta: 0, + cardsDelta: 0, + segmentStartMs: this.sessionState.lastMediaMs, + segmentEndMs: mediaMs, + payloadJson: sanitizePayload( + { + fromMs: this.sessionState.lastMediaMs, + toMs: mediaMs, + }, + this.maxPayloadBytes, + ), + }); + } else if (mediaDeltaMs < 0) { + this.sessionState.seekBackwardCount += 1; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_SEEK_BACKWARD, + wordsDelta: 0, + cardsDelta: 0, + segmentStartMs: this.sessionState.lastMediaMs, + segmentEndMs: mediaMs, + payloadJson: sanitizePayload( + { + fromMs: this.sessionState.lastMediaMs, + toMs: mediaMs, + }, + this.maxPayloadBytes, + ), + }); + } + } + } + + this.sessionState.lastWallClockMs = nowMs; + this.sessionState.lastMediaMs = mediaMs; + this.sessionState.pendingTelemetry = true; + } + + recordPauseState(isPaused: boolean): void { + if (!this.sessionState) return; + if (this.sessionState.isPaused === isPaused) return; + + const nowMs = Date.now(); + this.sessionState.isPaused = isPaused; + if (isPaused) { + this.sessionState.lastPauseStartMs = nowMs; + this.sessionState.pauseCount += 1; + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_PAUSE_START, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: sanitizePayload({ paused: true }, this.maxPayloadBytes), + }); + } else { + if (this.sessionState.lastPauseStartMs) { + const pauseMs = Math.max(0, nowMs - this.sessionState.lastPauseStartMs); + this.sessionState.pauseMs += pauseMs; + this.sessionState.lastPauseStartMs = null; + } + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: nowMs, + eventType: EVENT_PAUSE_END, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: sanitizePayload({ paused: false }, this.maxPayloadBytes), + }); + } + + this.sessionState.pendingTelemetry = true; + } + + recordLookup(hit: boolean): void { + if (!this.sessionState) return; + this.sessionState.lookupCount += 1; + if (hit) { + this.sessionState.lookupHits += 1; + } + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + eventType: EVENT_LOOKUP, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: sanitizePayload( + { + hit, + }, + this.maxPayloadBytes, + ), + }); + } + + recordCardsMined(count = 1): void { + if (!this.sessionState) return; + this.sessionState.cardsMined += count; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + eventType: EVENT_CARD_MINED, + wordsDelta: 0, + cardsDelta: count, + payloadJson: sanitizePayload({ cardsMined: count }, this.maxPayloadBytes), + }); + } + + recordMediaBufferEvent(): void { + if (!this.sessionState) return; + this.sessionState.mediaBufferEvents += 1; + this.sessionState.pendingTelemetry = true; + this.recordWrite({ + kind: 'event', + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + eventType: EVENT_MEDIA_BUFFER, + cardsDelta: 0, + wordsDelta: 0, + payloadJson: sanitizePayload( + { + buffer: true, + }, + this.maxPayloadBytes, + ), + }); + } + + private recordWrite(write: QueuedWrite): void { + if (this.isDestroyed) return; + const { dropped } = enqueueWrite(this.queue, write, this.queueCap); + if (dropped > 0) { + this.droppedWriteCount += dropped; + this.logger.warn(`Immersion tracker queue overflow; dropped ${dropped} oldest writes`); + } + if (write.kind === 'event' || this.queue.length >= this.batchSize) { + this.scheduleFlush(0); + } + } + + private flushTelemetry(force = false): void { + if (!this.sessionState || (!force && !this.sessionState.pendingTelemetry)) { + return; + } + this.recordWrite({ + kind: 'telemetry', + sessionId: this.sessionState.sessionId, + sampleMs: Date.now(), + totalWatchedMs: this.sessionState.totalWatchedMs, + activeWatchedMs: this.sessionState.activeWatchedMs, + linesSeen: this.sessionState.linesSeen, + wordsSeen: this.sessionState.wordsSeen, + tokensSeen: this.sessionState.tokensSeen, + cardsMined: this.sessionState.cardsMined, + lookupCount: this.sessionState.lookupCount, + lookupHits: this.sessionState.lookupHits, + pauseCount: this.sessionState.pauseCount, + pauseMs: this.sessionState.pauseMs, + seekForwardCount: this.sessionState.seekForwardCount, + seekBackwardCount: this.sessionState.seekBackwardCount, + mediaBufferEvents: this.sessionState.mediaBufferEvents, + }); + this.sessionState.pendingTelemetry = false; + } + + private scheduleFlush(delayMs = this.flushIntervalMs): void { + if (this.flushScheduled || this.writeLock.locked) return; + this.flushScheduled = true; + this.flushTimer = setTimeout(() => { + this.flushScheduled = false; + this.flushNow(); + }, delayMs); + } + + private flushNow(): void { + if (this.writeLock.locked || this.isDestroyed) return; + if (this.queue.length === 0) { + this.flushScheduled = false; + return; + } + + this.flushTelemetry(); + if (this.queue.length === 0) { + this.flushScheduled = false; + return; + } + + const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length)); + this.writeLock.locked = true; + try { + this.db.exec('BEGIN IMMEDIATE'); + for (const write of batch) { + this.flushSingle(write); + } + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + this.queue.unshift(...batch); + this.logger.warn('Immersion tracker flush failed, retrying later', error as Error); + } finally { + this.writeLock.locked = false; + this.flushScheduled = false; + if (this.queue.length > 0) { + this.scheduleFlush(this.flushIntervalMs); + } + } + } + + private flushSingle(write: QueuedWrite): void { + executeQueuedWrite(write, this.preparedStatements); + } + + private scheduleMaintenance(): void { + this.maintenanceTimer = setInterval(() => { + this.runMaintenance(); + }, this.maintenanceIntervalMs); + this.runMaintenance(); + } + + private runMaintenance(): void { + if (this.isDestroyed) return; + try { + this.flushTelemetry(true); + this.flushNow(); + const nowMs = Date.now(); + pruneRetention(this.db, nowMs, { + eventsRetentionMs: this.eventsRetentionMs, + telemetryRetentionMs: this.telemetryRetentionMs, + dailyRollupRetentionMs: this.dailyRollupRetentionMs, + monthlyRollupRetentionMs: this.monthlyRollupRetentionMs, + }); + this.runRollupMaintenance(); + + if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) { + this.db.exec('VACUUM'); + this.lastVacuumMs = nowMs; + } + } catch (error) { + this.logger.warn( + 'Immersion tracker maintenance failed, will retry later', + (error as Error).message, + ); + } + } + + private runRollupMaintenance(): void { + runRollupMaintenance(this.db); + } + + private startSession(videoId: number, startedAtMs?: number): void { + const { sessionId, state } = startSessionRecord(this.db, videoId, startedAtMs); + this.sessionState = state; + this.recordWrite({ + kind: 'telemetry', + sessionId, + sampleMs: state.startedAtMs, + totalWatchedMs: 0, + activeWatchedMs: 0, + linesSeen: 0, + wordsSeen: 0, + tokensSeen: 0, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + pauseCount: 0, + pauseMs: 0, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + }); + this.scheduleFlush(0); + } + + private finalizeActiveSession(): void { + if (!this.sessionState) return; + const endedAt = Date.now(); + if (this.sessionState.lastPauseStartMs) { + this.sessionState.pauseMs += Math.max(0, endedAt - this.sessionState.lastPauseStartMs); + this.sessionState.lastPauseStartMs = null; + } + const finalWallNow = endedAt; + if (this.sessionState.lastWallClockMs > 0) { + const wallDelta = finalWallNow - this.sessionState.lastWallClockMs; + if (wallDelta > 0 && wallDelta < 60_000) { + this.sessionState.totalWatchedMs += wallDelta; + if (!this.sessionState.isPaused) { + this.sessionState.activeWatchedMs += wallDelta; + } + } + } + this.flushTelemetry(true); + this.flushNow(); + this.sessionState.pendingTelemetry = false; + + finalizeSessionRecord(this.db, this.sessionState, endedAt); + this.sessionState = null; + } + + private captureVideoMetadataAsync(videoId: number, sourceType: number, mediaPath: string): void { + if (sourceType !== SOURCE_TYPE_LOCAL) return; + void (async () => { + try { + const metadata = await getLocalVideoMetadata(mediaPath); + updateVideoMetadataRecord(this.db, videoId, metadata); + } catch (error) { + this.logger.warn('Unable to capture local video metadata', (error as Error).message); + } + })(); + } + + private updateVideoTitleForActiveSession(canonicalTitle: string): void { + if (!this.sessionState) return; + updateVideoTitleRecord(this.db, this.sessionState.videoId, canonicalTitle); + } +} diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts new file mode 100644 index 0000000..406e7ca --- /dev/null +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -0,0 +1,90 @@ +import type { DatabaseSync } from 'node:sqlite'; + +export function toMonthKey(timestampMs: number): number { + const monthDate = new Date(timestampMs); + return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1; +} + +export function pruneRetention( + db: DatabaseSync, + nowMs: number, + policy: { + eventsRetentionMs: number; + telemetryRetentionMs: number; + dailyRollupRetentionMs: number; + monthlyRollupRetentionMs: number; + }, +): void { + const eventCutoff = nowMs - policy.eventsRetentionMs; + const telemetryCutoff = nowMs - policy.telemetryRetentionMs; + const dailyCutoff = nowMs - policy.dailyRollupRetentionMs; + const monthlyCutoff = nowMs - policy.monthlyRollupRetentionMs; + const dayCutoff = Math.floor(dailyCutoff / 86_400_000); + const monthCutoff = toMonthKey(monthlyCutoff); + + db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff); + db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff); + db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff); + db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff); + db.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`).run( + telemetryCutoff, + ); +} + +export function runRollupMaintenance(db: DatabaseSync): void { + db.exec(` + INSERT OR REPLACE INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_words_seen, total_tokens_seen, total_cards, cards_per_hour, + words_per_min, lookup_hit_rate + ) + SELECT + CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day, + s.video_id AS video_id, + COUNT(DISTINCT s.session_id) AS total_sessions, + COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, + COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, + COALESCE(SUM(t.words_seen), 0) AS total_words_seen, + COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, + COALESCE(SUM(t.cards_mined), 0) AS total_cards, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS cards_per_hour, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS words_per_min, + CASE + WHEN COALESCE(SUM(t.lookup_count), 0) > 0 + THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL) + ELSE NULL + END AS lookup_hit_rate + FROM imm_sessions s + JOIN imm_session_telemetry t + ON t.session_id = s.session_id + GROUP BY rollup_day, s.video_id + `); + + db.exec(` + INSERT OR REPLACE INTO imm_monthly_rollups ( + rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, + total_words_seen, total_tokens_seen, total_cards + ) + SELECT + CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month, + s.video_id AS video_id, + COUNT(DISTINCT s.session_id) AS total_sessions, + COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min, + COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, + COALESCE(SUM(t.words_seen), 0) AS total_words_seen, + COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, + COALESCE(SUM(t.cards_mined), 0) AS total_cards + FROM imm_sessions s + JOIN imm_session_telemetry t + ON t.session_id = s.session_id + GROUP BY rollup_month, s.video_id + `); +} diff --git a/src/core/services/immersion-tracker/metadata.test.ts b/src/core/services/immersion-tracker/metadata.test.ts new file mode 100644 index 0000000..b9da9d4 --- /dev/null +++ b/src/core/services/immersion-tracker/metadata.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; +import type { spawn as spawnFn } from 'node:child_process'; +import { SOURCE_TYPE_LOCAL } from './types'; +import { getLocalVideoMetadata, runFfprobe } from './metadata'; + +type Spawn = typeof spawnFn; + +function createSpawnStub(options: { + stdout?: string; + stderr?: string; + emitError?: boolean; +}): Spawn { + return (() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + + queueMicrotask(() => { + if (options.emitError) { + child.emit('error', new Error('ffprobe failed')); + return; + } + if (options.stderr) { + child.stderr.emit('data', Buffer.from(options.stderr)); + } + if (options.stdout !== undefined) { + child.stdout.emit('data', Buffer.from(options.stdout)); + } + child.emit('close', 0); + }); + + return child as unknown as ReturnType; + }) as Spawn; +} + +test('runFfprobe parses valid JSON from stream and format sections', async () => { + const metadata = await runFfprobe('/tmp/video.mp4', { + spawn: createSpawnStub({ + stdout: JSON.stringify({ + format: { duration: '12.34', bit_rate: '3456000' }, + streams: [ + { + codec_type: 'video', + codec_tag_string: 'avc1', + width: 1920, + height: 1080, + avg_frame_rate: '24000/1001', + }, + { + codec_type: 'audio', + codec_tag_string: 'mp4a', + }, + ], + }), + }), + }); + + assert.equal(metadata.durationMs, 12340); + assert.equal(metadata.bitrateKbps, 3456); + assert.equal(metadata.widthPx, 1920); + assert.equal(metadata.heightPx, 1080); + assert.equal(metadata.fpsX100, 2398); + assert.equal(metadata.containerId, 0); + assert.ok(Number(metadata.codecId) > 0); + assert.ok(Number(metadata.audioCodecId) > 0); +}); + +test('runFfprobe returns empty metadata for invalid JSON and process errors', async () => { + const invalidJsonMetadata = await runFfprobe('/tmp/broken.mp4', { + spawn: createSpawnStub({ stdout: '{invalid' }), + }); + assert.deepEqual(invalidJsonMetadata, { + durationMs: null, + codecId: null, + containerId: null, + widthPx: null, + heightPx: null, + fpsX100: null, + bitrateKbps: null, + audioCodecId: null, + }); + + const errorMetadata = await runFfprobe('/tmp/error.mp4', { + spawn: createSpawnStub({ emitError: true }), + }); + assert.deepEqual(errorMetadata, { + durationMs: null, + codecId: null, + containerId: null, + widthPx: null, + heightPx: null, + fpsX100: null, + bitrateKbps: null, + audioCodecId: null, + }); +}); + +test('getLocalVideoMetadata derives title and falls back to null hash on read errors', async () => { + const successMetadata = await getLocalVideoMetadata('/tmp/Episode 01.mkv', { + spawn: createSpawnStub({ stdout: JSON.stringify({ format: { duration: '0' }, streams: [] }) }), + fs: { + createReadStream: () => { + const stream = new EventEmitter(); + queueMicrotask(() => { + stream.emit('data', Buffer.from('hello world')); + stream.emit('end'); + }); + return stream as unknown as ReturnType; + }, + promises: { + stat: (async () => ({ size: 1234 }) as unknown) as typeof import('node:fs').promises.stat, + }, + } as never, + }); + + assert.equal(successMetadata.sourceType, SOURCE_TYPE_LOCAL); + assert.equal(successMetadata.canonicalTitle, 'Episode 01'); + assert.equal(successMetadata.fileSizeBytes, 1234); + assert.equal( + successMetadata.hashSha256, + createHash('sha256').update('hello world').digest('hex'), + ); + + const hashFallbackMetadata = await getLocalVideoMetadata('/tmp/Episode 02.mkv', { + spawn: createSpawnStub({ stdout: JSON.stringify({ format: {}, streams: [] }) }), + fs: { + createReadStream: () => { + const stream = new EventEmitter(); + queueMicrotask(() => { + stream.emit('error', new Error('read failed')); + }); + return stream as unknown as ReturnType; + }, + promises: { + stat: (async () => ({ size: 5678 }) as unknown) as typeof import('node:fs').promises.stat, + }, + } as never, + }); + + assert.equal(hashFallbackMetadata.canonicalTitle, 'Episode 02'); + assert.equal(hashFallbackMetadata.hashSha256, null); +}); diff --git a/src/core/services/immersion-tracker/metadata.ts b/src/core/services/immersion-tracker/metadata.ts new file mode 100644 index 0000000..394da91 --- /dev/null +++ b/src/core/services/immersion-tracker/metadata.ts @@ -0,0 +1,153 @@ +import crypto from 'node:crypto'; +import { spawn as nodeSpawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import { + deriveCanonicalTitle, + emptyMetadata, + hashToCode, + parseFps, + toNullableInt, +} from './reducer'; +import { SOURCE_TYPE_LOCAL, type ProbeMetadata, type VideoMetadata } from './types'; + +type SpawnFn = typeof nodeSpawn; + +interface FsDeps { + createReadStream: typeof fs.createReadStream; + promises: { + stat: typeof fs.promises.stat; + }; +} + +interface MetadataDeps { + spawn?: SpawnFn; + fs?: FsDeps; +} + +export async function computeSha256( + mediaPath: string, + deps: MetadataDeps = {}, +): Promise { + const fileSystem = deps.fs ?? fs; + return new Promise((resolve) => { + const file = fileSystem.createReadStream(mediaPath); + const digest = crypto.createHash('sha256'); + file.on('data', (chunk) => digest.update(chunk)); + file.on('end', () => resolve(digest.digest('hex'))); + file.on('error', () => resolve(null)); + }); +} + +export function runFfprobe(mediaPath: string, deps: MetadataDeps = {}): Promise { + const spawn = deps.spawn ?? nodeSpawn; + return new Promise((resolve) => { + const child = spawn('ffprobe', [ + '-v', + 'error', + '-print_format', + 'json', + '-show_entries', + 'stream=codec_type,codec_tag_string,width,height,avg_frame_rate,bit_rate', + '-show_entries', + 'format=duration,bit_rate', + mediaPath, + ]); + + let output = ''; + let errorOutput = ''; + child.stdout.on('data', (chunk) => { + output += chunk.toString('utf-8'); + }); + child.stderr.on('data', (chunk) => { + errorOutput += chunk.toString('utf-8'); + }); + child.on('error', () => resolve(emptyMetadata())); + child.on('close', () => { + if (errorOutput && output.length === 0) { + resolve(emptyMetadata()); + return; + } + + try { + const parsed = JSON.parse(output) as { + format?: { duration?: string; bit_rate?: string }; + streams?: Array<{ + codec_type?: string; + codec_tag_string?: string; + width?: number; + height?: number; + avg_frame_rate?: string; + bit_rate?: string; + }>; + }; + + const durationText = parsed.format?.duration; + const bitrateText = parsed.format?.bit_rate; + const durationMs = Number(durationText) ? Math.round(Number(durationText) * 1000) : null; + const bitrateKbps = Number(bitrateText) ? Math.round(Number(bitrateText) / 1000) : null; + + let codecId: number | null = null; + let containerId: number | null = null; + let widthPx: number | null = null; + let heightPx: number | null = null; + let fpsX100: number | null = null; + let audioCodecId: number | null = null; + + for (const stream of parsed.streams ?? []) { + if (stream.codec_type === 'video') { + widthPx = toNullableInt(stream.width); + heightPx = toNullableInt(stream.height); + fpsX100 = parseFps(stream.avg_frame_rate); + codecId = hashToCode(stream.codec_tag_string); + containerId = 0; + } + if (stream.codec_type === 'audio') { + audioCodecId = hashToCode(stream.codec_tag_string); + if (audioCodecId && audioCodecId > 0) { + break; + } + } + } + + resolve({ + durationMs, + codecId, + containerId, + widthPx, + heightPx, + fpsX100, + bitrateKbps, + audioCodecId, + }); + } catch { + resolve(emptyMetadata()); + } + }); + }); +} + +export async function getLocalVideoMetadata( + mediaPath: string, + deps: MetadataDeps = {}, +): Promise { + const fileSystem = deps.fs ?? fs; + const hash = await computeSha256(mediaPath, deps); + const info = await runFfprobe(mediaPath, deps); + const stat = await fileSystem.promises.stat(mediaPath); + return { + sourceType: SOURCE_TYPE_LOCAL, + canonicalTitle: deriveCanonicalTitle(mediaPath), + durationMs: info.durationMs || 0, + fileSizeBytes: Number.isFinite(stat.size) ? stat.size : null, + codecId: info.codecId ?? null, + containerId: info.containerId ?? null, + widthPx: info.widthPx ?? null, + heightPx: info.heightPx ?? null, + fpsX100: info.fpsX100 ?? null, + bitrateKbps: info.bitrateKbps ?? null, + audioCodecId: info.audioCodecId ?? null, + hashSha256: hash, + screenshotPath: null, + metadataJson: null, + }; +} diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts new file mode 100644 index 0000000..84a6554 --- /dev/null +++ b/src/core/services/immersion-tracker/query.ts @@ -0,0 +1,104 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { + ImmersionSessionRollupRow, + SessionSummaryQueryRow, + SessionTimelineRow, +} from './types'; + +export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] { + const prepared = db.prepare(` + SELECT + s.video_id AS videoId, + s.started_at_ms AS startedAtMs, + s.ended_at_ms AS endedAtMs, + COALESCE(SUM(t.total_watched_ms), 0) AS totalWatchedMs, + COALESCE(SUM(t.active_watched_ms), 0) AS activeWatchedMs, + COALESCE(SUM(t.lines_seen), 0) AS linesSeen, + COALESCE(SUM(t.words_seen), 0) AS wordsSeen, + COALESCE(SUM(t.tokens_seen), 0) AS tokensSeen, + COALESCE(SUM(t.cards_mined), 0) AS cardsMined, + COALESCE(SUM(t.lookup_count), 0) AS lookupCount, + COALESCE(SUM(t.lookup_hits), 0) AS lookupHits + FROM imm_sessions s + LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id + GROUP BY s.session_id + ORDER BY s.started_at_ms DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as SessionSummaryQueryRow[]; +} + +export function getSessionTimeline( + db: DatabaseSync, + sessionId: number, + limit = 200, +): SessionTimelineRow[] { + const prepared = db.prepare(` + SELECT + sample_ms AS sampleMs, + total_watched_ms AS totalWatchedMs, + active_watched_ms AS activeWatchedMs, + lines_seen AS linesSeen, + words_seen AS wordsSeen, + tokens_seen AS tokensSeen, + cards_mined AS cardsMined + FROM imm_session_telemetry + WHERE session_id = ? + ORDER BY sample_ms DESC + LIMIT ? + `); + return prepared.all(sessionId, limit) as unknown as SessionTimelineRow[]; +} + +export function getQueryHints(db: DatabaseSync): { + totalSessions: number; + activeSessions: number; +} { + const sessions = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions'); + const active = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL'); + const totalSessions = Number(sessions.get()?.total ?? 0); + const activeSessions = Number(active.get()?.total ?? 0); + return { totalSessions, activeSessions }; +} + +export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] { + const prepared = db.prepare(` + SELECT + rollup_day AS rollupDayOrMonth, + video_id AS videoId, + total_sessions AS totalSessions, + total_active_min AS totalActiveMin, + total_lines_seen AS totalLinesSeen, + total_words_seen AS totalWordsSeen, + total_tokens_seen AS totalTokensSeen, + total_cards AS totalCards, + cards_per_hour AS cardsPerHour, + words_per_min AS wordsPerMin, + lookup_hit_rate AS lookupHitRate + FROM imm_daily_rollups + ORDER BY rollup_day DESC, video_id DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; +} + +export function getMonthlyRollups(db: DatabaseSync, limit = 24): ImmersionSessionRollupRow[] { + const prepared = db.prepare(` + SELECT + rollup_month AS rollupDayOrMonth, + video_id AS videoId, + total_sessions AS totalSessions, + total_active_min AS totalActiveMin, + total_lines_seen AS totalLinesSeen, + total_words_seen AS totalWordsSeen, + total_tokens_seen AS totalTokensSeen, + total_cards AS totalCards, + 0 AS cardsPerHour, + 0 AS wordsPerMin, + 0 AS lookupHitRate + FROM imm_monthly_rollups + ORDER BY rollup_month DESC, video_id DESC + LIMIT ? + `); + return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; +} diff --git a/src/core/services/immersion-tracker/queue.ts b/src/core/services/immersion-tracker/queue.ts new file mode 100644 index 0000000..c665431 --- /dev/null +++ b/src/core/services/immersion-tracker/queue.ts @@ -0,0 +1,19 @@ +import type { QueuedWrite } from './types'; + +export function enqueueWrite( + queue: QueuedWrite[], + write: QueuedWrite, + queueCap: number, +): { + dropped: number; + queueLength: number; +} { + let dropped = 0; + if (queue.length >= queueCap) { + const overflow = queue.length - queueCap + 1; + queue.splice(0, overflow); + dropped = overflow; + } + queue.push(write); + return { dropped, queueLength: queue.length }; +} diff --git a/src/core/services/immersion-tracker/reducer.ts b/src/core/services/immersion-tracker/reducer.ts new file mode 100644 index 0000000..f12deb7 --- /dev/null +++ b/src/core/services/immersion-tracker/reducer.ts @@ -0,0 +1,144 @@ +import path from 'node:path'; +import type { ProbeMetadata, SessionState } from './types'; +import { SOURCE_TYPE_REMOTE } from './types'; + +export function createInitialSessionState( + sessionId: number, + videoId: number, + startedAtMs: number, +): SessionState { + return { + sessionId, + videoId, + startedAtMs, + currentLineIndex: 0, + totalWatchedMs: 0, + activeWatchedMs: 0, + linesSeen: 0, + wordsSeen: 0, + tokensSeen: 0, + cardsMined: 0, + lookupCount: 0, + lookupHits: 0, + pauseCount: 0, + pauseMs: 0, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + lastWallClockMs: 0, + lastMediaMs: null, + lastPauseStartMs: null, + isPaused: false, + pendingTelemetry: true, + }; +} + +export function resolveBoundedInt( + value: number | undefined, + fallback: number, + min: number, + max: number, +): number { + if (!Number.isFinite(value)) return fallback; + const candidate = Math.floor(value as number); + if (candidate < min || candidate > max) return fallback; + return candidate; +} + +export function sanitizePayload(payload: Record, maxPayloadBytes: number): string { + const json = JSON.stringify(payload); + return json.length <= maxPayloadBytes ? json : JSON.stringify({ truncated: true }); +} + +export function calculateTextMetrics(value: string): { + words: number; + tokens: number; +} { + const words = value.split(/\s+/).filter(Boolean).length; + const cjkCount = value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0; + const tokens = Math.max(words, cjkCount); + return { words, tokens }; +} + +export function secToMs(seconds: number): number { + const coerced = Number(seconds); + if (!Number.isFinite(coerced)) return 0; + return Math.round(coerced * 1000); +} + +export function normalizeMediaPath(mediaPath: string | null): string { + if (!mediaPath || !mediaPath.trim()) return ''; + return mediaPath.trim(); +} + +export function normalizeText(value: string | null | undefined): string { + if (!value) return ''; + return value.trim().replace(/\s+/g, ' '); +} + +export function buildVideoKey(mediaPath: string, sourceType: number): string { + if (sourceType === SOURCE_TYPE_REMOTE) { + return `remote:${mediaPath}`; + } + return `local:${mediaPath}`; +} + +export function isRemoteSource(mediaPath: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath); +} + +export function deriveCanonicalTitle(mediaPath: string): string { + if (isRemoteSource(mediaPath)) { + try { + const parsed = new URL(mediaPath); + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts.length > 0) { + const leaf = decodeURIComponent(parts[parts.length - 1]!); + return normalizeText(leaf.replace(/\.[^/.]+$/, '')); + } + return normalizeText(parsed.hostname) || 'unknown'; + } catch { + return normalizeText(mediaPath); + } + } + + const filename = path.basename(mediaPath); + return normalizeText(filename.replace(/\.[^/.]+$/, '')); +} + +export function parseFps(value?: string): number | null { + if (!value || typeof value !== 'string') return null; + const [num, den] = value.split('/'); + const n = Number(num); + const d = Number(den); + if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null; + const fps = n / d; + return Number.isFinite(fps) ? Math.round(fps * 100) : null; +} + +export function hashToCode(input?: string): number | null { + if (!input) return null; + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff; + } + return hash || null; +} + +export function emptyMetadata(): ProbeMetadata { + return { + durationMs: null, + codecId: null, + containerId: null, + widthPx: null, + heightPx: null, + fpsX100: null, + bitrateKbps: null, + audioCodecId: null, + }; +} + +export function toNullableInt(value: number | null | undefined): number | null { + if (value === null || value === undefined || !Number.isFinite(value)) return null; + return value; +} diff --git a/src/core/services/immersion-tracker/session.ts b/src/core/services/immersion-tracker/session.ts new file mode 100644 index 0000000..2c39de1 --- /dev/null +++ b/src/core/services/immersion-tracker/session.ts @@ -0,0 +1,37 @@ +import crypto from 'node:crypto'; +import type { DatabaseSync } from 'node:sqlite'; +import { createInitialSessionState } from './reducer'; +import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types'; +import type { SessionState } from './types'; + +export function startSessionRecord( + db: DatabaseSync, + videoId: number, + startedAtMs = Date.now(), +): { sessionId: number; state: SessionState } { + const sessionUuid = crypto.randomUUID(); + const result = db + .prepare( + ` + INSERT INTO imm_sessions ( + session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + `, + ) + .run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, startedAtMs); + const sessionId = Number(result.lastInsertRowid); + return { + sessionId, + state: createInitialSessionState(sessionId, videoId, startedAtMs), + }; +} + +export function finalizeSessionRecord( + db: DatabaseSync, + sessionState: SessionState, + endedAtMs = Date.now(), +): void { + db.prepare( + 'UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?', + ).run(endedAtMs, SESSION_STATUS_ENDED, Date.now(), sessionState.sessionId); +} diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts new file mode 100644 index 0000000..5d89008 --- /dev/null +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -0,0 +1,162 @@ +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 type { DatabaseSync as NodeDatabaseSync } from 'node:sqlite'; +import { finalizeSessionRecord, startSessionRecord } from './session'; +import { + createTrackerPreparedStatements, + ensureSchema, + executeQueuedWrite, + getOrCreateVideoRecord, +} from './storage'; +import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types'; + +type DatabaseSyncCtor = typeof NodeDatabaseSync; +const DatabaseSync: DatabaseSyncCtor | null = (() => { + try { + return (require('node:sqlite') as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null; + } catch { + return null; + } +})(); +const testIfSqlite = DatabaseSync ? test : test.skip; + +function makeDbPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-')); + return path.join(dir, 'immersion.sqlite'); +} + +function cleanupDbPath(dbPath: string): void { + const dir = path.dirname(dbPath); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +testIfSqlite('ensureSchema creates immersion core tables', () => { + const dbPath = makeDbPath(); + const db = new DatabaseSync!(dbPath); + + try { + ensureSchema(db); + const rows = db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`, + ) + .all() as Array<{ name: string }>; + const tableNames = new Set(rows.map((row) => row.name)); + + assert.ok(tableNames.has('imm_videos')); + assert.ok(tableNames.has('imm_sessions')); + assert.ok(tableNames.has('imm_session_telemetry')); + assert.ok(tableNames.has('imm_session_events')); + assert.ok(tableNames.has('imm_daily_rollups')); + assert.ok(tableNames.has('imm_monthly_rollups')); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + +testIfSqlite('start/finalize session updates ended_at and status', () => { + const dbPath = makeDbPath(); + const db = new DatabaseSync!(dbPath); + + try { + ensureSchema(db); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a.mkv', { + canonicalTitle: 'Slice A Episode', + sourcePath: '/tmp/slice-a.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const startedAtMs = 1_234_567_000; + const endedAtMs = startedAtMs + 8_500; + const { sessionId, state } = startSessionRecord(db, videoId, startedAtMs); + + finalizeSessionRecord(db, state, endedAtMs); + + const row = db + .prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?') + .get(sessionId) as { + ended_at_ms: number | null; + status: number; + } | null; + + assert.ok(row); + assert.equal(row?.ended_at_ms, endedAtMs); + assert.equal(row?.status, SESSION_STATUS_ENDED); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + +testIfSqlite('executeQueuedWrite inserts event and telemetry rows', () => { + const dbPath = makeDbPath(); + const db = new DatabaseSync!(dbPath); + + try { + ensureSchema(db); + const stmts = createTrackerPreparedStatements(db); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a-events.mkv', { + canonicalTitle: 'Slice A Events', + sourcePath: '/tmp/slice-a-events.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const { sessionId } = startSessionRecord(db, videoId, 5_000); + + executeQueuedWrite( + { + kind: 'telemetry', + sessionId, + sampleMs: 6_000, + totalWatchedMs: 1_000, + activeWatchedMs: 900, + linesSeen: 3, + wordsSeen: 6, + tokensSeen: 6, + cardsMined: 1, + lookupCount: 2, + lookupHits: 1, + pauseCount: 1, + pauseMs: 50, + seekForwardCount: 0, + seekBackwardCount: 0, + mediaBufferEvents: 0, + }, + stmts, + ); + executeQueuedWrite( + { + kind: 'event', + sessionId, + sampleMs: 6_100, + eventType: EVENT_SUBTITLE_LINE, + lineIndex: 1, + segmentStartMs: 0, + segmentEndMs: 800, + wordsDelta: 2, + cardsDelta: 0, + payloadJson: '{"event":"subtitle-line"}', + }, + stmts, + ); + + const telemetryCount = db + .prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry WHERE session_id = ?') + .get(sessionId) as { total: number }; + const eventCount = db + .prepare('SELECT COUNT(*) AS total FROM imm_session_events WHERE session_id = ?') + .get(sessionId) as { total: number }; + + assert.equal(telemetryCount.total, 1); + assert.equal(eventCount.total, 1); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts new file mode 100644 index 0000000..bd40c69 --- /dev/null +++ b/src/core/services/immersion-tracker/storage.ts @@ -0,0 +1,328 @@ +import type { DatabaseSync } from 'node:sqlite'; +import { SCHEMA_VERSION } from './types'; +import type { QueuedWrite, VideoMetadata } from './types'; + +export interface TrackerPreparedStatements { + telemetryInsertStmt: ReturnType; + eventInsertStmt: ReturnType; +} + +export function applyPragmas(db: DatabaseSync): void { + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA synchronous = NORMAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 2500'); +} + +export function ensureSchema(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS imm_schema_version ( + schema_version INTEGER PRIMARY KEY, + applied_at_ms INTEGER NOT NULL + ); + `); + + const currentVersion = db + .prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1') + .get() as { schema_version: number } | null; + if (currentVersion?.schema_version === SCHEMA_VERSION) { + return; + } + + db.exec(` + CREATE TABLE IF NOT EXISTS imm_videos( + video_id INTEGER PRIMARY KEY AUTOINCREMENT, + video_key TEXT NOT NULL UNIQUE, + canonical_title TEXT NOT NULL, + source_type INTEGER NOT NULL, + source_path TEXT, + source_url TEXT, + 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_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS imm_sessions( + session_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_uuid TEXT NOT NULL UNIQUE, + video_id INTEGER NOT NULL, + started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER, + status INTEGER NOT NULL, + locale_id INTEGER, target_lang_id INTEGER, + difficulty_tier INTEGER, subtitle_mode INTEGER, + created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, + FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS imm_session_telemetry( + telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + sample_ms INTEGER NOT NULL, + total_watched_ms INTEGER NOT NULL DEFAULT 0, + active_watched_ms INTEGER NOT NULL DEFAULT 0, + lines_seen INTEGER NOT NULL DEFAULT 0, + words_seen INTEGER NOT NULL DEFAULT 0, + tokens_seen INTEGER NOT NULL DEFAULT 0, + cards_mined INTEGER NOT NULL DEFAULT 0, + lookup_count INTEGER NOT NULL DEFAULT 0, + lookup_hits INTEGER NOT NULL DEFAULT 0, + pause_count INTEGER NOT NULL DEFAULT 0, + pause_ms INTEGER NOT NULL DEFAULT 0, + seek_forward_count INTEGER NOT NULL DEFAULT 0, + seek_backward_count INTEGER NOT NULL DEFAULT 0, + media_buffer_events INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS imm_session_events( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + ts_ms INTEGER NOT NULL, + event_type INTEGER NOT NULL, + line_index INTEGER, + segment_start_ms INTEGER, + segment_end_ms INTEGER, + words_delta INTEGER NOT NULL DEFAULT 0, + cards_delta INTEGER NOT NULL DEFAULT 0, + payload_json TEXT, + FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS imm_daily_rollups( + rollup_day INTEGER NOT NULL, + video_id INTEGER, + total_sessions INTEGER NOT NULL DEFAULT 0, + total_active_min REAL NOT NULL DEFAULT 0, + total_lines_seen INTEGER NOT NULL DEFAULT 0, + total_words_seen INTEGER NOT NULL DEFAULT 0, + total_tokens_seen INTEGER NOT NULL DEFAULT 0, + total_cards INTEGER NOT NULL DEFAULT 0, + cards_per_hour REAL, + words_per_min REAL, + lookup_hit_rate REAL, + PRIMARY KEY (rollup_day, video_id) + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS imm_monthly_rollups( + rollup_month INTEGER NOT NULL, + video_id INTEGER, + total_sessions INTEGER NOT NULL DEFAULT 0, + total_active_min REAL NOT NULL DEFAULT 0, + total_lines_seen INTEGER NOT NULL DEFAULT 0, + total_words_seen INTEGER NOT NULL DEFAULT 0, + total_tokens_seen INTEGER NOT NULL DEFAULT 0, + total_cards INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (rollup_month, video_id) + ); + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_video_started + ON imm_sessions(video_id, started_at_ms DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_status_started + ON imm_sessions(status, started_at_ms DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_telemetry_session_sample + ON imm_session_telemetry(session_id, sample_ms DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_events_session_ts + ON imm_session_events(session_id, ts_ms DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_events_type_ts + ON imm_session_events(event_type, ts_ms DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_rollups_day_video + ON imm_daily_rollups(rollup_day, video_id) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_rollups_month_video + ON imm_monthly_rollups(rollup_month, video_id) + `); + + db.exec(` + INSERT INTO imm_schema_version(schema_version, applied_at_ms) + VALUES (${SCHEMA_VERSION}, ${Date.now()}) + ON CONFLICT DO NOTHING + `); +} + +export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPreparedStatements { + return { + telemetryInsertStmt: db.prepare(` + INSERT INTO imm_session_telemetry ( + session_id, sample_ms, total_watched_ms, active_watched_ms, + lines_seen, words_seen, tokens_seen, cards_mined, lookup_count, + lookup_hits, pause_count, pause_ms, seek_forward_count, + seek_backward_count, media_buffer_events + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + `), + eventInsertStmt: db.prepare(` + INSERT INTO imm_session_events ( + session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms, + words_delta, cards_delta, payload_json + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + `), + }; +} + +export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void { + if (write.kind === 'telemetry') { + stmts.telemetryInsertStmt.run( + write.sessionId, + write.sampleMs!, + write.totalWatchedMs!, + write.activeWatchedMs!, + write.linesSeen!, + write.wordsSeen!, + write.tokensSeen!, + write.cardsMined!, + write.lookupCount!, + write.lookupHits!, + write.pauseCount!, + write.pauseMs!, + write.seekForwardCount!, + write.seekBackwardCount!, + write.mediaBufferEvents!, + ); + return; + } + + stmts.eventInsertStmt.run( + write.sessionId, + write.sampleMs!, + write.eventType!, + write.lineIndex ?? null, + write.segmentStartMs ?? null, + write.segmentEndMs ?? null, + write.wordsDelta ?? 0, + write.cardsDelta ?? 0, + write.payloadJson ?? null, + ); +} + +export function getOrCreateVideoRecord( + db: DatabaseSync, + videoKey: string, + details: { + canonicalTitle: string; + sourcePath: string | null; + sourceUrl: string | null; + sourceType: number; + }, +): number { + const existing = db + .prepare('SELECT video_id FROM imm_videos WHERE video_key = ?') + .get(videoKey) as { video_id: number } | null; + if (existing?.video_id) { + db.prepare( + 'UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?', + ).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id); + return existing.video_id; + } + + const nowMs = Date.now(); + const insert = 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_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = insert.run( + videoKey, + details.canonicalTitle || 'unknown', + details.sourceType, + details.sourcePath, + details.sourceUrl, + 0, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + nowMs, + nowMs, + ); + return Number(result.lastInsertRowid); +} + +export function updateVideoMetadataRecord( + db: DatabaseSync, + videoId: number, + metadata: VideoMetadata, +): void { + db.prepare( + ` + UPDATE imm_videos + SET + 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 = ?, + updated_at_ms = ? + WHERE video_id = ? + `, + ).run( + metadata.durationMs, + metadata.fileSizeBytes, + metadata.codecId, + metadata.containerId, + metadata.widthPx, + metadata.heightPx, + metadata.fpsX100, + metadata.bitrateKbps, + metadata.audioCodecId, + metadata.hashSha256, + metadata.screenshotPath, + metadata.metadataJson, + Date.now(), + videoId, + ); +} + +export function updateVideoTitleRecord( + db: DatabaseSync, + videoId: number, + canonicalTitle: string, +): void { + db.prepare('UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?').run( + canonicalTitle, + Date.now(), + videoId, + ); +} diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts new file mode 100644 index 0000000..52a9afc --- /dev/null +++ b/src/core/services/immersion-tracker/types.ts @@ -0,0 +1,167 @@ +export const SCHEMA_VERSION = 1; +export const DEFAULT_QUEUE_CAP = 1_000; +export const DEFAULT_BATCH_SIZE = 25; +export const DEFAULT_FLUSH_INTERVAL_MS = 500; +export const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000; +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; +export const DEFAULT_EVENTS_RETENTION_MS = ONE_WEEK_MS; +export const DEFAULT_VACUUM_INTERVAL_MS = ONE_WEEK_MS; +export const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +export const DEFAULT_DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000; +export const DEFAULT_MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000; +export const DEFAULT_MAX_PAYLOAD_BYTES = 256; + +export const SOURCE_TYPE_LOCAL = 1; +export const SOURCE_TYPE_REMOTE = 2; + +export const SESSION_STATUS_ACTIVE = 1; +export const SESSION_STATUS_ENDED = 2; + +export const EVENT_SUBTITLE_LINE = 1; +export const EVENT_MEDIA_BUFFER = 2; +export const EVENT_LOOKUP = 3; +export const EVENT_CARD_MINED = 4; +export const EVENT_SEEK_FORWARD = 5; +export const EVENT_SEEK_BACKWARD = 6; +export const EVENT_PAUSE_START = 7; +export const EVENT_PAUSE_END = 8; + +export interface ImmersionTrackerOptions { + dbPath: string; + policy?: ImmersionTrackerPolicy; +} + +export interface ImmersionTrackerPolicy { + queueCap?: number; + batchSize?: number; + flushIntervalMs?: number; + maintenanceIntervalMs?: number; + payloadCapBytes?: number; + retention?: { + eventsDays?: number; + telemetryDays?: number; + dailyRollupsDays?: number; + monthlyRollupsDays?: number; + vacuumIntervalDays?: number; + }; +} + +export interface TelemetryAccumulator { + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; + lookupCount: number; + lookupHits: number; + pauseCount: number; + pauseMs: number; + seekForwardCount: number; + seekBackwardCount: number; + mediaBufferEvents: number; +} + +export interface SessionState extends TelemetryAccumulator { + sessionId: number; + videoId: number; + startedAtMs: number; + currentLineIndex: number; + lastWallClockMs: number; + lastMediaMs: number | null; + lastPauseStartMs: number | null; + isPaused: boolean; + pendingTelemetry: boolean; +} + +export interface QueuedWrite { + kind: 'telemetry' | 'event'; + sessionId: number; + sampleMs?: number; + totalWatchedMs?: number; + activeWatchedMs?: number; + linesSeen?: number; + wordsSeen?: number; + tokensSeen?: number; + cardsMined?: number; + lookupCount?: number; + lookupHits?: number; + pauseCount?: number; + pauseMs?: number; + seekForwardCount?: number; + seekBackwardCount?: number; + mediaBufferEvents?: number; + eventType?: number; + lineIndex?: number | null; + segmentStartMs?: number | null; + segmentEndMs?: number | null; + wordsDelta?: number; + cardsDelta?: number; + payloadJson?: string | null; +} + +export interface VideoMetadata { + sourceType: number; + canonicalTitle: string; + durationMs: number; + fileSizeBytes: number | null; + codecId: number | null; + containerId: number | null; + widthPx: number | null; + heightPx: number | null; + fpsX100: number | null; + bitrateKbps: number | null; + audioCodecId: number | null; + hashSha256: string | null; + screenshotPath: string | null; + metadataJson: string | null; +} + +export interface SessionSummaryQueryRow { + videoId: number | null; + startedAtMs: number; + endedAtMs: number | null; + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; + lookupCount: number; + lookupHits: number; +} + +export interface SessionTimelineRow { + sampleMs: number; + totalWatchedMs: number; + activeWatchedMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; + cardsMined: number; +} + +export interface ImmersionSessionRollupRow { + rollupDayOrMonth: number; + videoId: number | null; + totalSessions: number; + totalActiveMin: number; + totalLinesSeen: number; + totalWordsSeen: number; + totalTokensSeen: number; + totalCards: number; + cardsPerHour: number | null; + wordsPerMin: number | null; + lookupHitRate: number | null; +} + +export interface ProbeMetadata { + durationMs: number | null; + codecId: number | null; + containerId: number | null; + widthPx: number | null; + heightPx: number | null; + fpsX100: number | null; + bitrateKbps: number | null; + audioCodecId: number | null; +} diff --git a/src/core/services/index.ts b/src/core/services/index.ts new file mode 100644 index 0000000..6a159da --- /dev/null +++ b/src/core/services/index.ts @@ -0,0 +1,112 @@ +export { Texthooker } from './texthooker'; +export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws'; +export { registerGlobalShortcuts } from './shortcut'; +export { createIpcDepsRuntime, registerIpcHandlers } from './ipc'; +export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback'; +export { + refreshOverlayShortcutsRuntime, + registerOverlayShortcuts, + syncOverlayShortcutsRuntime, + unregisterOverlayShortcutsRuntime, +} from './overlay-shortcut'; +export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler'; +export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command'; +export { + copyCurrentSubtitle, + handleMineSentenceDigit, + handleMultiCopyDigit, + markLastCardAsAudioCard, + mineSentenceCard, + triggerFieldGrouping, + updateLastCardFromClipboard, +} from './mining'; +export { createAppLifecycleDepsRuntime, startAppLifecycle } from './app-lifecycle'; +export { cycleSecondarySubMode } from './subtitle-position'; +export { + getInitialInvisibleOverlayVisibility, + isAutoUpdateEnabledRuntime, + shouldAutoInitializeOverlayRuntimeFromConfig, + shouldBindVisibleOverlayToMpvSubVisibility, +} from './startup'; +export { openYomitanSettingsWindow } from './yomitan-settings'; +export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer'; +export { createSubtitleProcessingController } from './subtitle-processing-controller'; +export { createFrequencyDictionaryLookup } from './frequency-dictionary'; +export { createJlptVocabularyLookup } from './jlpt-vocab'; +export { + getIgnoredPos1Entries, + JLPT_EXCLUDED_TERMS, + JLPT_IGNORED_MECAB_POS1, + JLPT_IGNORED_MECAB_POS1_ENTRIES, + JLPT_IGNORED_MECAB_POS1_LIST, + shouldIgnoreJlptByTerm, + shouldIgnoreJlptForMecabPos1, +} from './jlpt-token-filter'; +export type { JlptIgnoredPos1Entry } from './jlpt-token-filter'; +export { loadYomitanExtension } from './yomitan-extension-loader'; +export { + getJimakuLanguagePreference, + getJimakuMaxEntryResults, + jimakuFetchJson, + resolveJimakuApiKey, +} from './jimaku'; +export { + loadSubtitlePosition, + saveSubtitlePosition, + updateCurrentMediaPath, +} from './subtitle-position'; +export { + createOverlayWindow, + enforceOverlayLayerOrder, + ensureOverlayWindowLevel, + updateOverlayWindowBounds, +} from './overlay-window'; +export { initializeOverlayRuntime } from './overlay-runtime-init'; +export { + setInvisibleOverlayVisible, + setVisibleOverlayVisible, + syncInvisibleOverlayMousePassthrough, + updateInvisibleOverlayVisibility, + updateVisibleOverlayVisibility, +} from './overlay-visibility'; +export { + MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + MpvIpcClient, + playNextSubtitleRuntime, + replayCurrentSubtitleRuntime, + resolveCurrentAudioStreamIndex, + sendMpvCommandRuntime, + setMpvSubVisibilityRuntime, + showMpvOsdRuntime, +} from './mpv'; +export type { MpvRuntimeClientLike, MpvTrackProperty } from './mpv'; +export { + applyMpvSubtitleRenderMetricsPatch, + DEFAULT_MPV_SUBTITLE_RENDER_METRICS, + sanitizeMpvSubtitleRenderMetrics, +} from './mpv-render-metrics'; +export { createOverlayContentMeasurementStore } from './overlay-content-measurement'; +export { parseClipboardVideoPath } from './overlay-drop'; +export { handleMpvCommandFromIpc } from './ipc-command'; +export { createFieldGroupingOverlayRuntime } from './field-grouping-overlay'; +export { createNumericShortcutRuntime } from './numeric-shortcut'; +export { runStartupBootstrapRuntime } from './startup'; +export { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from './subsync-runner'; +export { registerAnkiJimakuIpcRuntime } from './anki-jimaku'; +export { ImmersionTrackerService } from './immersion-tracker-service'; +export { + authenticateWithPassword as authenticateWithPasswordRuntime, + listItems as listJellyfinItemsRuntime, + listLibraries as listJellyfinLibrariesRuntime, + listSubtitleTracks as listJellyfinSubtitleTracksRuntime, + resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime, + ticksToSeconds as jellyfinTicksToSecondsRuntime, +} from './jellyfin'; +export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote'; +export { + broadcastRuntimeOptionsChangedRuntime, + createOverlayManager, + setOverlayDebugVisualizationEnabledRuntime, +} from './overlay-manager'; +export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload'; +export { createDiscordPresenceService, buildDiscordPresenceActivity } from './discord-presence'; diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts new file mode 100644 index 0000000..347e33b --- /dev/null +++ b/src/core/services/ipc-command.ts @@ -0,0 +1,92 @@ +import { + RuntimeOptionApplyResult, + RuntimeOptionId, + SubsyncManualRunRequest, + SubsyncResult, +} from '../../types'; + +export interface HandleMpvCommandFromIpcOptions { + specialCommands: { + SUBSYNC_TRIGGER: string; + RUNTIME_OPTIONS_OPEN: string; + RUNTIME_OPTION_CYCLE_PREFIX: string; + REPLAY_SUBTITLE: string; + PLAY_NEXT_SUBTITLE: string; + }; + triggerSubsyncFromConfig: () => void; + openRuntimeOptionsPalette: () => void; + runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; + showMpvOsd: (text: string) => void; + mpvReplaySubtitle: () => void; + mpvPlayNextSubtitle: () => void; + mpvSendCommand: (command: (string | number)[]) => void; + isMpvConnected: () => boolean; + hasRuntimeOptionsManager: () => boolean; +} + +export function handleMpvCommandFromIpc( + command: (string | number)[], + options: HandleMpvCommandFromIpcOptions, +): void { + const first = typeof command[0] === 'string' ? command[0] : ''; + if (first === options.specialCommands.SUBSYNC_TRIGGER) { + options.triggerSubsyncFromConfig(); + return; + } + + if (first === options.specialCommands.RUNTIME_OPTIONS_OPEN) { + options.openRuntimeOptionsPalette(); + return; + } + + if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) { + if (!options.hasRuntimeOptionsManager()) return; + const [, idToken, directionToken] = first.split(':'); + const id = idToken as RuntimeOptionId; + const direction: 1 | -1 = directionToken === 'prev' ? -1 : 1; + const result = options.runtimeOptionsCycle(id, direction); + if (!result.ok && result.error) { + options.showMpvOsd(result.error); + } + return; + } + + if (options.isMpvConnected()) { + if (first === options.specialCommands.REPLAY_SUBTITLE) { + options.mpvReplaySubtitle(); + } else if (first === options.specialCommands.PLAY_NEXT_SUBTITLE) { + options.mpvPlayNextSubtitle(); + } else { + options.mpvSendCommand(command); + } + } +} + +export async function runSubsyncManualFromIpc( + request: SubsyncManualRunRequest, + options: { + isSubsyncInProgress: () => boolean; + setSubsyncInProgress: (inProgress: boolean) => void; + showMpvOsd: (text: string) => void; + runWithSpinner: (task: () => Promise) => Promise; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + }, +): Promise { + if (options.isSubsyncInProgress()) { + const busy = 'Subsync already running'; + options.showMpvOsd(busy); + return { ok: false, message: busy }; + } + try { + options.setSubsyncInProgress(true); + const result = await options.runWithSpinner(() => options.runSubsyncManual(request)); + options.showMpvOsd(result.message); + return result; + } catch (error) { + const message = `Subsync failed: ${(error as Error).message}`; + options.showMpvOsd(message); + return { ok: false, message }; + } finally { + options.setSubsyncInProgress(false); + } +} diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts new file mode 100644 index 0000000..ee51c6c --- /dev/null +++ b/src/core/services/ipc.test.ts @@ -0,0 +1,237 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createIpcDepsRuntime, registerIpcHandlers } from './ipc'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; + +interface FakeIpcRegistrar { + on: Map void>; + handle: Map unknown>; +} + +function createFakeIpcRegistrar(): { + registrar: { + on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void; + handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void; + }; + handlers: FakeIpcRegistrar; +} { + const handlers: FakeIpcRegistrar = { + on: new Map(), + handle: new Map(), + }; + return { + registrar: { + on: (channel, listener) => { + handlers.on.set(channel, listener); + }, + handle: (channel, listener) => { + handlers.handle.set(channel, listener); + }, + }, + handlers, + }; +} + +test('createIpcDepsRuntime wires AniList handlers', async () => { + const calls: string[] = []; + const deps = createIpcDepsRuntime({ + getInvisibleWindow: () => null, + getMainWindow: () => null, + getVisibleOverlayVisibility: () => false, + getInvisibleOverlayVisibility: () => false, + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleVisibleOverlay: () => {}, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getMpvSubtitleRenderMetrics: () => null, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabTokenizer: () => null, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getSecondarySubMode: () => 'hover', + getMpvClient: () => null, + focusMainWindow: () => {}, + runSubsyncManual: async () => ({ ok: true, message: 'ok' }), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => ({}), + setRuntimeOption: () => ({ ok: true }), + cycleRuntimeOption: () => ({ ok: true }), + reportOverlayContentBounds: () => {}, + reportHoveredSubtitleToken: () => {}, + getAnilistStatus: () => ({ tokenStatus: 'resolved' }), + clearAnilistToken: () => { + calls.push('clearAnilistToken'); + }, + openAnilistSetup: () => { + calls.push('openAnilistSetup'); + }, + getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }), + retryAnilistQueueNow: async () => { + calls.push('retryAnilistQueueNow'); + return { ok: true, message: 'done' }; + }, + appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), + }); + + assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); + deps.clearAnilistToken(); + deps.openAnilistSetup(); + assert.deepEqual(deps.getAnilistQueueStatus(), { + pending: 1, + ready: 0, + deadLetter: 0, + }); + assert.deepEqual(await deps.retryAnilistQueueNow(), { + ok: true, + message: 'done', + }); + assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); +}); + +test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: Array<{ id: string; value: unknown }> = []; + const cycles: Array<{ id: string; direction: 1 | -1 }> = []; + registerIpcHandlers( + { + getInvisibleWindow: () => null, + isVisibleOverlayVisible: () => false, + setInvisibleIgnoreMouseEvents: () => {}, + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleDevTools: () => {}, + getVisibleOverlayVisibility: () => false, + toggleVisibleOverlay: () => {}, + getInvisibleOverlayVisibility: () => false, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getMpvSubtitleRenderMetrics: () => null, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabStatus: () => ({ available: false, enabled: false, path: null }), + setMecabEnabled: () => {}, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getSecondarySubMode: () => 'hover', + getCurrentSecondarySub: () => '', + focusMainWindow: () => {}, + runSubsyncManual: async () => ({ ok: true, message: 'ok' }), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + setRuntimeOption: (id, value) => { + calls.push({ id, value }); + return { ok: true }; + }, + cycleRuntimeOption: (id, direction) => { + cycles.push({ id, direction }); + return { ok: true }; + }, + reportOverlayContentBounds: () => {}, + reportHoveredSubtitleToken: () => {}, + getAnilistStatus: () => ({}), + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + getAnilistQueueStatus: () => ({}), + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + }, + registrar, + ); + + const setHandler = handlers.handle.get(IPC_CHANNELS.request.setRuntimeOption); + assert.ok(setHandler); + const invalidIdResult = await setHandler!({}, '__invalid__', true); + assert.deepEqual(invalidIdResult, { ok: false, error: 'Invalid runtime option id' }); + const invalidValueResult = await setHandler!({}, 'anki.autoUpdateNewCards', 42); + assert.deepEqual(invalidValueResult, { + ok: false, + error: 'Invalid runtime option value payload', + }); + const validResult = await setHandler!({}, 'anki.autoUpdateNewCards', true); + assert.deepEqual(validResult, { ok: true }); + assert.deepEqual(calls, [{ id: 'anki.autoUpdateNewCards', value: true }]); + + const cycleHandler = handlers.handle.get(IPC_CHANNELS.request.cycleRuntimeOption); + assert.ok(cycleHandler); + const invalidDirection = await cycleHandler!({}, 'anki.kikuFieldGrouping', 2); + assert.deepEqual(invalidDirection, { + ok: false, + error: 'Invalid runtime option cycle direction', + }); + await cycleHandler!({}, 'anki.kikuFieldGrouping', -1); + assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -1 }]); +}); + +test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const saves: unknown[] = []; + const modals: unknown[] = []; + registerIpcHandlers( + { + getInvisibleWindow: () => null, + isVisibleOverlayVisible: () => false, + setInvisibleIgnoreMouseEvents: () => {}, + onOverlayModalClosed: (modal) => { + modals.push(modal); + }, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleDevTools: () => {}, + getVisibleOverlayVisibility: () => false, + toggleVisibleOverlay: () => {}, + getInvisibleOverlayVisibility: () => false, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getMpvSubtitleRenderMetrics: () => null, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: (position) => { + saves.push(position); + }, + getMecabStatus: () => ({ available: false, enabled: false, path: null }), + setMecabEnabled: () => {}, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getSecondarySubMode: () => 'hover', + getCurrentSecondarySub: () => '', + focusMainWindow: () => {}, + runSubsyncManual: async () => ({ ok: true, message: 'ok' }), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + setRuntimeOption: () => ({ ok: true }), + cycleRuntimeOption: () => ({ ok: true }), + reportOverlayContentBounds: () => {}, + reportHoveredSubtitleToken: () => {}, + getAnilistStatus: () => ({}), + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + getAnilistQueueStatus: () => ({}), + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + }, + registrar, + ); + + handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' }); + handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 }); + assert.deepEqual(saves, [ + { yPercent: 42, invisibleOffsetXPx: undefined, invisibleOffsetYPx: undefined }, + ]); + + handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal'); + handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); + assert.deepEqual(modals, ['subsync']); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts new file mode 100644 index 0000000..1aa8d91 --- /dev/null +++ b/src/core/services/ipc.ts @@ -0,0 +1,397 @@ +import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron'; +import type { + RuntimeOptionId, + RuntimeOptionValue, + SubtitlePosition, + SubsyncManualRunRequest, + SubsyncResult, +} from '../../types'; +import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; +import { + parseMpvCommand, + parseOptionalForwardingOptions, + parseOverlayHostedModal, + parseRuntimeOptionDirection, + parseRuntimeOptionId, + parseRuntimeOptionValue, + parseSubtitlePosition, + parseSubsyncManualRunRequest, +} from '../../shared/ipc/validators'; + +export interface IpcServiceDeps { + getInvisibleWindow: () => WindowLike | null; + isVisibleOverlayVisible: () => boolean; + setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; + onOverlayModalClosed: (modal: OverlayHostedModal) => void; + openYomitanSettings: () => void; + quitApp: () => void; + toggleDevTools: () => void; + getVisibleOverlayVisibility: () => boolean; + toggleVisibleOverlay: () => void; + getInvisibleOverlayVisibility: () => boolean; + tokenizeCurrentSubtitle: () => Promise; + getCurrentSubtitleRaw: () => string; + getCurrentSubtitleAss: () => string; + getMpvSubtitleRenderMetrics: () => unknown; + getSubtitlePosition: () => unknown; + getSubtitleStyle: () => unknown; + saveSubtitlePosition: (position: SubtitlePosition) => void; + getMecabStatus: () => { + available: boolean; + enabled: boolean; + path: string | null; + }; + setMecabEnabled: (enabled: boolean) => void; + handleMpvCommand: (command: Array) => void; + getKeybindings: () => unknown; + getConfiguredShortcuts: () => unknown; + getSecondarySubMode: () => unknown; + getCurrentSecondarySub: () => string; + focusMainWindow: () => void; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + getAnkiConnectStatus: () => boolean; + getRuntimeOptions: () => unknown; + setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; + reportOverlayContentBounds: (payload: unknown) => void; + reportHoveredSubtitleToken: (tokenIndex: number | null) => void; + getAnilistStatus: () => unknown; + clearAnilistToken: () => void; + openAnilistSetup: () => void; + getAnilistQueueStatus: () => unknown; + retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + appendClipboardVideoToQueue: () => { ok: boolean; message: string }; +} + +interface WindowLike { + isDestroyed: () => boolean; + focus: () => void; + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; + webContents: { + toggleDevTools: () => void; + }; +} + +interface MecabTokenizerLike { + getStatus: () => { + available: boolean; + enabled: boolean; + path: string | null; + }; + setEnabled: (enabled: boolean) => void; +} + +interface MpvClientLike { + currentSecondarySubText?: string; +} + +interface IpcMainRegistrar { + on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void; + handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void; +} + +export interface IpcDepsRuntimeOptions { + getInvisibleWindow: () => WindowLike | null; + getMainWindow: () => WindowLike | null; + getVisibleOverlayVisibility: () => boolean; + getInvisibleOverlayVisibility: () => boolean; + onOverlayModalClosed: (modal: OverlayHostedModal) => void; + openYomitanSettings: () => void; + quitApp: () => void; + toggleVisibleOverlay: () => void; + tokenizeCurrentSubtitle: () => Promise; + getCurrentSubtitleRaw: () => string; + getCurrentSubtitleAss: () => string; + getMpvSubtitleRenderMetrics: () => unknown; + getSubtitlePosition: () => unknown; + getSubtitleStyle: () => unknown; + saveSubtitlePosition: (position: SubtitlePosition) => void; + getMecabTokenizer: () => MecabTokenizerLike | null; + handleMpvCommand: (command: Array) => void; + getKeybindings: () => unknown; + getConfiguredShortcuts: () => unknown; + getSecondarySubMode: () => unknown; + getMpvClient: () => MpvClientLike | null; + focusMainWindow: () => void; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + getAnkiConnectStatus: () => boolean; + getRuntimeOptions: () => unknown; + setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; + reportOverlayContentBounds: (payload: unknown) => void; + reportHoveredSubtitleToken: (tokenIndex: number | null) => void; + getAnilistStatus: () => unknown; + clearAnilistToken: () => void; + openAnilistSetup: () => void; + getAnilistQueueStatus: () => unknown; + retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + appendClipboardVideoToQueue: () => { ok: boolean; message: string }; +} + +export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps { + return { + getInvisibleWindow: () => options.getInvisibleWindow(), + isVisibleOverlayVisible: options.getVisibleOverlayVisibility, + setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => { + const invisibleWindow = options.getInvisibleWindow(); + if (!invisibleWindow || invisibleWindow.isDestroyed()) return; + invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions); + }, + onOverlayModalClosed: options.onOverlayModalClosed, + openYomitanSettings: options.openYomitanSettings, + quitApp: options.quitApp, + toggleDevTools: () => { + const mainWindow = options.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.toggleDevTools(); + }, + getVisibleOverlayVisibility: options.getVisibleOverlayVisibility, + toggleVisibleOverlay: options.toggleVisibleOverlay, + getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility, + tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, + getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, + getCurrentSubtitleAss: options.getCurrentSubtitleAss, + getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, + getSubtitlePosition: options.getSubtitlePosition, + getSubtitleStyle: options.getSubtitleStyle, + saveSubtitlePosition: options.saveSubtitlePosition, + getMecabStatus: () => { + const mecabTokenizer = options.getMecabTokenizer(); + return mecabTokenizer + ? mecabTokenizer.getStatus() + : { available: false, enabled: false, path: null }; + }, + setMecabEnabled: (enabled) => { + const mecabTokenizer = options.getMecabTokenizer(); + if (!mecabTokenizer) return; + mecabTokenizer.setEnabled(enabled); + }, + handleMpvCommand: options.handleMpvCommand, + getKeybindings: options.getKeybindings, + getConfiguredShortcuts: options.getConfiguredShortcuts, + getSecondarySubMode: options.getSecondarySubMode, + getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '', + focusMainWindow: () => { + const mainWindow = options.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.focus(); + }, + runSubsyncManual: options.runSubsyncManual, + getAnkiConnectStatus: options.getAnkiConnectStatus, + getRuntimeOptions: options.getRuntimeOptions, + setRuntimeOption: options.setRuntimeOption, + cycleRuntimeOption: options.cycleRuntimeOption, + reportOverlayContentBounds: options.reportOverlayContentBounds, + reportHoveredSubtitleToken: options.reportHoveredSubtitleToken, + getAnilistStatus: options.getAnilistStatus, + clearAnilistToken: options.clearAnilistToken, + openAnilistSetup: options.openAnilistSetup, + getAnilistQueueStatus: options.getAnilistQueueStatus, + retryAnilistQueueNow: options.retryAnilistQueueNow, + appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, + }; +} + +export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void { + ipc.on( + IPC_CHANNELS.command.setIgnoreMouseEvents, + (event: unknown, ignore: unknown, options: unknown = {}) => { + if (typeof ignore !== 'boolean') return; + const parsedOptions = parseOptionalForwardingOptions(options); + const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender); + if (senderWindow && !senderWindow.isDestroyed()) { + const invisibleWindow = deps.getInvisibleWindow(); + if ( + senderWindow === invisibleWindow && + deps.isVisibleOverlayVisible() && + invisibleWindow && + !invisibleWindow.isDestroyed() + ) { + deps.setInvisibleIgnoreMouseEvents(true, { forward: true }); + } else { + senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); + } + } + }, + ); + + ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => { + const parsedModal = parseOverlayHostedModal(modal); + if (!parsedModal) return; + deps.onOverlayModalClosed(parsedModal); + }); + + ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => { + deps.openYomitanSettings(); + }); + + ipc.on(IPC_CHANNELS.command.quitApp, () => { + deps.quitApp(); + }); + + ipc.on(IPC_CHANNELS.command.toggleDevTools, () => { + deps.toggleDevTools(); + }); + + ipc.handle(IPC_CHANNELS.request.getOverlayVisibility, () => { + return deps.getVisibleOverlayVisibility(); + }); + + ipc.on(IPC_CHANNELS.command.toggleOverlay, () => { + deps.toggleVisibleOverlay(); + }); + + ipc.handle(IPC_CHANNELS.request.getVisibleOverlayVisibility, () => { + return deps.getVisibleOverlayVisibility(); + }); + + ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => { + return deps.getInvisibleOverlayVisibility(); + }); + + ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => { + return await deps.tokenizeCurrentSubtitle(); + }); + + ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleRaw, () => { + return deps.getCurrentSubtitleRaw(); + }); + + ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleAss, () => { + return deps.getCurrentSubtitleAss(); + }); + + ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => { + return deps.getMpvSubtitleRenderMetrics(); + }); + + ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => { + return deps.getSubtitlePosition(); + }); + + ipc.handle(IPC_CHANNELS.request.getSubtitleStyle, () => { + return deps.getSubtitleStyle(); + }); + + ipc.on(IPC_CHANNELS.command.saveSubtitlePosition, (_event: unknown, position: unknown) => { + const parsedPosition = parseSubtitlePosition(position); + if (!parsedPosition) return; + deps.saveSubtitlePosition(parsedPosition); + }); + + ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { + return deps.getMecabStatus(); + }); + + ipc.on(IPC_CHANNELS.command.setMecabEnabled, (_event: unknown, enabled: unknown) => { + if (typeof enabled !== 'boolean') return; + deps.setMecabEnabled(enabled); + }); + + ipc.on(IPC_CHANNELS.command.mpvCommand, (_event: unknown, command: unknown) => { + const parsedCommand = parseMpvCommand(command); + if (!parsedCommand) return; + deps.handleMpvCommand(parsedCommand); + }); + + ipc.handle(IPC_CHANNELS.request.getKeybindings, () => { + return deps.getKeybindings(); + }); + + ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => { + return deps.getConfiguredShortcuts(); + }); + + ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => { + return deps.getSecondarySubMode(); + }); + + ipc.handle(IPC_CHANNELS.request.getCurrentSecondarySub, () => { + return deps.getCurrentSecondarySub(); + }); + + ipc.handle(IPC_CHANNELS.request.focusMainWindow, () => { + deps.focusMainWindow(); + }); + + ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => { + const parsedRequest = parseSubsyncManualRunRequest(request); + if (!parsedRequest) { + return { ok: false, message: 'Invalid subsync manual request payload' }; + } + return await deps.runSubsyncManual(parsedRequest); + }); + + ipc.handle(IPC_CHANNELS.request.getAnkiConnectStatus, () => { + return deps.getAnkiConnectStatus(); + }); + + ipc.handle(IPC_CHANNELS.request.getRuntimeOptions, () => { + return deps.getRuntimeOptions(); + }); + + ipc.handle(IPC_CHANNELS.request.setRuntimeOption, (_event, id: unknown, value: unknown) => { + const parsedId = parseRuntimeOptionId(id); + if (!parsedId) { + return { ok: false, error: 'Invalid runtime option id' }; + } + const parsedValue = parseRuntimeOptionValue(value); + if (parsedValue === null) { + return { ok: false, error: 'Invalid runtime option value payload' }; + } + return deps.setRuntimeOption(parsedId, parsedValue); + }); + + ipc.handle(IPC_CHANNELS.request.cycleRuntimeOption, (_event, id: unknown, direction: unknown) => { + const parsedId = parseRuntimeOptionId(id); + if (!parsedId) { + return { ok: false, error: 'Invalid runtime option id' }; + } + const parsedDirection = parseRuntimeOptionDirection(direction); + if (!parsedDirection) { + return { ok: false, error: 'Invalid runtime option cycle direction' }; + } + return deps.cycleRuntimeOption(parsedId, parsedDirection); + }); + + ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (_event: unknown, payload: unknown) => { + deps.reportOverlayContentBounds(payload); + }); + + ipc.on('subtitle-token-hover:set', (_event: unknown, tokenIndex: unknown) => { + if (tokenIndex === null) { + deps.reportHoveredSubtitleToken(null); + return; + } + if (!Number.isInteger(tokenIndex) || (tokenIndex as number) < 0) { + return; + } + deps.reportHoveredSubtitleToken(tokenIndex as number); + }); + + ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => { + return deps.getAnilistStatus(); + }); + + ipc.handle(IPC_CHANNELS.request.clearAnilistToken, () => { + deps.clearAnilistToken(); + return { ok: true }; + }); + + ipc.handle(IPC_CHANNELS.request.openAnilistSetup, () => { + deps.openAnilistSetup(); + return { ok: true }; + }); + + ipc.handle(IPC_CHANNELS.request.getAnilistQueueStatus, () => { + return deps.getAnilistQueueStatus(); + }); + + ipc.handle(IPC_CHANNELS.request.retryAnilistNow, async () => { + return await deps.retryAnilistQueueNow(); + }); + + ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => { + return deps.appendClipboardVideoToQueue(); + }); +} diff --git a/src/core/services/jellyfin-remote.test.ts b/src/core/services/jellyfin-remote.test.ts new file mode 100644 index 0000000..071bada --- /dev/null +++ b/src/core/services/jellyfin-remote.test.ts @@ -0,0 +1,317 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote'; + +class FakeWebSocket { + private listeners: Record void>> = {}; + + on(event: string, listener: (...args: unknown[]) => void): this { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(listener); + return this; + } + + close(): void { + this.emit('close'); + } + + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners[event] ?? []) { + listener(...args); + } + } +} + +test('Jellyfin remote service has no traffic until started', async () => { + let socketCreateCount = 0; + const fetchCalls: Array<{ input: string; init: RequestInit }> = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local:8096', + accessToken: 'token-0', + deviceId: 'device-0', + webSocketFactory: () => { + socketCreateCount += 1; + return new FakeWebSocket() as unknown as any; + }, + fetchImpl: (async (input, init) => { + fetchCalls.push({ input: String(input), init: init ?? {} }); + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(socketCreateCount, 0); + assert.equal(fetchCalls.length, 0); + assert.equal(service.isConnected(), false); +}); + +test('start posts capabilities on socket connect', async () => { + const sockets: FakeWebSocket[] = []; + const fetchCalls: Array<{ input: string; init: RequestInit }> = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local:8096', + accessToken: 'token-1', + deviceId: 'device-1', + webSocketFactory: (url) => { + assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1'); + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async (input, init) => { + fetchCalls.push({ input: String(input), init: init ?? {} }); + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + + service.start(); + sockets[0]!.emit('open'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0]!.input, 'http://jellyfin.local:8096/Sessions/Capabilities/Full'); + assert.equal(service.isConnected(), true); +}); + +test('socket headers include jellyfin authorization metadata', () => { + const seenHeaders: Record[] = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local:8096', + accessToken: 'token-auth', + deviceId: 'device-auth', + clientName: 'SubMiner', + clientVersion: '0.1.0', + deviceName: 'SubMiner', + socketHeadersFactory: (_url, headers) => { + seenHeaders.push(headers); + return new FakeWebSocket() as unknown as any; + }, + fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch, + }); + + service.start(); + assert.equal(seenHeaders.length, 1); + assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"')); + assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"')); + assert.ok(seenHeaders[0]!['X-Emby-Authorization']); +}); + +test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => { + const sockets: FakeWebSocket[] = []; + const playPayloads: unknown[] = []; + const playstatePayloads: unknown[] = []; + const commandPayloads: unknown[] = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-2', + deviceId: 'device-2', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch, + onPlay: (payload) => playPayloads.push(payload), + onPlaystate: (payload) => playstatePayloads.push(payload), + onGeneralCommand: (payload) => commandPayloads.push(payload), + }); + + service.start(); + const socket = sockets[0]!; + socket.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemId: 'movie-1' } })); + socket.emit( + 'message', + JSON.stringify({ MessageType: 'Playstate', Data: JSON.stringify({ Command: 'Pause' }) }), + ); + socket.emit( + 'message', + Buffer.from( + JSON.stringify({ + MessageType: 'GeneralCommand', + Data: { Name: 'DisplayMessage' }, + }), + 'utf8', + ), + ); + + assert.deepEqual(playPayloads, [{ ItemId: 'movie-1' }]); + assert.deepEqual(playstatePayloads, [{ Command: 'Pause' }]); + assert.deepEqual(commandPayloads, [{ Name: 'DisplayMessage' }]); +}); + +test('schedules reconnect with bounded exponential backoff', () => { + const sockets: FakeWebSocket[] = []; + const delays: number[] = []; + const pendingTimers: Array<() => void> = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-3', + deviceId: 'device-3', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch, + reconnectBaseDelayMs: 100, + reconnectMaxDelayMs: 400, + setTimer: ((handler: () => void, delay?: number) => { + pendingTimers.push(handler); + delays.push(Number(delay)); + return pendingTimers.length as unknown as ReturnType; + }) as typeof setTimeout, + clearTimer: (() => { + return; + }) as typeof clearTimeout, + }); + + service.start(); + sockets[0]!.emit('close'); + pendingTimers.shift()?.(); + sockets[1]!.emit('close'); + pendingTimers.shift()?.(); + sockets[2]!.emit('close'); + pendingTimers.shift()?.(); + sockets[3]!.emit('close'); + + assert.deepEqual(delays, [100, 200, 400, 400]); + assert.equal(sockets.length, 4); +}); + +test('Jellyfin remote stop prevents further reconnect/network activity', () => { + const sockets: FakeWebSocket[] = []; + const fetchCalls: Array<{ input: string; init: RequestInit }> = []; + const pendingTimers: Array<() => void> = []; + const clearedTimers: unknown[] = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-stop', + deviceId: 'device-stop', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async (input, init) => { + fetchCalls.push({ input: String(input), init: init ?? {} }); + return new Response(null, { status: 200 }); + }) as typeof fetch, + setTimer: ((handler: () => void) => { + pendingTimers.push(handler); + return pendingTimers.length as unknown as ReturnType; + }) as typeof setTimeout, + clearTimer: ((timer) => { + clearedTimers.push(timer); + }) as typeof clearTimeout, + }); + + service.start(); + assert.equal(sockets.length, 1); + sockets[0]!.emit('close'); + assert.equal(pendingTimers.length, 1); + + service.stop(); + for (const reconnect of pendingTimers) reconnect(); + + assert.ok(clearedTimers.length >= 1); + assert.equal(sockets.length, 1); + assert.equal(fetchCalls.length, 0); + assert.equal(service.isConnected(), false); +}); + +test('reportProgress posts timeline payload and treats failure as non-fatal', async () => { + const sockets: FakeWebSocket[] = []; + const fetchCalls: Array<{ input: string; init: RequestInit }> = []; + let shouldFailTimeline = false; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-4', + deviceId: 'device-4', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async (input, init) => { + fetchCalls.push({ input: String(input), init: init ?? {} }); + if (String(input).endsWith('/Sessions/Playing/Progress') && shouldFailTimeline) { + return new Response('boom', { status: 500 }); + } + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + + service.start(); + sockets[0]!.emit('open'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const expectedPayload = buildJellyfinTimelinePayload({ + itemId: 'movie-2', + positionTicks: 123456, + isPaused: true, + volumeLevel: 33, + audioStreamIndex: 1, + subtitleStreamIndex: 2, + }); + const expectedPostedPayload = JSON.parse(JSON.stringify(expectedPayload)); + + const ok = await service.reportProgress({ + itemId: 'movie-2', + positionTicks: 123456, + isPaused: true, + volumeLevel: 33, + audioStreamIndex: 1, + subtitleStreamIndex: 2, + }); + shouldFailTimeline = true; + const failed = await service.reportProgress({ + itemId: 'movie-2', + positionTicks: 999, + }); + + const timelineCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Progress')); + assert.ok(timelineCall); + assert.equal(ok, true); + assert.equal(failed, false); + assert.ok(typeof timelineCall.init.body === 'string'); + assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload); +}); + +test('advertiseNow validates server registration using Sessions endpoint', async () => { + const sockets: FakeWebSocket[] = []; + const calls: string[] = []; + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-5', + deviceId: 'device-5', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async (input) => { + const url = String(input); + calls.push(url); + if (url.endsWith('/Sessions')) { + return new Response(JSON.stringify([{ DeviceId: 'device-5' }]), { status: 200 }); + } + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + + service.start(); + sockets[0]!.emit('open'); + const ok = await service.advertiseNow(); + assert.equal(ok, true); + assert.ok(calls.some((url) => url.endsWith('/Sessions'))); +}); diff --git a/src/core/services/jellyfin-remote.ts b/src/core/services/jellyfin-remote.ts new file mode 100644 index 0000000..8f72050 --- /dev/null +++ b/src/core/services/jellyfin-remote.ts @@ -0,0 +1,431 @@ +import WebSocket from 'ws'; + +export interface JellyfinRemoteSessionMessage { + MessageType?: string; + Data?: unknown; +} + +export interface JellyfinTimelinePlaybackState { + itemId: string; + mediaSourceId?: string; + positionTicks?: number; + playbackStartTimeTicks?: number; + isPaused?: boolean; + isMuted?: boolean; + canSeek?: boolean; + volumeLevel?: number; + playbackRate?: number; + playMethod?: string; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + playlistItemId?: string | null; + eventName?: string; +} + +export interface JellyfinTimelinePayload { + ItemId: string; + MediaSourceId?: string; + PositionTicks: number; + PlaybackStartTimeTicks: number; + IsPaused: boolean; + IsMuted: boolean; + CanSeek: boolean; + VolumeLevel: number; + PlaybackRate: number; + PlayMethod: string; + AudioStreamIndex?: number | null; + SubtitleStreamIndex?: number | null; + PlaylistItemId?: string | null; + EventName: string; +} + +interface JellyfinRemoteSocket { + on(event: 'open', listener: () => void): this; + on(event: 'close', listener: () => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'message', listener: (data: unknown) => void): this; + close(): void; +} + +type JellyfinRemoteSocketHeaders = Record; + +export interface JellyfinRemoteSessionServiceOptions { + serverUrl: string; + accessToken: string; + deviceId: string; + capabilities?: { + PlayableMediaTypes?: string; + SupportedCommands?: string; + SupportsMediaControl?: boolean; + }; + onPlay?: (payload: unknown) => void; + onPlaystate?: (payload: unknown) => void; + onGeneralCommand?: (payload: unknown) => void; + fetchImpl?: typeof fetch; + webSocketFactory?: (url: string) => JellyfinRemoteSocket; + socketHeadersFactory?: ( + url: string, + headers: JellyfinRemoteSocketHeaders, + ) => JellyfinRemoteSocket; + setTimer?: typeof setTimeout; + clearTimer?: typeof clearTimeout; + reconnectBaseDelayMs?: number; + reconnectMaxDelayMs?: number; + clientName?: string; + clientVersion?: string; + deviceName?: string; + onConnected?: () => void; + onDisconnected?: () => void; +} + +function normalizeServerUrl(serverUrl: string): string { + return serverUrl.trim().replace(/\/+$/, ''); +} + +function clampVolume(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 100; + return Math.max(0, Math.min(100, Math.round(value))); +} + +function normalizeTicks(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; + return Math.max(0, Math.floor(value)); +} + +function parseMessageData(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function parseInboundMessage(rawData: unknown): JellyfinRemoteSessionMessage | null { + const serialized = + typeof rawData === 'string' + ? rawData + : Buffer.isBuffer(rawData) + ? rawData.toString('utf8') + : null; + if (!serialized) return null; + try { + const parsed = JSON.parse(serialized) as JellyfinRemoteSessionMessage; + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch { + return null; + } +} + +function asNullableInteger(value: number | null | undefined): number | null { + if (typeof value !== 'number' || !Number.isInteger(value)) return null; + return value; +} + +function createDefaultCapabilities(): { + PlayableMediaTypes: string; + SupportedCommands: string; + SupportsMediaControl: boolean; +} { + return { + PlayableMediaTypes: 'Video,Audio', + SupportedCommands: + 'Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent', + SupportsMediaControl: true, + }; +} + +function buildAuthorizationHeader(params: { + clientName: string; + deviceName: string; + clientVersion: string; + deviceId: string; + accessToken: string; +}): string { + return `MediaBrowser Client="${params.clientName}", Device="${params.deviceName}", DeviceId="${params.deviceId}", Version="${params.clientVersion}", Token="${params.accessToken}"`; +} + +export function buildJellyfinTimelinePayload( + state: JellyfinTimelinePlaybackState, +): JellyfinTimelinePayload { + return { + ItemId: state.itemId, + MediaSourceId: state.mediaSourceId, + PositionTicks: normalizeTicks(state.positionTicks), + PlaybackStartTimeTicks: normalizeTicks(state.playbackStartTimeTicks), + IsPaused: state.isPaused === true, + IsMuted: state.isMuted === true, + CanSeek: state.canSeek !== false, + VolumeLevel: clampVolume(state.volumeLevel), + PlaybackRate: + typeof state.playbackRate === 'number' && Number.isFinite(state.playbackRate) + ? state.playbackRate + : 1, + PlayMethod: state.playMethod || 'DirectPlay', + AudioStreamIndex: asNullableInteger(state.audioStreamIndex), + SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex), + PlaylistItemId: state.playlistItemId, + EventName: state.eventName || 'timeupdate', + }; +} + +export class JellyfinRemoteSessionService { + private readonly serverUrl: string; + private readonly accessToken: string; + private readonly deviceId: string; + private readonly fetchImpl: typeof fetch; + private readonly webSocketFactory?: (url: string) => JellyfinRemoteSocket; + private readonly socketHeadersFactory?: ( + url: string, + headers: JellyfinRemoteSocketHeaders, + ) => JellyfinRemoteSocket; + private readonly setTimer: typeof setTimeout; + private readonly clearTimer: typeof clearTimeout; + private readonly onPlay?: (payload: unknown) => void; + private readonly onPlaystate?: (payload: unknown) => void; + private readonly onGeneralCommand?: (payload: unknown) => void; + private readonly capabilities: { + PlayableMediaTypes: string; + SupportedCommands: string; + SupportsMediaControl: boolean; + }; + private readonly authHeader: string; + private readonly onConnected?: () => void; + private readonly onDisconnected?: () => void; + + private readonly reconnectBaseDelayMs: number; + private readonly reconnectMaxDelayMs: number; + private socket: JellyfinRemoteSocket | null = null; + private running = false; + private connected = false; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + + constructor(options: JellyfinRemoteSessionServiceOptions) { + this.serverUrl = normalizeServerUrl(options.serverUrl); + this.accessToken = options.accessToken; + this.deviceId = options.deviceId; + this.fetchImpl = options.fetchImpl ?? fetch; + this.webSocketFactory = options.webSocketFactory; + this.socketHeadersFactory = options.socketHeadersFactory; + this.setTimer = options.setTimer ?? setTimeout; + this.clearTimer = options.clearTimer ?? clearTimeout; + this.onPlay = options.onPlay; + this.onPlaystate = options.onPlaystate; + this.onGeneralCommand = options.onGeneralCommand; + this.capabilities = { + ...createDefaultCapabilities(), + ...(options.capabilities ?? {}), + }; + const clientName = options.clientName || 'SubMiner'; + const clientVersion = options.clientVersion || '0.1.0'; + const deviceName = options.deviceName || clientName; + this.authHeader = buildAuthorizationHeader({ + clientName, + deviceName, + clientVersion, + deviceId: this.deviceId, + accessToken: this.accessToken, + }); + this.onConnected = options.onConnected; + this.onDisconnected = options.onDisconnected; + this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500); + this.reconnectMaxDelayMs = Math.max( + this.reconnectBaseDelayMs, + options.reconnectMaxDelayMs ?? 10_000, + ); + } + + public start(): void { + if (this.running) return; + this.running = true; + this.reconnectAttempt = 0; + this.connectSocket(); + } + + public stop(): void { + this.running = false; + this.connected = false; + if (this.reconnectTimer) { + this.clearTimer(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.socket) { + this.socket.close(); + this.socket = null; + } + } + + public isConnected(): boolean { + return this.connected; + } + + public async advertiseNow(): Promise { + await this.postCapabilities(); + return this.isRegisteredOnServer(); + } + + public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise { + return this.postTimeline('/Sessions/Playing', { + ...buildJellyfinTimelinePayload(state), + EventName: state.eventName || 'start', + }); + } + + public async reportProgress(state: JellyfinTimelinePlaybackState): Promise { + return this.postTimeline('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state)); + } + + public async reportStopped(state: JellyfinTimelinePlaybackState): Promise { + return this.postTimeline('/Sessions/Playing/Stopped', { + ...buildJellyfinTimelinePayload(state), + EventName: state.eventName || 'stop', + }); + } + + private connectSocket(): void { + if (!this.running) return; + if (this.reconnectTimer) { + this.clearTimer(this.reconnectTimer); + this.reconnectTimer = null; + } + const socket = this.createSocket(this.createSocketUrl()); + this.socket = socket; + let disconnected = false; + + socket.on('open', () => { + if (this.socket !== socket || !this.running) return; + this.connected = true; + this.reconnectAttempt = 0; + this.onConnected?.(); + void this.postCapabilities(); + }); + + socket.on('message', (rawData) => { + this.handleInboundMessage(rawData); + }); + + const handleDisconnect = () => { + if (disconnected) return; + disconnected = true; + if (this.socket === socket) { + this.socket = null; + } + this.connected = false; + this.onDisconnected?.(); + if (this.running) { + this.scheduleReconnect(); + } + }; + + socket.on('close', handleDisconnect); + socket.on('error', handleDisconnect); + } + + private scheduleReconnect(): void { + const delay = Math.min( + this.reconnectMaxDelayMs, + this.reconnectBaseDelayMs * 2 ** this.reconnectAttempt, + ); + this.reconnectAttempt += 1; + if (this.reconnectTimer) { + this.clearTimer(this.reconnectTimer); + } + this.reconnectTimer = this.setTimer(() => { + this.reconnectTimer = null; + this.connectSocket(); + }, delay); + } + + private createSocketUrl(): string { + const baseUrl = new URL(`${this.serverUrl}/`); + const socketUrl = new URL('/socket', baseUrl); + socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + socketUrl.searchParams.set('api_key', this.accessToken); + socketUrl.searchParams.set('deviceId', this.deviceId); + return socketUrl.toString(); + } + + private createSocket(url: string): JellyfinRemoteSocket { + const headers: JellyfinRemoteSocketHeaders = { + Authorization: this.authHeader, + 'X-Emby-Authorization': this.authHeader, + 'X-Emby-Token': this.accessToken, + }; + if (this.socketHeadersFactory) { + return this.socketHeadersFactory(url, headers); + } + if (this.webSocketFactory) { + return this.webSocketFactory(url); + } + return new WebSocket(url, { headers }) as unknown as JellyfinRemoteSocket; + } + + private async postCapabilities(): Promise { + const payload = this.capabilities; + const fullEndpointOk = await this.postJson('/Sessions/Capabilities/Full', payload); + if (fullEndpointOk) return; + await this.postJson('/Sessions/Capabilities', payload); + } + + private async isRegisteredOnServer(): Promise { + try { + const response = await this.fetchImpl(`${this.serverUrl}/Sessions`, { + method: 'GET', + headers: { + Authorization: this.authHeader, + 'X-Emby-Authorization': this.authHeader, + 'X-Emby-Token': this.accessToken, + }, + }); + if (!response.ok) return false; + const sessions = (await response.json()) as Array>; + return sessions.some((session) => String(session.DeviceId || '') === this.deviceId); + } catch { + return false; + } + } + + private async postTimeline(path: string, payload: JellyfinTimelinePayload): Promise { + return this.postJson(path, payload); + } + + private async postJson(path: string, payload: unknown): Promise { + try { + const response = await this.fetchImpl(`${this.serverUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.authHeader, + 'X-Emby-Authorization': this.authHeader, + 'X-Emby-Token': this.accessToken, + }, + body: JSON.stringify(payload), + }); + return response.ok; + } catch { + return false; + } + } + + private handleInboundMessage(rawData: unknown): void { + const message = parseInboundMessage(rawData); + if (!message) return; + const messageType = message.MessageType; + const payload = parseMessageData(message.Data); + if (messageType === 'Play') { + this.onPlay?.(payload); + return; + } + if (messageType === 'Playstate') { + this.onPlaystate?.(payload); + return; + } + if (messageType === 'GeneralCommand') { + this.onGeneralCommand?.(payload); + } + } +} diff --git a/src/core/services/jellyfin-token-store.ts b/src/core/services/jellyfin-token-store.ts new file mode 100644 index 0000000..ce9e38f --- /dev/null +++ b/src/core/services/jellyfin-token-store.ts @@ -0,0 +1,140 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { safeStorage } from 'electron'; + +interface PersistedSessionPayload { + encryptedSession?: string; + plaintextSession?: { + accessToken?: string; + userId?: string; + }; + // Legacy payload fields (token only). + encryptedToken?: string; + plaintextToken?: string; + updatedAt?: number; +} + +export interface JellyfinStoredSession { + accessToken: string; + userId: string; +} + +export interface JellyfinTokenStore { + loadSession: () => JellyfinStoredSession | null; + saveSession: (session: JellyfinStoredSession) => void; + clearSession: () => void; +} + +function ensureDirectory(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function writePayload(filePath: string, payload: PersistedSessionPayload): void { + ensureDirectory(filePath); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); +} + +export function createJellyfinTokenStore( + filePath: string, + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + error: (message: string, details?: unknown) => void; + }, +): JellyfinTokenStore { + return { + loadSession(): JellyfinStoredSession | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as PersistedSessionPayload; + + if (typeof parsed.encryptedSession === 'string' && parsed.encryptedSession.length > 0) { + const encrypted = Buffer.from(parsed.encryptedSession, 'base64'); + if (!safeStorage.isEncryptionAvailable()) { + logger.warn('Jellyfin session encryption is not available on this system.'); + return null; + } + const decrypted = safeStorage.decryptString(encrypted).trim(); + const session = JSON.parse(decrypted) as Partial; + const accessToken = typeof session.accessToken === 'string' ? session.accessToken.trim() : ''; + const userId = typeof session.userId === 'string' ? session.userId.trim() : ''; + if (!accessToken || !userId) return null; + return { accessToken, userId }; + } + + if (parsed.plaintextSession && typeof parsed.plaintextSession === 'object') { + const accessToken = + typeof parsed.plaintextSession.accessToken === 'string' + ? parsed.plaintextSession.accessToken.trim() + : ''; + const userId = + typeof parsed.plaintextSession.userId === 'string' + ? parsed.plaintextSession.userId.trim() + : ''; + if (accessToken && userId) { + const session = { accessToken, userId }; + this.saveSession(session); + return session; + } + } + + if ( + (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) || + (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) + ) { + logger.warn('Ignoring legacy Jellyfin token-only store payload because userId is missing.'); + } + } catch (error) { + logger.error('Failed to read Jellyfin session store.', error); + } + return null; + }, + + saveSession(session: JellyfinStoredSession): void { + const accessToken = session.accessToken.trim(); + const userId = session.userId.trim(); + if (!accessToken || !userId) { + this.clearSession(); + return; + } + try { + if (!safeStorage.isEncryptionAvailable()) { + logger.warn( + 'Jellyfin session encryption unavailable; storing session in plaintext fallback.', + ); + writePayload(filePath, { + plaintextSession: { + accessToken, + userId, + }, + updatedAt: Date.now(), + }); + return; + } + const encrypted = safeStorage.encryptString(JSON.stringify({ accessToken, userId })); + writePayload(filePath, { + encryptedSession: encrypted.toString('base64'), + updatedAt: Date.now(), + }); + } catch (error) { + logger.error('Failed to persist Jellyfin session.', error); + } + }, + + clearSession(): void { + if (!fs.existsSync(filePath)) return; + try { + fs.unlinkSync(filePath); + logger.info('Cleared stored Jellyfin session.'); + } catch (error) { + logger.error('Failed to clear stored Jellyfin session.', error); + } + }, + }; +} diff --git a/src/core/services/jellyfin.test.ts b/src/core/services/jellyfin.test.ts new file mode 100644 index 0000000..1c84bf9 --- /dev/null +++ b/src/core/services/jellyfin.test.ts @@ -0,0 +1,690 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + authenticateWithPassword, + listItems, + listLibraries, + listSubtitleTracks, + resolvePlaybackPlan, + ticksToSeconds, +} from './jellyfin'; + +const clientInfo = { + deviceId: 'subminer-test', + clientName: 'SubMiner', + clientVersion: '0.1.0-test', +}; + +test('authenticateWithPassword returns token and user', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + assert.match(String(input), /Users\/AuthenticateByName$/); + return new Response( + JSON.stringify({ + AccessToken: 'abc123', + User: { Id: 'user-1' }, + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const session = await authenticateWithPassword( + 'http://jellyfin.local:8096/', + 'kyle', + 'pw', + clientInfo, + ); + assert.equal(session.serverUrl, 'http://jellyfin.local:8096'); + assert.equal(session.accessToken, 'abc123'); + assert.equal(session.userId, 'user-1'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('listLibraries maps server response', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Items: [ + { + Id: 'lib-1', + Name: 'TV', + CollectionType: 'tvshows', + Type: 'CollectionFolder', + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const libraries = await listLibraries( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + ); + assert.deepEqual(libraries, [ + { + id: 'lib-1', + name: 'TV', + collectionType: 'tvshows', + type: 'CollectionFolder', + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('listItems supports search and formats title', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input) => { + assert.match(String(input), /SearchTerm=planet/); + return new Response( + JSON.stringify({ + Items: [ + { + Id: 'ep-1', + Name: 'Pilot', + Type: 'Episode', + SeriesName: 'Space Show', + ParentIndexNumber: 1, + IndexNumber: 2, + }, + ], + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const items = await listItems( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { + libraryId: 'lib-1', + searchTerm: 'planet', + limit: 25, + }, + ); + assert.equal(items[0]!.title, 'Space Show S01E02 Pilot'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan chooses direct play when allowed', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-1', + Name: 'Movie A', + UserData: { PlaybackPositionTicks: 20_000_000 }, + MediaSources: [ + { + Id: 'ms-1', + Container: 'mkv', + SupportsDirectStream: true, + SupportsTranscoding: true, + DefaultAudioStreamIndex: 1, + DefaultSubtitleStreamIndex: 3, + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const plan = await resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { + enabled: true, + directPlayPreferred: true, + directPlayContainers: ['mkv'], + }, + { itemId: 'movie-1' }, + ); + + assert.equal(plan.mode, 'direct'); + assert.match(plan.url, /Videos\/movie-1\/stream\?/); + assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/); + assert.equal(plan.subtitleStreamIndex, null); + assert.equal(ticksToSeconds(plan.startTimeTicks), 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-2', + Name: 'Movie B', + UserData: { PlaybackPositionTicks: 10_000_000 }, + MediaSources: [ + { + Id: 'ms-2', + Container: 'mkv', + SupportsDirectStream: true, + SupportsTranscoding: true, + DefaultAudioStreamIndex: 4, + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const plan = await resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { + enabled: true, + directPlayPreferred: false, + directPlayContainers: ['mkv'], + transcodeVideoCodec: 'h264', + }, + { itemId: 'movie-2' }, + ); + + assert.equal(plan.mode, 'transcode'); + const url = new URL(plan.url); + assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/); + assert.equal(url.searchParams.get('api_key'), 'token'); + assert.equal(url.searchParams.get('AudioStreamIndex'), '4'); + assert.equal(url.searchParams.get('StartTimeTicks'), '10000000'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan falls back to transcode when direct container not allowed', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-3', + Name: 'Movie C', + UserData: { PlaybackPositionTicks: 0 }, + MediaSources: [ + { + Id: 'ms-3', + Container: 'avi', + SupportsDirectStream: true, + SupportsTranscoding: true, + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const plan = await resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { + enabled: true, + directPlayPreferred: true, + directPlayContainers: ['mkv', 'mp4'], + transcodeVideoCodec: 'h265', + }, + { + itemId: 'movie-3', + audioStreamIndex: 2, + subtitleStreamIndex: 5, + }, + ); + + assert.equal(plan.mode, 'transcode'); + const url = new URL(plan.url); + assert.equal(url.searchParams.get('VideoCodec'), 'h265'); + assert.equal(url.searchParams.get('AudioStreamIndex'), '2'); + assert.equal(url.searchParams.get('SubtitleStreamIndex'), '5'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('listSubtitleTracks returns all subtitle streams with delivery urls', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-1', + MediaSources: [ + { + Id: 'ms-1', + MediaStreams: [ + { + Type: 'Subtitle', + Index: 2, + Language: 'eng', + DisplayTitle: 'English Full', + IsDefault: true, + DeliveryMethod: 'Embed', + }, + { + Type: 'Subtitle', + Index: 3, + Language: 'jpn', + Title: 'Japanese Signs', + IsForced: true, + IsExternal: true, + DeliveryMethod: 'External', + DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt', + IsExternalUrl: false, + }, + { + Type: 'Subtitle', + Index: 4, + Language: 'spa', + Title: 'Spanish External', + DeliveryMethod: 'External', + DeliveryUrl: 'https://cdn.example.com/subs.srt', + IsExternalUrl: true, + }, + ], + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const tracks = await listSubtitleTracks( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + 'movie-1', + ); + assert.equal(tracks.length, 3); + assert.deepEqual( + tracks.map((track) => track.index), + [2, 3, 4], + ); + assert.equal( + tracks[0]!.deliveryUrl, + 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token', + ); + assert.equal( + tracks[1]!.deliveryUrl, + 'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token', + ); + assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan falls back to transcode when direct play blocked', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-1', + Name: 'Movie A', + UserData: { PlaybackPositionTicks: 0 }, + MediaSources: [ + { + Id: 'ms-1', + Container: 'avi', + SupportsDirectStream: true, + SupportsTranscoding: true, + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const plan = await resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { + enabled: true, + directPlayPreferred: true, + directPlayContainers: ['mkv', 'mp4'], + transcodeVideoCodec: 'h265', + }, + { itemId: 'movie-1' }, + ); + + assert.equal(plan.mode, 'transcode'); + assert.match(plan.url, /master\.m3u8\?/); + assert.match(plan.url, /VideoCodec=h265/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan reuses server transcoding url and appends missing params', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-4', + Name: 'Movie D', + UserData: { PlaybackPositionTicks: 50_000_000 }, + MediaSources: [ + { + Id: 'ms-4', + Container: 'mkv', + SupportsDirectStream: false, + SupportsTranscoding: true, + DefaultAudioStreamIndex: 3, + TranscodingUrl: '/Videos/movie-4/master.m3u8?VideoCodec=hevc', + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const plan = await resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { + enabled: true, + directPlayPreferred: true, + }, + { + itemId: 'movie-4', + subtitleStreamIndex: 8, + }, + ); + + assert.equal(plan.mode, 'transcode'); + const url = new URL(plan.url); + assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/); + assert.equal(url.searchParams.get('VideoCodec'), 'hevc'); + assert.equal(url.searchParams.get('api_key'), 'token'); + assert.equal(url.searchParams.get('AudioStreamIndex'), '3'); + assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8'); + assert.equal(url.searchParams.get('StartTimeTicks'), '50000000'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan preserves episode metadata, stream selection, and resume ticks', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'ep-2', + Type: 'Episode', + Name: 'A New Hope', + SeriesName: 'Galaxy Quest', + ParentIndexNumber: 2, + IndexNumber: 7, + UserData: { PlaybackPositionTicks: 35_000_000 }, + MediaSources: [ + { + Id: 'ms-ep-2', + Container: 'mkv', + SupportsDirectStream: true, + SupportsTranscoding: true, + DefaultAudioStreamIndex: 6, + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + const plan = await resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { + enabled: true, + directPlayPreferred: true, + directPlayContainers: ['mkv'], + }, + { + itemId: 'ep-2', + subtitleStreamIndex: 9, + }, + ); + + assert.equal(plan.mode, 'direct'); + assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope'); + assert.equal(plan.audioStreamIndex, 6); + assert.equal(plan.subtitleStreamIndex, 9); + assert.equal(plan.startTimeTicks, 35_000_000); + const url = new URL(plan.url); + assert.equal(url.searchParams.get('AudioStreamIndex'), '6'); + assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9'); + assert.equal(url.searchParams.get('StartTimeTicks'), '35000000'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('listSubtitleTracks falls back from PlaybackInfo to item media sources', async () => { + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = (async (input) => { + requestCount += 1; + if (requestCount === 1) { + assert.match(String(input), /\/Items\/movie-fallback\/PlaybackInfo\?/); + return new Response('Playback info unavailable', { status: 500 }); + } + return new Response( + JSON.stringify({ + Id: 'movie-fallback', + MediaSources: [ + { + Id: 'ms-fallback', + MediaStreams: [ + { + Type: 'Subtitle', + Index: 11, + Language: 'eng', + Title: 'English', + DeliveryMethod: 'External', + DeliveryUrl: '/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt', + IsExternalUrl: false, + }, + ], + }, + ], + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const tracks = await listSubtitleTracks( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + 'movie-fallback', + ); + assert.equal(requestCount, 2); + assert.equal(tracks.length, 1); + assert.equal(tracks[0]!.index, 11); + assert.equal( + tracks[0]!.deliveryUrl, + 'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token', + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('authenticateWithPassword surfaces invalid credentials and server status failures', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' })) as typeof fetch; + + try { + await assert.rejects( + () => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'badpw', clientInfo), + /Invalid Jellyfin username or password\./, + ); + } finally { + globalThis.fetch = originalFetch; + } + + globalThis.fetch = (async () => + new Response('Oops', { status: 500, statusText: 'Internal Server Error' })) as typeof fetch; + try { + await assert.rejects( + () => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'pw', clientInfo), + /Jellyfin login failed \(500 Internal Server Error\)\./, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('listLibraries surfaces token-expiry auth errors', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('Forbidden', { status: 403, statusText: 'Forbidden' })) as typeof fetch; + + try { + await assert.rejects( + () => + listLibraries( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'expired', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + ), + /Jellyfin authentication failed \(invalid or expired token\)\./, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-empty', + Name: 'Movie Empty', + UserData: { PlaybackPositionTicks: 0 }, + MediaSources: [], + }), + { status: 200 }, + )) as typeof fetch; + + try { + await assert.rejects( + () => + resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { enabled: true }, + { itemId: 'movie-empty' }, + ), + /No playable media source found for Jellyfin item\./, + ); + } finally { + globalThis.fetch = originalFetch; + } + + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + Id: 'movie-no-stream', + Name: 'Movie No Stream', + UserData: { PlaybackPositionTicks: 0 }, + MediaSources: [ + { + Id: 'ms-none', + Container: 'avi', + SupportsDirectStream: false, + SupportsTranscoding: false, + }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + try { + await assert.rejects( + () => + resolvePlaybackPlan( + { + serverUrl: 'http://jellyfin.local', + accessToken: 'token', + userId: 'u1', + username: 'kyle', + }, + clientInfo, + { enabled: true }, + { itemId: 'movie-no-stream' }, + ), + /Jellyfin item cannot be streamed by direct play or transcoding\./, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/core/services/jellyfin.ts b/src/core/services/jellyfin.ts new file mode 100644 index 0000000..1c2a015 --- /dev/null +++ b/src/core/services/jellyfin.ts @@ -0,0 +1,523 @@ +import { JellyfinConfig } from '../../types'; + +const JELLYFIN_TICKS_PER_SECOND = 10_000_000; + +export interface JellyfinAuthSession { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +} + +export interface JellyfinLibrary { + id: string; + name: string; + collectionType: string; + type: string; +} + +export interface JellyfinPlaybackSelection { + itemId: string; + audioStreamIndex?: number; + subtitleStreamIndex?: number; +} + +export interface JellyfinPlaybackPlan { + mode: 'direct' | 'transcode'; + url: string; + title: string; + startTimeTicks: number; + audioStreamIndex: number | null; + subtitleStreamIndex: number | null; +} + +export interface JellyfinSubtitleTrack { + index: number; + language: string; + title: string; + codec: string; + isDefault: boolean; + isForced: boolean; + isExternal: boolean; + deliveryMethod: string; + deliveryUrl: string | null; +} + +interface JellyfinAuthResponse { + AccessToken?: string; + User?: { Id?: string; Name?: string }; +} + +interface JellyfinMediaStream { + Index?: number; + Type?: string; + IsExternal?: boolean; + IsDefault?: boolean; + IsForced?: boolean; + Language?: string; + DisplayTitle?: string; + Title?: string; + Codec?: string; + DeliveryMethod?: string; + DeliveryUrl?: string; + IsExternalUrl?: boolean; +} + +interface JellyfinMediaSource { + Id?: string; + Container?: string; + SupportsDirectStream?: boolean; + SupportsTranscoding?: boolean; + TranscodingUrl?: string; + DefaultAudioStreamIndex?: number; + DefaultSubtitleStreamIndex?: number; + MediaStreams?: JellyfinMediaStream[]; + LiveStreamId?: string; +} + +interface JellyfinItemUserData { + PlaybackPositionTicks?: number; +} + +interface JellyfinItem { + Id?: string; + Name?: string; + Type?: string; + SeriesName?: string; + ParentIndexNumber?: number; + IndexNumber?: number; + UserData?: JellyfinItemUserData; + MediaSources?: JellyfinMediaSource[]; +} + +interface JellyfinItemsResponse { + Items?: JellyfinItem[]; +} + +interface JellyfinPlaybackInfoResponse { + MediaSources?: JellyfinMediaSource[]; +} + +export interface JellyfinClientInfo { + deviceId: string; + clientName: string; + clientVersion: string; +} + +function normalizeBaseUrl(value: string): string { + return value.trim().replace(/\/+$/, ''); +} + +function ensureString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function asIntegerOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) ? value : null; +} + +function resolveDeliveryUrl( + session: JellyfinAuthSession, + stream: JellyfinMediaStream, + itemId: string, + mediaSourceId: string, +): string | null { + const deliveryUrl = ensureString(stream.DeliveryUrl).trim(); + if (deliveryUrl) { + if (stream.IsExternalUrl === true) return deliveryUrl; + const resolved = new URL(deliveryUrl, `${session.serverUrl}/`); + if (!resolved.searchParams.has('api_key')) { + resolved.searchParams.set('api_key', session.accessToken); + } + return resolved.toString(); + } + + const streamIndex = asIntegerOrNull(stream.Index); + if (streamIndex === null || !itemId || !mediaSourceId) return null; + const codec = ensureString(stream.Codec).toLowerCase(); + const ext = + codec === 'subrip' + ? 'srt' + : codec === 'webvtt' + ? 'vtt' + : codec === 'vtt' + ? 'vtt' + : codec === 'ass' + ? 'ass' + : codec === 'ssa' + ? 'ssa' + : 'srt'; + const fallback = new URL( + `/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`, + `${session.serverUrl}/`, + ); + if (!fallback.searchParams.has('api_key')) { + fallback.searchParams.set('api_key', session.accessToken); + } + return fallback.toString(); +} + +function createAuthorizationHeader(client: JellyfinClientInfo, token?: string): string { + const parts = [ + `Client="${client.clientName}"`, + `Device="${client.clientName}"`, + `DeviceId="${client.deviceId}"`, + `Version="${client.clientVersion}"`, + ]; + if (token) parts.push(`Token="${token}"`); + return `MediaBrowser ${parts.join(', ')}`; +} + +async function jellyfinRequestJson( + path: string, + init: RequestInit, + session: JellyfinAuthSession, + client: JellyfinClientInfo, +): Promise { + const headers = new Headers(init.headers ?? {}); + headers.set('Content-Type', 'application/json'); + headers.set('Authorization', createAuthorizationHeader(client, session.accessToken)); + headers.set('X-Emby-Token', session.accessToken); + + const response = await fetch(`${session.serverUrl}${path}`, { + ...init, + headers, + }); + + if (response.status === 401 || response.status === 403) { + throw new Error('Jellyfin authentication failed (invalid or expired token).'); + } + if (!response.ok) { + throw new Error(`Jellyfin request failed (${response.status} ${response.statusText}).`); + } + return response.json() as Promise; +} + +function createDirectPlayUrl( + session: JellyfinAuthSession, + itemId: string, + mediaSource: JellyfinMediaSource, + plan: JellyfinPlaybackPlan, +): string { + const query = new URLSearchParams({ + static: 'true', + api_key: session.accessToken, + MediaSourceId: ensureString(mediaSource.Id), + }); + if (mediaSource.LiveStreamId) { + query.set('LiveStreamId', mediaSource.LiveStreamId); + } + if (plan.audioStreamIndex !== null) { + query.set('AudioStreamIndex', String(plan.audioStreamIndex)); + } + if (plan.subtitleStreamIndex !== null) { + query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex)); + } + if (plan.startTimeTicks > 0) { + query.set('StartTimeTicks', String(plan.startTimeTicks)); + } + return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`; +} + +function createTranscodeUrl( + session: JellyfinAuthSession, + itemId: string, + mediaSource: JellyfinMediaSource, + plan: JellyfinPlaybackPlan, + config: JellyfinConfig, +): string { + if (mediaSource.TranscodingUrl) { + const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`); + if (!url.searchParams.has('api_key')) { + url.searchParams.set('api_key', session.accessToken); + } + if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) { + url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex)); + } + if (!url.searchParams.has('SubtitleStreamIndex') && plan.subtitleStreamIndex !== null) { + url.searchParams.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex)); + } + if (!url.searchParams.has('StartTimeTicks') && plan.startTimeTicks > 0) { + url.searchParams.set('StartTimeTicks', String(plan.startTimeTicks)); + } + return url.toString(); + } + + const query = new URLSearchParams({ + api_key: session.accessToken, + MediaSourceId: ensureString(mediaSource.Id), + VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'), + TranscodingContainer: 'ts', + }); + if (plan.audioStreamIndex !== null) { + query.set('AudioStreamIndex', String(plan.audioStreamIndex)); + } + if (plan.subtitleStreamIndex !== null) { + query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex)); + } + if (plan.startTimeTicks > 0) { + query.set('StartTimeTicks', String(plan.startTimeTicks)); + } + return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${query.toString()}`; +} + +function getStreamDefaults(source: JellyfinMediaSource): { + audioStreamIndex: number | null; +} { + const audioDefault = asIntegerOrNull(source.DefaultAudioStreamIndex); + if (audioDefault !== null) return { audioStreamIndex: audioDefault }; + + const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : []; + const defaultAudio = streams.find( + (stream) => stream.Type === 'Audio' && stream.IsDefault === true, + ); + return { + audioStreamIndex: asIntegerOrNull(defaultAudio?.Index), + }; +} + +function getDisplayTitle(item: JellyfinItem): string { + if (item.Type === 'Episode') { + const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0; + const episode = asIntegerOrNull(item.IndexNumber) ?? 0; + const prefix = item.SeriesName ? `${item.SeriesName} ` : ''; + return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim(); + } + return ensureString(item.Name).trim() || 'Jellyfin Item'; +} + +function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean { + if (source.SupportsDirectStream !== true) return false; + if (config.directPlayPreferred === false) return false; + + const container = ensureString(source.Container).toLowerCase(); + const allowlist = Array.isArray(config.directPlayContainers) + ? config.directPlayContainers.map((entry) => entry.toLowerCase()) + : []; + if (!container || allowlist.length === 0) return true; + return allowlist.includes(container); +} + +export async function authenticateWithPassword( + serverUrl: string, + username: string, + password: string, + client: JellyfinClientInfo, +): Promise { + const normalizedUrl = normalizeBaseUrl(serverUrl); + if (!normalizedUrl) throw new Error('Missing Jellyfin server URL.'); + if (!username.trim()) throw new Error('Missing Jellyfin username.'); + if (!password) throw new Error('Missing Jellyfin password.'); + + const response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: createAuthorizationHeader(client), + }, + body: JSON.stringify({ + Username: username, + Pw: password, + }), + }); + + if (response.status === 401 || response.status === 403) { + throw new Error('Invalid Jellyfin username or password.'); + } + if (!response.ok) { + throw new Error(`Jellyfin login failed (${response.status} ${response.statusText}).`); + } + + const payload = (await response.json()) as JellyfinAuthResponse; + const accessToken = ensureString(payload.AccessToken); + const userId = ensureString(payload.User?.Id); + if (!accessToken || !userId) { + throw new Error('Jellyfin login response missing token/user.'); + } + + return { + serverUrl: normalizedUrl, + accessToken, + userId, + username: username.trim(), + }; +} + +export async function listLibraries( + session: JellyfinAuthSession, + client: JellyfinClientInfo, +): Promise { + const payload = await jellyfinRequestJson( + `/Users/${session.userId}/Views`, + { method: 'GET' }, + session, + client, + ); + + const items = Array.isArray(payload.Items) ? payload.Items : []; + return items.map((item) => ({ + id: ensureString(item.Id), + name: ensureString(item.Name, 'Untitled'), + collectionType: ensureString((item as { CollectionType?: string }).CollectionType), + type: ensureString(item.Type), + })); +} + +export async function listItems( + session: JellyfinAuthSession, + client: JellyfinClientInfo, + options: { + libraryId: string; + searchTerm?: string; + limit?: number; + }, +): Promise> { + if (!options.libraryId) throw new Error('Missing Jellyfin library id.'); + + const query = new URLSearchParams({ + ParentId: options.libraryId, + Recursive: 'true', + IncludeItemTypes: 'Movie,Episode,Audio', + Fields: 'MediaSources,UserData', + SortBy: 'SortName', + SortOrder: 'Ascending', + Limit: String(options.limit ?? 100), + }); + if (options.searchTerm?.trim()) { + query.set('SearchTerm', options.searchTerm.trim()); + } + + const payload = await jellyfinRequestJson( + `/Users/${session.userId}/Items?${query.toString()}`, + { method: 'GET' }, + session, + client, + ); + const items = Array.isArray(payload.Items) ? payload.Items : []; + return items.map((item) => ({ + id: ensureString(item.Id), + name: ensureString(item.Name), + type: ensureString(item.Type), + title: getDisplayTitle(item), + })); +} + +export async function listSubtitleTracks( + session: JellyfinAuthSession, + client: JellyfinClientInfo, + itemId: string, +): Promise { + if (!itemId.trim()) throw new Error('Missing Jellyfin item id.'); + let source: JellyfinMediaSource | undefined; + + try { + const playbackInfo = await jellyfinRequestJson( + `/Items/${itemId}/PlaybackInfo?UserId=${encodeURIComponent(session.userId)}`, + { + method: 'POST', + body: JSON.stringify({ UserId: session.userId }), + }, + session, + client, + ); + source = Array.isArray(playbackInfo.MediaSources) ? playbackInfo.MediaSources[0] : undefined; + } catch {} + + if (!source) { + const item = await jellyfinRequestJson( + `/Users/${session.userId}/Items/${itemId}?Fields=MediaSources`, + { method: 'GET' }, + session, + client, + ); + source = Array.isArray(item.MediaSources) ? item.MediaSources[0] : undefined; + } + + if (!source) { + throw new Error('No playable media source found for Jellyfin item.'); + } + const mediaSourceId = ensureString(source.Id); + + const streams = Array.isArray(source.MediaStreams) ? source.MediaStreams : []; + const tracks: JellyfinSubtitleTrack[] = []; + for (const stream of streams) { + if (stream.Type !== 'Subtitle') continue; + const index = asIntegerOrNull(stream.Index); + if (index === null) continue; + tracks.push({ + index, + language: ensureString(stream.Language), + title: ensureString(stream.DisplayTitle || stream.Title), + codec: ensureString(stream.Codec), + isDefault: stream.IsDefault === true, + isForced: stream.IsForced === true, + isExternal: stream.IsExternal === true, + deliveryMethod: ensureString(stream.DeliveryMethod), + deliveryUrl: resolveDeliveryUrl(session, stream, itemId, mediaSourceId), + }); + } + return tracks; +} + +export async function resolvePlaybackPlan( + session: JellyfinAuthSession, + client: JellyfinClientInfo, + config: JellyfinConfig, + selection: JellyfinPlaybackSelection, +): Promise { + if (!selection.itemId) { + throw new Error('Missing Jellyfin item id.'); + } + + const item = await jellyfinRequestJson( + `/Users/${session.userId}/Items/${selection.itemId}?Fields=MediaSources,UserData`, + { method: 'GET' }, + session, + client, + ); + const source = Array.isArray(item.MediaSources) ? item.MediaSources[0] : undefined; + if (!source) { + throw new Error('No playable media source found for Jellyfin item.'); + } + + const defaults = getStreamDefaults(source); + const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null; + const subtitleStreamIndex = selection.subtitleStreamIndex ?? null; + const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0); + const basePlan: JellyfinPlaybackPlan = { + mode: 'transcode', + url: '', + title: getDisplayTitle(item), + startTimeTicks, + audioStreamIndex, + subtitleStreamIndex, + }; + + if (shouldPreferDirectPlay(source, config)) { + return { + ...basePlan, + mode: 'direct', + url: createDirectPlayUrl(session, selection.itemId, source, basePlan), + }; + } + if (source.SupportsTranscoding !== true && source.SupportsDirectStream === true) { + return { + ...basePlan, + mode: 'direct', + url: createDirectPlayUrl(session, selection.itemId, source, basePlan), + }; + } + if (source.SupportsTranscoding !== true) { + throw new Error('Jellyfin item cannot be streamed by direct play or transcoding.'); + } + + return { + ...basePlan, + mode: 'transcode', + url: createTranscodeUrl(session, selection.itemId, source, basePlan, config), + }; +} + +export function ticksToSeconds(ticks: number): number { + return Math.max(0, Math.floor(ticks / JELLYFIN_TICKS_PER_SECOND)); +} diff --git a/src/core/services/jimaku.ts b/src/core/services/jimaku.ts new file mode 100644 index 0000000..6dce052 --- /dev/null +++ b/src/core/services/jimaku.ts @@ -0,0 +1,71 @@ +import { JimakuApiResponse, JimakuConfig, JimakuLanguagePreference } from '../../types'; +import { + jimakuFetchJson as jimakuFetchJsonRequest, + resolveJimakuApiKey as resolveJimakuApiKeyFromConfig, +} from '../../jimaku/utils'; + +export function getJimakuConfig(getResolvedConfig: () => { jimaku?: JimakuConfig }): JimakuConfig { + const config = getResolvedConfig(); + return config.jimaku ?? {}; +} + +export function getJimakuBaseUrl( + getResolvedConfig: () => { jimaku?: JimakuConfig }, + defaultBaseUrl: string, +): string { + const config = getJimakuConfig(getResolvedConfig); + return config.apiBaseUrl || defaultBaseUrl; +} + +export function getJimakuLanguagePreference( + getResolvedConfig: () => { jimaku?: JimakuConfig }, + defaultPreference: JimakuLanguagePreference, +): JimakuLanguagePreference { + const config = getJimakuConfig(getResolvedConfig); + return config.languagePreference || defaultPreference; +} + +export function getJimakuMaxEntryResults( + getResolvedConfig: () => { jimaku?: JimakuConfig }, + defaultValue: number, +): number { + const config = getJimakuConfig(getResolvedConfig); + const value = config.maxEntryResults; + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + return defaultValue; +} + +export async function resolveJimakuApiKey( + getResolvedConfig: () => { jimaku?: JimakuConfig }, +): Promise { + return resolveJimakuApiKeyFromConfig(getJimakuConfig(getResolvedConfig)); +} + +export async function jimakuFetchJson( + endpoint: string, + query: Record = {}, + options: { + getResolvedConfig: () => { jimaku?: JimakuConfig }; + defaultBaseUrl: string; + defaultMaxEntryResults: number; + defaultLanguagePreference: JimakuLanguagePreference; + }, +): Promise> { + const apiKey = await resolveJimakuApiKey(options.getResolvedConfig); + if (!apiKey) { + return { + ok: false, + error: { + error: 'Jimaku API key not set. Configure jimaku.apiKey or jimaku.apiKeyCommand.', + code: 401, + }, + }; + } + + return jimakuFetchJsonRequest(endpoint, query, { + baseUrl: getJimakuBaseUrl(options.getResolvedConfig, options.defaultBaseUrl), + apiKey, + }); +} diff --git a/src/core/services/jlpt-token-filter.ts b/src/core/services/jlpt-token-filter.ts new file mode 100644 index 0000000..c0442cd --- /dev/null +++ b/src/core/services/jlpt-token-filter.ts @@ -0,0 +1,85 @@ +export type JlptIgnoredPos1Entry = { + pos1: string; + reason: string; +}; + +// Token-level lexical terms excluded from JLPT highlighting. +// These are not tied to POS and act as a safety layer for non-dictionary cases. +export const JLPT_EXCLUDED_TERMS = new Set([ + 'この', + 'その', + 'あの', + 'どの', + 'これ', + 'それ', + 'あれ', + 'どれ', + 'ここ', + 'そこ', + 'あそこ', + 'どこ', + 'こと', + 'ああ', + 'ええ', + 'うう', + 'おお', + 'はは', + 'へえ', + 'ふう', + 'ほう', +]); + +export function shouldIgnoreJlptByTerm(term: string): boolean { + return JLPT_EXCLUDED_TERMS.has(term); +} + +// MeCab POS1 categories that should be excluded from JLPT-level token tagging. +// These are filtered out because they are typically functional or non-lexical words. +export const JLPT_IGNORED_MECAB_POS1_ENTRIES = [ + { + pos1: '助詞', + reason: 'Particles (ko/kara/nagara etc.): mostly grammatical glue, not independent vocabulary.', + }, + { + pos1: '助動詞', + reason: 'Auxiliary verbs (past tense, politeness, modality): grammar helpers.', + }, + { + pos1: '記号', + reason: 'Symbols/punctuation and symbols-like tokens.', + }, + { + pos1: '補助記号', + reason: 'Auxiliary symbols (e.g. bracket-like or markup tokens).', + }, + { + pos1: '連体詞', + reason: 'Adnominal forms (e.g. demonstratives like "この").', + }, + { + pos1: '感動詞', + reason: 'Interjections/onomatopoeia-style exclamations.', + }, + { + pos1: '接続詞', + reason: 'Conjunctions that connect clauses, usually not target vocab items.', + }, + { + pos1: '接頭詞', + reason: 'Prefixes/prefix-like grammatical elements.', + }, +] as const satisfies readonly JlptIgnoredPos1Entry[]; + +export const JLPT_IGNORED_MECAB_POS1 = JLPT_IGNORED_MECAB_POS1_ENTRIES.map((entry) => entry.pos1); + +export const JLPT_IGNORED_MECAB_POS1_LIST: readonly string[] = JLPT_IGNORED_MECAB_POS1; + +const JLPT_IGNORED_MECAB_POS1_SET = new Set(JLPT_IGNORED_MECAB_POS1_LIST); + +export function getIgnoredPos1Entries(): readonly JlptIgnoredPos1Entry[] { + return JLPT_IGNORED_MECAB_POS1_ENTRIES; +} + +export function shouldIgnoreJlptForMecabPos1(pos1: string): boolean { + return JLPT_IGNORED_MECAB_POS1_SET.has(pos1); +} diff --git a/src/core/services/jlpt-vocab.ts b/src/core/services/jlpt-vocab.ts new file mode 100644 index 0000000..7afa3a7 --- /dev/null +++ b/src/core/services/jlpt-vocab.ts @@ -0,0 +1,179 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { JlptLevel } from '../../types'; + +export interface JlptVocabLookupOptions { + searchPaths: string[]; + log: (message: string) => void; +} + +const JLPT_BANK_FILES: { level: JlptLevel; filename: string }[] = [ + { level: 'N1', filename: 'term_meta_bank_1.json' }, + { level: 'N2', filename: 'term_meta_bank_2.json' }, + { level: 'N3', filename: 'term_meta_bank_3.json' }, + { level: 'N4', filename: 'term_meta_bank_4.json' }, + { level: 'N5', filename: 'term_meta_bank_5.json' }, +]; +const JLPT_LEVEL_PRECEDENCE: Record = { + N1: 5, + N2: 4, + N3: 3, + N4: 2, + N5: 1, +}; + +const NOOP_LOOKUP = (): null => null; + +function normalizeJlptTerm(value: string): string { + return value.trim(); +} + +function hasFrequencyDisplayValue(meta: unknown): boolean { + if (!meta || typeof meta !== 'object') return false; + const frequency = (meta as { frequency?: unknown }).frequency; + if (!frequency || typeof frequency !== 'object') return false; + return Object.prototype.hasOwnProperty.call(frequency as Record, 'displayValue'); +} + +function addEntriesToMap( + rawEntries: unknown, + level: JlptLevel, + terms: Map, + log: (message: string) => void, +): void { + const shouldUpdateLevel = ( + existingLevel: JlptLevel | undefined, + incomingLevel: JlptLevel, + ): boolean => + existingLevel === undefined || + JLPT_LEVEL_PRECEDENCE[incomingLevel] > JLPT_LEVEL_PRECEDENCE[existingLevel]; + + if (!Array.isArray(rawEntries)) { + return; + } + + for (const rawEntry of rawEntries) { + if (!Array.isArray(rawEntry)) { + continue; + } + + const [term, _entryId, meta] = rawEntry as [unknown, unknown, unknown]; + if (typeof term !== 'string') { + continue; + } + + const normalizedTerm = normalizeJlptTerm(term); + if (!normalizedTerm) { + continue; + } + + if (!hasFrequencyDisplayValue(meta)) { + continue; + } + + const existingLevel = terms.get(normalizedTerm); + if (shouldUpdateLevel(existingLevel, level)) { + terms.set(normalizedTerm, level); + continue; + } + + log( + `JLPT dictionary already has ${normalizedTerm} as ${existingLevel}; keeping that level instead of ${level}`, + ); + } +} + +function collectDictionaryFromPath( + dictionaryPath: string, + log: (message: string) => void, +): Map { + const terms = new Map(); + + for (const bank of JLPT_BANK_FILES) { + const bankPath = path.join(dictionaryPath, bank.filename); + if (!fs.existsSync(bankPath)) { + log(`JLPT bank file missing for ${bank.level}: ${bankPath}`); + continue; + } + + let rawText: string; + try { + rawText = fs.readFileSync(bankPath, 'utf-8'); + } catch { + log(`Failed to read JLPT bank file ${bankPath}`); + continue; + } + + let rawEntries: unknown; + try { + rawEntries = JSON.parse(rawText) as unknown; + } catch { + log(`Failed to parse JLPT bank file as JSON: ${bankPath}`); + continue; + } + + if (!Array.isArray(rawEntries)) { + log(`JLPT bank file has unsupported format (expected JSON array): ${bankPath}`); + continue; + } + + const beforeSize = terms.size; + addEntriesToMap(rawEntries, bank.level, terms, log); + if (terms.size === beforeSize) { + log(`JLPT bank file contained no extractable entries: ${bankPath}`); + } + } + + return terms; +} + +export async function createJlptVocabularyLookup( + options: JlptVocabLookupOptions, +): Promise<(term: string) => JlptLevel | null> { + const attemptedPaths: string[] = []; + let foundDictionaryPathCount = 0; + let foundBankCount = 0; + const resolvedBanks: string[] = []; + for (const dictionaryPath of options.searchPaths) { + attemptedPaths.push(dictionaryPath); + if (!fs.existsSync(dictionaryPath)) { + continue; + } + + if (!fs.statSync(dictionaryPath).isDirectory()) { + continue; + } + + foundDictionaryPathCount += 1; + + const terms = collectDictionaryFromPath(dictionaryPath, options.log); + if (terms.size > 0) { + resolvedBanks.push(dictionaryPath); + foundBankCount += 1; + options.log(`JLPT dictionary loaded from ${dictionaryPath} (${terms.size} entries)`); + return (term: string): JlptLevel | null => { + if (!term) return null; + const normalized = normalizeJlptTerm(term); + return normalized ? (terms.get(normalized) ?? null) : null; + }; + } + + options.log( + `JLPT dictionary directory exists but contains no readable term_meta_bank_*.json files: ${dictionaryPath}`, + ); + } + + options.log( + `JLPT dictionary not found. Searched ${attemptedPaths.length} candidate path(s): ${attemptedPaths.join(', ')}`, + ); + if (foundDictionaryPathCount > 0 && foundBankCount === 0) { + options.log( + 'JLPT dictionary directories found, but none contained valid term_meta_bank_*.json files.', + ); + } + if (resolvedBanks.length > 0 && foundBankCount > 0) { + options.log(`JLPT dictionary search matched path(s): ${resolvedBanks.join(', ')}`); + } + return NOOP_LOOKUP; +} diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts new file mode 100644 index 0000000..19f54d5 --- /dev/null +++ b/src/core/services/mining.test.ts @@ -0,0 +1,209 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + copyCurrentSubtitle, + handleMineSentenceDigit, + handleMultiCopyDigit, + mineSentenceCard, +} from './mining'; + +test('copyCurrentSubtitle reports tracker and subtitle guards', () => { + const osd: string[] = []; + const copied: string[] = []; + + copyCurrentSubtitle({ + subtitleTimingTracker: null, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + assert.equal(osd.at(-1), 'Subtitle tracker not available'); + + copyCurrentSubtitle({ + subtitleTimingTracker: { + getRecentBlocks: () => [], + getCurrentSubtitle: () => null, + findTiming: () => null, + }, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + assert.equal(osd.at(-1), 'No current subtitle'); + assert.deepEqual(copied, []); +}); + +test('copyCurrentSubtitle copies current subtitle text', () => { + const osd: string[] = []; + const copied: string[] = []; + + copyCurrentSubtitle({ + subtitleTimingTracker: { + getRecentBlocks: () => [], + getCurrentSubtitle: () => 'hello world', + findTiming: () => null, + }, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + + assert.deepEqual(copied, ['hello world']); + assert.equal(osd.at(-1), 'Copied subtitle'); +}); + +test('mineSentenceCard handles missing integration and disconnected mpv', async () => { + const osd: string[] = []; + + assert.equal( + await mineSentenceCard({ + ankiIntegration: null, + mpvClient: null, + showMpvOsd: (text) => osd.push(text), + }), + false, + ); + assert.equal(osd.at(-1), 'AnkiConnect integration not enabled'); + + assert.equal( + await mineSentenceCard({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async () => false, + }, + mpvClient: { + connected: false, + currentSubText: 'line', + currentSubStart: 1, + currentSubEnd: 2, + }, + showMpvOsd: (text) => osd.push(text), + }), + false, + ); + + assert.equal(osd.at(-1), 'MPV not connected'); +}); + +test('mineSentenceCard creates sentence card from mpv subtitle state', async () => { + const created: Array<{ + sentence: string; + startTime: number; + endTime: number; + secondarySub?: string; + }> = []; + + const createdCard = await mineSentenceCard({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, startTime, endTime, secondarySub) => { + created.push({ sentence, startTime, endTime, secondarySub }); + return true; + }, + }, + mpvClient: { + connected: true, + currentSubText: 'subtitle line', + currentSubStart: 10, + currentSubEnd: 12, + currentSecondarySubText: 'secondary line', + }, + showMpvOsd: () => {}, + }); + + assert.equal(createdCard, true); + assert.deepEqual(created, [ + { + sentence: 'subtitle line', + startTime: 10, + endTime: 12, + secondarySub: 'secondary line', + }, + ]); +}); + +test('handleMultiCopyDigit copies available history and reports truncation', () => { + const osd: string[] = []; + const copied: string[] = []; + + handleMultiCopyDigit(5, { + subtitleTimingTracker: { + getRecentBlocks: (count) => ['a', 'b'].slice(0, count), + getCurrentSubtitle: () => null, + findTiming: () => null, + }, + writeClipboardText: (text) => copied.push(text), + showMpvOsd: (text) => osd.push(text), + }); + + assert.deepEqual(copied, ['a\n\nb']); + assert.equal(osd.at(-1), 'Only 2 lines available, copied 2'); +}); + +test('handleMineSentenceDigit reports async create failures', async () => { + const osd: string[] = []; + const logs: Array<{ message: string; err: unknown }> = []; + let cardsMined = 0; + + handleMineSentenceDigit(2, { + subtitleTimingTracker: { + getRecentBlocks: () => ['one', 'two'], + getCurrentSubtitle: () => null, + findTiming: (text) => + text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 }, + }, + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async () => { + throw new Error('mine boom'); + }, + }, + getCurrentSecondarySubText: () => 'sub2', + showMpvOsd: (text) => osd.push(text), + logError: (message, err) => logs.push({ message, err }), + onCardsMined: (count) => { + cardsMined += count; + }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(logs.length, 1); + assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:'); + assert.equal((logs[0]?.err as Error).message, 'mine boom'); + assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom'))); + assert.equal(cardsMined, 0); +}); + +test('handleMineSentenceDigit increments successful card count', async () => { + const osd: string[] = []; + let cardsMined = 0; + + handleMineSentenceDigit(2, { + subtitleTimingTracker: { + getRecentBlocks: () => ['one', 'two'], + getCurrentSubtitle: () => null, + findTiming: (text) => + text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 }, + }, + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async () => true, + }, + getCurrentSecondarySubText: () => 'sub2', + showMpvOsd: (text) => osd.push(text), + logError: () => {}, + onCardsMined: (count) => { + cardsMined += count; + }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(cardsMined, 1); +}); diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts new file mode 100644 index 0000000..da643f9 --- /dev/null +++ b/src/core/services/mining.ts @@ -0,0 +1,181 @@ +interface SubtitleTimingTrackerLike { + getRecentBlocks: (count: number) => string[]; + getCurrentSubtitle: () => string | null; + findTiming: (text: string) => { startTime: number; endTime: number } | null; +} + +interface AnkiIntegrationLike { + updateLastAddedFromClipboard: (clipboardText: string) => Promise; + triggerFieldGroupingForLastAddedCard: () => Promise; + markLastCardAsAudioCard: () => Promise; + createSentenceCard: ( + sentence: string, + startTime: number, + endTime: number, + secondarySub?: string, + ) => Promise; +} + +interface MpvClientLike { + connected: boolean; + currentSubText: string; + currentSubStart: number; + currentSubEnd: number; + currentSecondarySubText?: string; +} + +export function handleMultiCopyDigit( + count: number, + deps: { + subtitleTimingTracker: SubtitleTimingTrackerLike | null; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }, +): void { + if (!deps.subtitleTimingTracker) return; + + const availableCount = Math.min(count, 200); + const blocks = deps.subtitleTimingTracker.getRecentBlocks(availableCount); + if (blocks.length === 0) { + deps.showMpvOsd('No subtitle history available'); + return; + } + + const actualCount = blocks.length; + deps.writeClipboardText(blocks.join('\n\n')); + if (actualCount < count) { + deps.showMpvOsd(`Only ${actualCount} lines available, copied ${actualCount}`); + } else { + deps.showMpvOsd(`Copied ${actualCount} lines`); + } +} + +export function copyCurrentSubtitle(deps: { + subtitleTimingTracker: SubtitleTimingTrackerLike | null; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; +}): void { + if (!deps.subtitleTimingTracker) { + deps.showMpvOsd('Subtitle tracker not available'); + return; + } + const currentSubtitle = deps.subtitleTimingTracker.getCurrentSubtitle(); + if (!currentSubtitle) { + deps.showMpvOsd('No current subtitle'); + return; + } + deps.writeClipboardText(currentSubtitle); + deps.showMpvOsd('Copied subtitle'); +} + +function requireAnkiIntegration( + ankiIntegration: AnkiIntegrationLike | null, + showMpvOsd: (text: string) => void, +): AnkiIntegrationLike | null { + if (!ankiIntegration) { + showMpvOsd('AnkiConnect integration not enabled'); + return null; + } + return ankiIntegration; +} + +export async function updateLastCardFromClipboard(deps: { + ankiIntegration: AnkiIntegrationLike | null; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; +}): Promise { + const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd); + if (!anki) return; + await anki.updateLastAddedFromClipboard(deps.readClipboardText()); +} + +export async function triggerFieldGrouping(deps: { + ankiIntegration: AnkiIntegrationLike | null; + showMpvOsd: (text: string) => void; +}): Promise { + const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd); + if (!anki) return; + await anki.triggerFieldGroupingForLastAddedCard(); +} + +export async function markLastCardAsAudioCard(deps: { + ankiIntegration: AnkiIntegrationLike | null; + showMpvOsd: (text: string) => void; +}): Promise { + const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd); + if (!anki) return; + await anki.markLastCardAsAudioCard(); +} + +export async function mineSentenceCard(deps: { + ankiIntegration: AnkiIntegrationLike | null; + mpvClient: MpvClientLike | null; + showMpvOsd: (text: string) => void; +}): Promise { + const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd); + if (!anki) return false; + + const mpvClient = deps.mpvClient; + if (!mpvClient || !mpvClient.connected) { + deps.showMpvOsd('MPV not connected'); + return false; + } + if (!mpvClient.currentSubText) { + deps.showMpvOsd('No current subtitle'); + return false; + } + + return await anki.createSentenceCard( + mpvClient.currentSubText, + mpvClient.currentSubStart, + mpvClient.currentSubEnd, + mpvClient.currentSecondarySubText || undefined, + ); +} + +export function handleMineSentenceDigit( + count: number, + deps: { + subtitleTimingTracker: SubtitleTimingTrackerLike | null; + ankiIntegration: AnkiIntegrationLike | null; + getCurrentSecondarySubText: () => string | undefined; + showMpvOsd: (text: string) => void; + logError: (message: string, err: unknown) => void; + onCardsMined?: (count: number) => void; + }, +): void { + if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return; + + const blocks = deps.subtitleTimingTracker.getRecentBlocks(count); + if (blocks.length === 0) { + deps.showMpvOsd('No subtitle history available'); + return; + } + + const timings: { startTime: number; endTime: number }[] = []; + for (const block of blocks) { + const timing = deps.subtitleTimingTracker.findTiming(block); + if (timing) timings.push(timing); + } + + if (timings.length === 0) { + deps.showMpvOsd('Subtitle timing not found'); + return; + } + + const rangeStart = Math.min(...timings.map((t) => t.startTime)); + const rangeEnd = Math.max(...timings.map((t) => t.endTime)); + const sentence = blocks.join(' '); + const cardsToMine = 1; + deps.ankiIntegration + .createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText()) + .then((created) => { + if (created) { + deps.onCardsMined?.(cardsToMine); + } + }) + .catch((err) => { + deps.logError('mineSentenceMultiple failed:', err); + deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`); + }); +} diff --git a/src/core/services/mpv-control.test.ts b/src/core/services/mpv-control.test.ts new file mode 100644 index 0000000..b501647 --- /dev/null +++ b/src/core/services/mpv-control.test.ts @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + playNextSubtitleRuntime, + replayCurrentSubtitleRuntime, + sendMpvCommandRuntime, + setMpvSubVisibilityRuntime, + showMpvOsdRuntime, +} from './mpv'; + +test('showMpvOsdRuntime sends show-text when connected', () => { + const commands: (string | number)[][] = []; + showMpvOsdRuntime( + { + connected: true, + send: ({ command }) => { + commands.push(command); + }, + }, + 'hello', + ); + assert.deepEqual(commands, [['show-text', 'hello', '3000']]); +}); + +test('showMpvOsdRuntime logs fallback when disconnected', () => { + const logs: string[] = []; + showMpvOsdRuntime( + { + connected: false, + send: () => {}, + }, + 'hello', + (line) => { + logs.push(line); + }, + ); + assert.deepEqual(logs, ['OSD (MPV not connected): hello']); +}); + +test('mpv runtime command wrappers call expected client methods', () => { + const calls: string[] = []; + const client = { + connected: true, + send: ({ command }: { command: (string | number)[] }) => { + calls.push(`send:${command.join(',')}`); + }, + replayCurrentSubtitle: () => { + calls.push('replay'); + }, + playNextSubtitle: () => { + calls.push('next'); + }, + setSubVisibility: (visible: boolean) => { + calls.push(`subVisible:${visible}`); + }, + }; + + replayCurrentSubtitleRuntime(client); + playNextSubtitleRuntime(client); + sendMpvCommandRuntime(client, ['script-message', 'x']); + setMpvSubVisibilityRuntime(client, false); + + assert.deepEqual(calls, ['replay', 'next', 'send:script-message,x', 'subVisible:false']); +}); diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts new file mode 100644 index 0000000..8298209 --- /dev/null +++ b/src/core/services/mpv-properties.ts @@ -0,0 +1,173 @@ +import { + MPV_REQUEST_ID_AID, + MPV_REQUEST_ID_OSD_DIMENSIONS, + MPV_REQUEST_ID_OSD_HEIGHT, + MPV_REQUEST_ID_PATH, + MPV_REQUEST_ID_SECONDARY_SUBTEXT, + MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + MPV_REQUEST_ID_SUB_ASS_OVERRIDE, + MPV_REQUEST_ID_SUB_BOLD, + MPV_REQUEST_ID_SUB_BORDER_SIZE, + MPV_REQUEST_ID_SUB_FONT, + MPV_REQUEST_ID_SUB_FONT_SIZE, + MPV_REQUEST_ID_SUB_ITALIC, + MPV_REQUEST_ID_SUB_MARGIN_X, + MPV_REQUEST_ID_SUB_MARGIN_Y, + MPV_REQUEST_ID_SUB_POS, + MPV_REQUEST_ID_SUB_SCALE, + MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW, + MPV_REQUEST_ID_SUB_SHADOW_OFFSET, + MPV_REQUEST_ID_SUB_SPACING, + MPV_REQUEST_ID_SUBTEXT, + MPV_REQUEST_ID_SUBTEXT_ASS, + MPV_REQUEST_ID_SUB_USE_MARGINS, + MPV_REQUEST_ID_PAUSE, +} from './mpv-protocol'; + +type MpvProtocolCommand = { + command: unknown[]; + request_id?: number; +}; + +export interface MpvSendCommand { + (command: MpvProtocolCommand): boolean; +} + +const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ + 'sub-text', + 'path', + 'sub-start', + 'sub-end', + 'time-pos', + 'secondary-sub-text', + 'aid', + 'sub-pos', + 'sub-font-size', + 'sub-scale', + 'sub-margin-y', + 'sub-margin-x', + 'sub-font', + 'sub-spacing', + 'sub-bold', + 'sub-italic', + 'sub-scale-by-window', + 'osd-height', + 'osd-dimensions', + 'sub-text-ass', + 'sub-border-size', + 'sub-shadow-offset', + 'sub-ass-override', + 'sub-use-margins', + 'pause', + 'media-title', +]; + +const MPV_INITIAL_PROPERTY_REQUESTS: Array = [ + { + command: ['get_property', 'sub-text'], + request_id: MPV_REQUEST_ID_SUBTEXT, + }, + { + command: ['get_property', 'sub-text-ass'], + request_id: MPV_REQUEST_ID_SUBTEXT_ASS, + }, + { + command: ['get_property', 'path'], + request_id: MPV_REQUEST_ID_PATH, + }, + { + command: ['get_property', 'media-title'], + }, + { + command: ['get_property', 'pause'], + request_id: MPV_REQUEST_ID_PAUSE, + }, + { + command: ['get_property', 'secondary-sub-text'], + request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT, + }, + { + command: ['get_property', 'secondary-sub-visibility'], + request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + }, + { + command: ['get_property', 'aid'], + request_id: MPV_REQUEST_ID_AID, + }, + { + command: ['get_property', 'sub-pos'], + request_id: MPV_REQUEST_ID_SUB_POS, + }, + { + command: ['get_property', 'sub-font-size'], + request_id: MPV_REQUEST_ID_SUB_FONT_SIZE, + }, + { + command: ['get_property', 'sub-scale'], + request_id: MPV_REQUEST_ID_SUB_SCALE, + }, + { + command: ['get_property', 'sub-margin-y'], + request_id: MPV_REQUEST_ID_SUB_MARGIN_Y, + }, + { + command: ['get_property', 'sub-margin-x'], + request_id: MPV_REQUEST_ID_SUB_MARGIN_X, + }, + { + command: ['get_property', 'sub-font'], + request_id: MPV_REQUEST_ID_SUB_FONT, + }, + { + command: ['get_property', 'sub-spacing'], + request_id: MPV_REQUEST_ID_SUB_SPACING, + }, + { + command: ['get_property', 'sub-bold'], + request_id: MPV_REQUEST_ID_SUB_BOLD, + }, + { + command: ['get_property', 'sub-italic'], + request_id: MPV_REQUEST_ID_SUB_ITALIC, + }, + { + command: ['get_property', 'sub-scale-by-window'], + request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW, + }, + { + command: ['get_property', 'osd-height'], + request_id: MPV_REQUEST_ID_OSD_HEIGHT, + }, + { + command: ['get_property', 'osd-dimensions'], + request_id: MPV_REQUEST_ID_OSD_DIMENSIONS, + }, + { + command: ['get_property', 'sub-border-size'], + request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE, + }, + { + command: ['get_property', 'sub-shadow-offset'], + request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET, + }, + { + command: ['get_property', 'sub-ass-override'], + request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE, + }, + { + command: ['get_property', 'sub-use-margins'], + request_id: MPV_REQUEST_ID_SUB_USE_MARGINS, + }, +]; + +export function subscribeToMpvProperties(send: MpvSendCommand): void { + MPV_SUBTITLE_PROPERTY_OBSERVATIONS.forEach((property, index) => { + send({ command: ['observe_property', index + 1, property] }); + }); +} + +export function requestMpvInitialState(send: MpvSendCommand): void { + MPV_INITIAL_PROPERTY_REQUESTS.forEach((payload) => { + send(payload); + }); +} diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts new file mode 100644 index 0000000..110685d --- /dev/null +++ b/src/core/services/mpv-protocol.test.ts @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { MpvSubtitleRenderMetrics } from '../../types'; +import { + dispatchMpvProtocolMessage, + MPV_REQUEST_ID_TRACK_LIST_SECONDARY, + MpvProtocolHandleMessageDeps, + splitMpvMessagesFromBuffer, + parseVisibilityProperty, + asBoolean, +} from './mpv-protocol'; + +function createDeps(overrides: Partial = {}): { + deps: MpvProtocolHandleMessageDeps; + state: { + subText: string; + secondarySubText: string; + events: Array; + commands: unknown[]; + mediaPath: string; + restored: number; + }; +} { + const state = { + subText: '', + secondarySubText: '', + events: [] as Array, + commands: [] as unknown[], + mediaPath: '', + restored: 0, + }; + const metrics: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: '', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 0, + osdDimensions: null, + }; + + return { + state, + deps: { + getResolvedConfig: () => ({ + secondarySub: { secondarySubLanguages: ['ja'] }, + }), + getSubtitleMetrics: () => metrics, + isVisibleOverlayVisible: () => false, + emitSubtitleChange: (payload) => state.events.push(payload), + emitSubtitleAssChange: (payload) => state.events.push(payload), + emitSubtitleTiming: (payload) => state.events.push(payload), + emitSecondarySubtitleChange: (payload) => state.events.push(payload), + getCurrentSubText: () => state.subText, + setCurrentSubText: (text) => { + state.subText = text; + }, + setCurrentSubStart: () => {}, + getCurrentSubStart: () => 0, + setCurrentSubEnd: () => {}, + getCurrentSubEnd: () => 0, + emitMediaPathChange: (payload) => { + state.mediaPath = payload.path; + }, + emitMediaTitleChange: (payload) => state.events.push(payload), + emitSubtitleMetricsChange: (payload) => state.events.push(payload), + setCurrentSecondarySubText: (text) => { + state.secondarySubText = text; + }, + resolvePendingRequest: () => false, + setSecondarySubVisibility: () => {}, + syncCurrentAudioStreamIndex: () => {}, + setCurrentAudioTrackId: () => {}, + setCurrentTimePos: () => {}, + getCurrentTimePos: () => 0, + getPendingPauseAtSubEnd: () => false, + setPendingPauseAtSubEnd: () => {}, + getPauseAtTime: () => null, + setPauseAtTime: () => {}, + emitTimePosChange: () => {}, + emitPauseChange: () => {}, + autoLoadSecondarySubTrack: () => {}, + setCurrentVideoPath: () => {}, + emitSecondarySubtitleVisibility: (payload) => state.events.push(payload), + setCurrentAudioStreamIndex: () => {}, + sendCommand: (payload) => { + state.commands.push(payload); + return true; + }, + restorePreviousSecondarySubVisibility: () => { + state.restored += 1; + }, + setPreviousSecondarySubVisibility: () => { + // intentionally not tracked in this unit test + }, + ...overrides, + }, + }; +} + +test('dispatchMpvProtocolMessage emits subtitle text on property change', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'sub-text', data: '字幕' }, + deps, + ); + + assert.equal(state.subText, '字幕'); + assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); +}); + +test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage( + { + request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY, + data: [ + { type: 'audio', id: 1, lang: 'eng' }, + { type: 'sub', id: 2, lang: 'ja' }, + ], + }, + deps, + ); + + assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]); +}); + +test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps); + + assert.equal(state.restored, 1); +}); + +test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => { + let pendingPauseAtSubEnd = true; + let pauseAtTime: number | null = null; + const { deps, state } = createDeps({ + getPendingPauseAtSubEnd: () => pendingPauseAtSubEnd, + setPendingPauseAtSubEnd: (next) => { + pendingPauseAtSubEnd = next; + }, + getCurrentSubText: () => '字幕', + setCurrentSubEnd: () => {}, + getCurrentSubEnd: () => 0, + setPauseAtTime: (next) => { + pauseAtTime = next; + }, + }); + + await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps); + + assert.equal(pendingPauseAtSubEnd, false); + assert.equal(pauseAtTime, 42); + assert.deepEqual(state.events, [{ text: '字幕', start: 0, end: 0 }]); + assert.deepEqual(state.commands[state.commands.length - 1], { + command: ['set_property', 'pause', false], + }); +}); + +test('splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer', () => { + const parsed = splitMpvMessagesFromBuffer( + '{"event":"shutdown"}\n{"event":"property-change","name":"media-title","data":"x"}\n{"partial"', + ); + + assert.equal(parsed.messages.length, 2); + assert.equal(parsed.nextBuffer, '{"partial"'); + assert.equal(parsed.messages[0]!.event, 'shutdown'); + assert.equal(parsed.messages[1]!.name, 'media-title'); +}); + +test('splitMpvMessagesFromBuffer reports invalid JSON lines', () => { + const errors: Array<{ line: string; error?: string }> = []; + + splitMpvMessagesFromBuffer('{"event":"x"}\n{invalid}\n', undefined, (line, error) => { + errors.push({ line, error: String(error) }); + }); + + assert.equal(errors.length, 1); + assert.equal(errors[0]!.line, '{invalid}'); +}); + +test('visibility and boolean parsers handle text values', () => { + assert.equal(parseVisibilityProperty('true'), true); + assert.equal(parseVisibilityProperty('0'), false); + assert.equal(parseVisibilityProperty('unknown'), null); + assert.equal(asBoolean('yes', false), true); + assert.equal(asBoolean('0', true), false); +}); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts new file mode 100644 index 0000000..fae4f00 --- /dev/null +++ b/src/core/services/mpv-protocol.ts @@ -0,0 +1,396 @@ +import { MpvSubtitleRenderMetrics } from '../../types'; + +export type MpvMessage = { + event?: string; + name?: string; + data?: unknown; + request_id?: number; + error?: string; +}; + +export const MPV_REQUEST_ID_SUBTEXT = 101; +export const MPV_REQUEST_ID_PATH = 102; +export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; +export const MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY = 104; +export const MPV_REQUEST_ID_AID = 105; +export const MPV_REQUEST_ID_SUB_POS = 106; +export const MPV_REQUEST_ID_SUB_FONT_SIZE = 107; +export const MPV_REQUEST_ID_SUB_SCALE = 108; +export const MPV_REQUEST_ID_SUB_MARGIN_Y = 109; +export const MPV_REQUEST_ID_SUB_MARGIN_X = 110; +export const MPV_REQUEST_ID_SUB_FONT = 111; +export const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112; +export const MPV_REQUEST_ID_OSD_HEIGHT = 113; +export const MPV_REQUEST_ID_OSD_DIMENSIONS = 114; +export const MPV_REQUEST_ID_SUBTEXT_ASS = 115; +export const MPV_REQUEST_ID_SUB_SPACING = 116; +export const MPV_REQUEST_ID_SUB_BOLD = 117; +export const MPV_REQUEST_ID_SUB_ITALIC = 118; +export const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119; +export const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120; +export const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; +export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; +export const MPV_REQUEST_ID_PAUSE = 123; +export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; +export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; + +export type MpvMessageParser = (message: MpvMessage) => void; +export type MpvParseErrorHandler = (line: string, error: unknown) => void; + +export interface MpvProtocolParseResult { + messages: MpvMessage[]; + nextBuffer: string; +} + +export interface MpvProtocolHandleMessageDeps { + getResolvedConfig: () => { + secondarySub?: { secondarySubLanguages?: Array }; + }; + getSubtitleMetrics: () => MpvSubtitleRenderMetrics; + isVisibleOverlayVisible: () => boolean; + emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void; + emitSubtitleAssChange: (payload: { text: string }) => void; + emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; + emitSecondarySubtitleChange: (payload: { text: string }) => void; + getCurrentSubText: () => string; + setCurrentSubText: (text: string) => void; + setCurrentSubStart: (value: number) => void; + getCurrentSubStart: () => number; + setCurrentSubEnd: (value: number) => void; + getCurrentSubEnd: () => number; + emitMediaPathChange: (payload: { path: string }) => void; + emitMediaTitleChange: (payload: { title: string | null }) => void; + emitTimePosChange: (payload: { time: number }) => void; + emitPauseChange: (payload: { paused: boolean }) => void; + emitSubtitleMetricsChange: (payload: Partial) => void; + setCurrentSecondarySubText: (text: string) => void; + resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean; + setSecondarySubVisibility: (visible: boolean) => void; + syncCurrentAudioStreamIndex: () => void; + setCurrentAudioTrackId: (value: number | null) => void; + setCurrentTimePos: (value: number) => void; + getCurrentTimePos: () => number; + getPendingPauseAtSubEnd: () => boolean; + setPendingPauseAtSubEnd: (value: boolean) => void; + getPauseAtTime: () => number | null; + setPauseAtTime: (value: number | null) => void; + autoLoadSecondarySubTrack: () => void; + setCurrentVideoPath: (value: string) => void; + emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; + setPreviousSecondarySubVisibility: (visible: boolean) => void; + setCurrentAudioStreamIndex: ( + tracks: Array<{ + type?: string; + id?: number; + selected?: boolean; + 'ff-index'?: number; + }>, + ) => void; + sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean; + restorePreviousSecondarySubVisibility: () => void; +} + +export function splitMpvMessagesFromBuffer( + buffer: string, + onMessage?: MpvMessageParser, + onError?: MpvParseErrorHandler, +): MpvProtocolParseResult { + const lines = buffer.split('\n'); + const nextBuffer = lines.pop() || ''; + const messages: MpvMessage[] = []; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const message = JSON.parse(line) as MpvMessage; + messages.push(message); + if (onMessage) { + onMessage(message); + } + } catch (error) { + if (onError) { + onError(line, error); + } + } + } + + return { + messages, + nextBuffer, + }; +} + +export async function dispatchMpvProtocolMessage( + msg: MpvMessage, + deps: MpvProtocolHandleMessageDeps, +): Promise { + if (msg.event === 'property-change') { + if (msg.name === 'sub-text') { + const nextSubText = (msg.data as string) || ''; + const overlayVisible = deps.isVisibleOverlayVisible(); + deps.emitSubtitleChange({ + text: nextSubText, + isOverlayVisible: overlayVisible, + }); + deps.setCurrentSubText(nextSubText); + } else if (msg.name === 'sub-text-ass') { + deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); + } else if (msg.name === 'sub-start') { + deps.setCurrentSubStart((msg.data as number) || 0); + deps.emitSubtitleTiming({ + text: deps.getCurrentSubText(), + start: deps.getCurrentSubStart(), + end: deps.getCurrentSubEnd(), + }); + } else if (msg.name === 'sub-end') { + const subEnd = (msg.data as number) || 0; + deps.setCurrentSubEnd(subEnd); + if (deps.getPendingPauseAtSubEnd() && subEnd > 0) { + deps.setPauseAtTime(subEnd); + deps.setPendingPauseAtSubEnd(false); + deps.sendCommand({ command: ['set_property', 'pause', false] }); + } + deps.emitSubtitleTiming({ + text: deps.getCurrentSubText(), + start: deps.getCurrentSubStart(), + end: deps.getCurrentSubEnd(), + }); + } else if (msg.name === 'secondary-sub-text') { + const nextSubText = (msg.data as string) || ''; + deps.setCurrentSecondarySubText(nextSubText); + deps.emitSecondarySubtitleChange({ text: nextSubText }); + } else if (msg.name === 'aid') { + deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null); + deps.syncCurrentAudioStreamIndex(); + } else if (msg.name === 'time-pos') { + deps.emitTimePosChange({ time: (msg.data as number) || 0 }); + deps.setCurrentTimePos((msg.data as number) || 0); + if ( + deps.getPauseAtTime() !== null && + deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number) + ) { + deps.setPauseAtTime(null); + deps.sendCommand({ command: ['set_property', 'pause', true] }); + } + } else if (msg.name === 'pause') { + deps.emitPauseChange({ paused: asBoolean(msg.data, false) }); + } else if (msg.name === 'media-title') { + deps.emitMediaTitleChange({ + title: typeof msg.data === 'string' ? msg.data.trim() : null, + }); + } else if (msg.name === 'path') { + const path = (msg.data as string) || ''; + deps.setCurrentVideoPath(path); + deps.emitMediaPathChange({ path }); + deps.autoLoadSecondarySubTrack(); + deps.syncCurrentAudioStreamIndex(); + } else if (msg.name === 'sub-pos') { + deps.emitSubtitleMetricsChange({ subPos: msg.data as number }); + } else if (msg.name === 'sub-font-size') { + deps.emitSubtitleMetricsChange({ subFontSize: msg.data as number }); + } else if (msg.name === 'sub-scale') { + deps.emitSubtitleMetricsChange({ subScale: msg.data as number }); + } else if (msg.name === 'sub-margin-y') { + deps.emitSubtitleMetricsChange({ subMarginY: msg.data as number }); + } else if (msg.name === 'sub-margin-x') { + deps.emitSubtitleMetricsChange({ subMarginX: msg.data as number }); + } else if (msg.name === 'sub-font') { + deps.emitSubtitleMetricsChange({ subFont: msg.data as string }); + } else if (msg.name === 'sub-spacing') { + deps.emitSubtitleMetricsChange({ subSpacing: msg.data as number }); + } else if (msg.name === 'sub-bold') { + deps.emitSubtitleMetricsChange({ + subBold: asBoolean(msg.data, deps.getSubtitleMetrics().subBold), + }); + } else if (msg.name === 'sub-italic') { + deps.emitSubtitleMetricsChange({ + subItalic: asBoolean(msg.data, deps.getSubtitleMetrics().subItalic), + }); + } else if (msg.name === 'sub-border-size') { + deps.emitSubtitleMetricsChange({ subBorderSize: msg.data as number }); + } else if (msg.name === 'sub-shadow-offset') { + deps.emitSubtitleMetricsChange({ subShadowOffset: msg.data as number }); + } else if (msg.name === 'sub-ass-override') { + deps.emitSubtitleMetricsChange({ subAssOverride: msg.data as string }); + } else if (msg.name === 'sub-scale-by-window') { + deps.emitSubtitleMetricsChange({ + subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow), + }); + } else if (msg.name === 'sub-use-margins') { + deps.emitSubtitleMetricsChange({ + subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins), + }); + } else if (msg.name === 'osd-height') { + deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number }); + } else if (msg.name === 'osd-dimensions') { + const dims = msg.data as Record | null; + if (!dims) { + deps.emitSubtitleMetricsChange({ osdDimensions: null }); + } else { + deps.emitSubtitleMetricsChange({ + osdDimensions: { + w: asFiniteNumber(dims.w, 0), + h: asFiniteNumber(dims.h, 0), + ml: asFiniteNumber(dims.ml, 0), + mr: asFiniteNumber(dims.mr, 0), + mt: asFiniteNumber(dims.mt, 0), + mb: asFiniteNumber(dims.mb, 0), + }, + }); + } + } + } else if (msg.event === 'shutdown') { + deps.restorePreviousSecondarySubVisibility(); + } else if (msg.request_id) { + if (deps.resolvePendingRequest(msg.request_id, msg)) { + return; + } + + if (msg.data === undefined) { + return; + } + + if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY) { + const tracks = msg.data as Array<{ + type: string; + lang?: string; + id: number; + }>; + if (Array.isArray(tracks)) { + const config = deps.getResolvedConfig(); + const languages = config.secondarySub?.secondarySubLanguages || []; + const subTracks = tracks.filter((track) => track.type === 'sub'); + for (const language of languages) { + const match = subTracks.find((track) => track.lang === language); + if (match) { + deps.sendCommand({ + command: ['set_property', 'secondary-sid', match.id], + }); + break; + } + } + } + } else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) { + deps.setCurrentAudioStreamIndex( + msg.data as Array<{ + type?: string; + id?: number; + selected?: boolean; + 'ff-index'?: number; + }>, + ); + } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) { + const nextSubText = (msg.data as string) || ''; + deps.setCurrentSubText(nextSubText); + deps.emitSubtitleChange({ + text: nextSubText, + isOverlayVisible: deps.isVisibleOverlayVisible(), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) { + deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); + } else if (msg.request_id === MPV_REQUEST_ID_PATH) { + deps.emitMediaPathChange({ path: (msg.data as string) || '' }); + } else if (msg.request_id === MPV_REQUEST_ID_AID) { + deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null); + deps.syncCurrentAudioStreamIndex(); + } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUBTEXT) { + const nextSubText = (msg.data as string) || ''; + deps.setCurrentSecondarySubText(nextSubText); + deps.emitSecondarySubtitleChange({ text: nextSubText }); + } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) { + const previous = parseVisibilityProperty(msg.data); + if (previous !== null) { + deps.setPreviousSecondarySubVisibility(previous); + deps.emitSecondarySubtitleVisibility({ visible: previous }); + } + deps.setSecondarySubVisibility(false); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_POS) { + deps.emitSubtitleMetricsChange({ subPos: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT_SIZE) { + deps.emitSubtitleMetricsChange({ subFontSize: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE) { + deps.emitSubtitleMetricsChange({ subScale: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_Y) { + deps.emitSubtitleMetricsChange({ subMarginY: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_X) { + deps.emitSubtitleMetricsChange({ subMarginX: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT) { + deps.emitSubtitleMetricsChange({ subFont: msg.data as string }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SPACING) { + deps.emitSubtitleMetricsChange({ subSpacing: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_BOLD) { + deps.emitSubtitleMetricsChange({ + subBold: asBoolean(msg.data, deps.getSubtitleMetrics().subBold), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_ITALIC) { + deps.emitSubtitleMetricsChange({ + subItalic: asBoolean(msg.data, deps.getSubtitleMetrics().subItalic), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_BORDER_SIZE) { + deps.emitSubtitleMetricsChange({ subBorderSize: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SHADOW_OFFSET) { + deps.emitSubtitleMetricsChange({ subShadowOffset: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_ASS_OVERRIDE) { + deps.emitSubtitleMetricsChange({ subAssOverride: msg.data as string }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW) { + deps.emitSubtitleMetricsChange({ + subScaleByWindow: asBoolean(msg.data, deps.getSubtitleMetrics().subScaleByWindow), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_USE_MARGINS) { + deps.emitSubtitleMetricsChange({ + subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins), + }); + } else if (msg.request_id === MPV_REQUEST_ID_PAUSE) { + deps.emitPauseChange({ paused: asBoolean(msg.data, false) }); + } else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) { + deps.emitSubtitleMetricsChange({ osdHeight: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) { + const dims = msg.data as Record | null; + if (!dims) { + deps.emitSubtitleMetricsChange({ osdDimensions: null }); + } else { + deps.emitSubtitleMetricsChange({ + osdDimensions: { + w: asFiniteNumber(dims.w, 0), + h: asFiniteNumber(dims.h, 0), + ml: asFiniteNumber(dims.ml, 0), + mr: asFiniteNumber(dims.mr, 0), + mt: asFiniteNumber(dims.mt, 0), + mb: asFiniteNumber(dims.mb, 0), + }, + }); + } + } + } +} + +export function asBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['yes', 'true', '1'].includes(normalized)) return true; + if (['no', 'false', '0'].includes(normalized)) return false; + } + return fallback; +} + +export function asFiniteNumber(value: unknown, fallback: number): number { + const nextValue = Number(value); + return Number.isFinite(nextValue) ? nextValue : fallback; +} + +export function parseVisibilityProperty(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + if (typeof value !== 'string') return null; + + const normalized = value.trim().toLowerCase(); + if (normalized === 'yes' || normalized === 'true' || normalized === '1') { + return true; + } + if (normalized === 'no' || normalized === 'false' || normalized === '0') { + return false; + } + + return null; +} diff --git a/src/core/services/mpv-render-metrics.test.ts b/src/core/services/mpv-render-metrics.test.ts new file mode 100644 index 0000000..10bbfcb --- /dev/null +++ b/src/core/services/mpv-render-metrics.test.ts @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MpvSubtitleRenderMetrics } from '../../types'; +import { + applyMpvSubtitleRenderMetricsPatch, + DEFAULT_MPV_SUBTITLE_RENDER_METRICS, +} from './mpv-render-metrics'; + +const BASE: MpvSubtitleRenderMetrics = { + ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, +}; + +test('applyMpvSubtitleRenderMetricsPatch returns unchanged on empty patch', () => { + const { next, changed } = applyMpvSubtitleRenderMetricsPatch(BASE, {}); + assert.equal(changed, false); + assert.deepEqual(next, BASE); +}); + +test('applyMpvSubtitleRenderMetricsPatch reports changed when patch modifies value', () => { + const { next, changed } = applyMpvSubtitleRenderMetricsPatch(BASE, { + subPos: 95, + }); + assert.equal(changed, true); + assert.equal(next.subPos, 95); +}); diff --git a/src/core/services/mpv-render-metrics.ts b/src/core/services/mpv-render-metrics.ts new file mode 100644 index 0000000..e5ffa0c --- /dev/null +++ b/src/core/services/mpv-render-metrics.ts @@ -0,0 +1,99 @@ +import { MpvSubtitleRenderMetrics } from '../../types'; +import { asBoolean, asFiniteNumber, asString } from '../utils/coerce'; + +export const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 38, + subScale: 1, + subMarginY: 34, + subMarginX: 19, + subFont: 'sans-serif', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 2.5, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 720, + osdDimensions: null, +}; + +export function sanitizeMpvSubtitleRenderMetrics( + current: MpvSubtitleRenderMetrics, + patch: Partial | null | undefined, +): MpvSubtitleRenderMetrics { + if (!patch) return current; + return updateMpvSubtitleRenderMetrics(current, patch); +} + +export function updateMpvSubtitleRenderMetrics( + current: MpvSubtitleRenderMetrics, + patch: Partial, +): MpvSubtitleRenderMetrics { + const patchOsd = patch.osdDimensions; + const nextOsdDimensions = + patchOsd && + typeof patchOsd.w === 'number' && + typeof patchOsd.h === 'number' && + typeof patchOsd.ml === 'number' && + typeof patchOsd.mr === 'number' && + typeof patchOsd.mt === 'number' && + typeof patchOsd.mb === 'number' + ? { + w: asFiniteNumber(patchOsd.w, 0, 1, 100000), + h: asFiniteNumber(patchOsd.h, 0, 1, 100000), + ml: asFiniteNumber(patchOsd.ml, 0, 0, 100000), + mr: asFiniteNumber(patchOsd.mr, 0, 0, 100000), + mt: asFiniteNumber(patchOsd.mt, 0, 0, 100000), + mb: asFiniteNumber(patchOsd.mb, 0, 0, 100000), + } + : patchOsd === null + ? null + : current.osdDimensions; + + return { + subPos: asFiniteNumber(patch.subPos, current.subPos, 0, 150), + subFontSize: asFiniteNumber(patch.subFontSize, current.subFontSize, 1, 200), + subScale: asFiniteNumber(patch.subScale, current.subScale, 0.1, 10), + subMarginY: asFiniteNumber(patch.subMarginY, current.subMarginY, 0, 200), + subMarginX: asFiniteNumber(patch.subMarginX, current.subMarginX, 0, 200), + subFont: asString(patch.subFont, current.subFont), + subSpacing: asFiniteNumber(patch.subSpacing, current.subSpacing, -100, 100), + subBold: asBoolean(patch.subBold, current.subBold), + subItalic: asBoolean(patch.subItalic, current.subItalic), + subBorderSize: asFiniteNumber(patch.subBorderSize, current.subBorderSize, 0, 100), + subShadowOffset: asFiniteNumber(patch.subShadowOffset, current.subShadowOffset, 0, 100), + subAssOverride: asString(patch.subAssOverride, current.subAssOverride), + subScaleByWindow: asBoolean(patch.subScaleByWindow, current.subScaleByWindow), + subUseMargins: asBoolean(patch.subUseMargins, current.subUseMargins), + osdHeight: asFiniteNumber(patch.osdHeight, current.osdHeight, 1, 10000), + osdDimensions: nextOsdDimensions, + }; +} + +export function applyMpvSubtitleRenderMetricsPatch( + current: MpvSubtitleRenderMetrics, + patch: Partial, +): { next: MpvSubtitleRenderMetrics; changed: boolean } { + const next = updateMpvSubtitleRenderMetrics(current, patch); + const changed = + next.subPos !== current.subPos || + next.subFontSize !== current.subFontSize || + next.subScale !== current.subScale || + next.subMarginY !== current.subMarginY || + next.subMarginX !== current.subMarginX || + next.subFont !== current.subFont || + next.subSpacing !== current.subSpacing || + next.subBold !== current.subBold || + next.subItalic !== current.subItalic || + next.subBorderSize !== current.subBorderSize || + next.subShadowOffset !== current.subShadowOffset || + next.subAssOverride !== current.subAssOverride || + next.subScaleByWindow !== current.subScaleByWindow || + next.subUseMargins !== current.subUseMargins || + next.osdHeight !== current.osdHeight || + JSON.stringify(next.osdDimensions) !== JSON.stringify(current.osdDimensions); + return { next, changed }; +} diff --git a/src/core/services/mpv-state.test.ts b/src/core/services/mpv-state.test.ts new file mode 100644 index 0000000..ef8d511 --- /dev/null +++ b/src/core/services/mpv-state.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveCurrentAudioStreamIndex } from './mpv'; + +test('resolveCurrentAudioStreamIndex returns selected ff-index when no current track id', () => { + assert.equal( + resolveCurrentAudioStreamIndex( + [ + { type: 'audio', id: 1, selected: false, 'ff-index': 1 }, + { type: 'audio', id: 2, selected: true, 'ff-index': 3 }, + ], + null, + ), + 3, + ); +}); + +test('resolveCurrentAudioStreamIndex prefers matching current audio track id', () => { + assert.equal( + resolveCurrentAudioStreamIndex( + [ + { type: 'audio', id: 1, selected: true, 'ff-index': 3 }, + { type: 'audio', id: 2, selected: false, 'ff-index': 6 }, + ], + 2, + ), + 6, + ); +}); + +test('resolveCurrentAudioStreamIndex returns null when tracks are not an array', () => { + assert.equal(resolveCurrentAudioStreamIndex(null, null), null); +}); diff --git a/src/core/services/mpv-transport.test.ts b/src/core/services/mpv-transport.test.ts new file mode 100644 index 0000000..b602b61 --- /dev/null +++ b/src/core/services/mpv-transport.test.ts @@ -0,0 +1,223 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as net from 'node:net'; +import { EventEmitter } from 'node:events'; +import { + getMpvReconnectDelay, + MpvSocketMessagePayload, + MpvSocketTransport, + scheduleMpvReconnect, +} from './mpv-transport'; + +class FakeSocket extends EventEmitter { + public connectedPaths: string[] = []; + public writePayloads: string[] = []; + public destroyed = false; + + connect(path: string): void { + this.connectedPaths.push(path); + setTimeout(() => { + this.emit('connect'); + }, 0); + } + + write(payload: string): boolean { + this.writePayloads.push(payload); + return true; + } + + destroy(): void { + this.destroyed = true; + this.emit('close'); + } +} + +const wait = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('getMpvReconnectDelay follows existing reconnect ramp', () => { + assert.equal(getMpvReconnectDelay(0, true), 1000); + assert.equal(getMpvReconnectDelay(1, true), 1000); + assert.equal(getMpvReconnectDelay(2, true), 2000); + assert.equal(getMpvReconnectDelay(4, true), 5000); + assert.equal(getMpvReconnectDelay(7, true), 10000); + + assert.equal(getMpvReconnectDelay(0, false), 200); + assert.equal(getMpvReconnectDelay(2, false), 500); + assert.equal(getMpvReconnectDelay(4, false), 1000); + assert.equal(getMpvReconnectDelay(6, false), 2000); +}); + +test('scheduleMpvReconnect clears existing timer and increments attempt', () => { + const existing = {} as ReturnType; + const cleared: Array | null> = []; + const setTimers: Array | null> = []; + const calls: Array<{ attempt: number; delay: number }> = []; + let connected = 0; + + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + (globalThis as any).setTimeout = (handler: () => void, _delay: number) => { + handler(); + return 1 as unknown as ReturnType; + }; + (globalThis as any).clearTimeout = (timer: ReturnType | null) => { + cleared.push(timer); + }; + + const nextAttempt = scheduleMpvReconnect({ + attempt: 3, + hasConnectedOnce: true, + getReconnectTimer: () => existing, + setReconnectTimer: (timer) => { + setTimers.push(timer); + }, + onReconnectAttempt: (attempt, delay) => { + calls.push({ attempt, delay }); + }, + connect: () => { + connected += 1; + }, + }); + + (globalThis as any).setTimeout = originalSetTimeout; + (globalThis as any).clearTimeout = originalClearTimeout; + + assert.equal(nextAttempt, 4); + assert.equal(cleared.length, 1); + assert.equal(cleared[0]!, existing); + assert.equal(setTimers.length, 1); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.attempt, 4); + assert.equal(calls[0]!.delay, getMpvReconnectDelay(3, true)); + assert.equal(connected, 1); +}); + +test('MpvSocketTransport connects and sends payloads over a live socket', async () => { + const events: string[] = []; + const transport = new MpvSocketTransport({ + socketPath: '/tmp/mpv.sock', + onConnect: () => { + events.push('connect'); + }, + onData: () => { + events.push('data'); + }, + onError: () => { + events.push('error'); + }, + onClose: () => { + events.push('close'); + }, + socketFactory: () => new FakeSocket() as unknown as net.Socket, + }); + + const payload: MpvSocketMessagePayload = { + command: ['sub-seek', 1], + request_id: 1, + }; + + assert.equal(transport.send(payload), false); + + transport.connect(); + await wait(); + + assert.equal(events.includes('connect'), true); + assert.equal(transport.send(payload), true); + + const fakeSocket = transport.getSocket() as unknown as FakeSocket; + assert.equal(fakeSocket.connectedPaths.at(0), '/tmp/mpv.sock'); + assert.equal(fakeSocket.writePayloads.length, 1); + assert.equal(fakeSocket.writePayloads.at(0), `${JSON.stringify(payload)}\n`); +}); + +test('MpvSocketTransport reports lifecycle transitions and callback order', async () => { + const events: string[] = []; + const fakeError = new Error('boom'); + const transport = new MpvSocketTransport({ + socketPath: '/tmp/mpv.sock', + onConnect: () => { + events.push('connect'); + }, + onData: () => { + events.push('data'); + }, + onError: () => { + events.push('error'); + }, + onClose: () => { + events.push('close'); + }, + socketFactory: () => new FakeSocket() as unknown as net.Socket, + }); + + transport.connect(); + await wait(); + + const socket = transport.getSocket() as unknown as FakeSocket; + socket.emit('error', fakeError); + socket.emit('data', Buffer.from('{}')); + socket.destroy(); + await wait(); + + assert.equal(events.includes('connect'), true); + assert.equal(events.includes('data'), true); + assert.equal(events.includes('error'), true); + assert.equal(events.includes('close'), true); + assert.equal(transport.isConnected, false); + assert.equal(transport.isConnecting, false); + assert.equal(socket.destroyed, true); +}); + +test('MpvSocketTransport ignores connect requests while already connecting or connected', async () => { + const events: string[] = []; + const transport = new MpvSocketTransport({ + socketPath: '/tmp/mpv.sock', + onConnect: () => { + events.push('connect'); + }, + onData: () => { + events.push('data'); + }, + onError: () => { + events.push('error'); + }, + onClose: () => { + events.push('close'); + }, + socketFactory: () => new FakeSocket() as unknown as net.Socket, + }); + + transport.connect(); + transport.connect(); + await wait(); + + assert.equal(events.includes('connect'), true); + const socket = transport.getSocket() as unknown as FakeSocket; + socket.emit('close'); + await wait(); + + transport.connect(); + await wait(); + + assert.equal(events.filter((entry) => entry === 'connect').length, 2); +}); + +test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => { + const transport = new MpvSocketTransport({ + socketPath: '/tmp/mpv.sock', + onConnect: () => {}, + onData: () => {}, + onError: () => {}, + onClose: () => {}, + socketFactory: () => new FakeSocket() as unknown as net.Socket, + }); + + transport.connect(); + await wait(); + assert.equal(transport.isConnected, true); + + transport.shutdown(); + assert.equal(transport.isConnected, false); + assert.equal(transport.isConnecting, false); + assert.equal(transport.getSocket(), null); +}); diff --git a/src/core/services/mpv-transport.ts b/src/core/services/mpv-transport.ts new file mode 100644 index 0000000..02aa5dd --- /dev/null +++ b/src/core/services/mpv-transport.ts @@ -0,0 +1,167 @@ +import * as net from 'net'; + +export function getMpvReconnectDelay(attempt: number, hasConnectedOnce: boolean): number { + if (hasConnectedOnce) { + if (attempt < 2) { + return 1000; + } + if (attempt < 4) { + return 2000; + } + if (attempt < 7) { + return 5000; + } + return 10000; + } + + if (attempt < 2) { + return 200; + } + if (attempt < 4) { + return 500; + } + if (attempt < 6) { + return 1000; + } + return 2000; +} + +export interface MpvReconnectSchedulerDeps { + attempt: number; + hasConnectedOnce: boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; + onReconnectAttempt: (attempt: number, delay: number) => void; + connect: () => void; +} + +export function scheduleMpvReconnect(deps: MpvReconnectSchedulerDeps): number { + const reconnectTimer = deps.getReconnectTimer(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + const delay = getMpvReconnectDelay(deps.attempt, deps.hasConnectedOnce); + deps.setReconnectTimer( + setTimeout(() => { + deps.onReconnectAttempt(deps.attempt + 1, delay); + deps.connect(); + }, delay), + ); + return deps.attempt + 1; +} + +export interface MpvSocketMessagePayload { + command: unknown[]; + request_id?: number; +} + +interface MpvSocketTransportEvents { + onConnect: () => void; + onData: (data: Buffer) => void; + onError: (error: Error) => void; + onClose: () => void; +} + +export interface MpvSocketTransportOptions { + socketPath: string; + onConnect: () => void; + onData: (data: Buffer) => void; + onError: (error: Error) => void; + onClose: () => void; + socketFactory?: () => net.Socket; +} + +export class MpvSocketTransport { + private socketPath: string; + private readonly callbacks: MpvSocketTransportEvents; + private readonly socketFactory: () => net.Socket; + private socketRef: net.Socket | null = null; + public socket: net.Socket | null = null; + public connected = false; + public connecting = false; + + constructor(options: MpvSocketTransportOptions) { + this.socketPath = options.socketPath; + this.socketFactory = options.socketFactory ?? (() => new net.Socket()); + this.callbacks = { + onConnect: options.onConnect, + onData: options.onData, + onError: options.onError, + onClose: options.onClose, + }; + } + + setSocketPath(socketPath: string): void { + this.socketPath = socketPath; + } + + connect(): void { + if (this.connected || this.connecting) { + return; + } + + if (this.socketRef) { + this.socketRef.destroy(); + } + + this.connecting = true; + this.socketRef = this.socketFactory(); + this.socket = this.socketRef; + + this.socketRef.on('connect', () => { + this.connected = true; + this.connecting = false; + this.callbacks.onConnect(); + }); + + this.socketRef.on('data', (data: Buffer) => { + this.callbacks.onData(data); + }); + + this.socketRef.on('error', (error: Error) => { + this.connected = false; + this.connecting = false; + this.callbacks.onError(error); + }); + + this.socketRef.on('close', () => { + this.connected = false; + this.connecting = false; + this.callbacks.onClose(); + }); + + this.socketRef.connect(this.socketPath); + } + + send(payload: MpvSocketMessagePayload): boolean { + if (!this.connected || !this.socketRef) { + return false; + } + + const message = JSON.stringify(payload) + '\n'; + this.socketRef.write(message); + return true; + } + + shutdown(): void { + if (this.socketRef) { + this.socketRef.destroy(); + } + this.socketRef = null; + this.socket = null; + this.connected = false; + this.connecting = false; + } + + getSocket(): net.Socket | null { + return this.socketRef; + } + + get isConnected(): boolean { + return this.connected; + } + + get isConnecting(): boolean { + return this.connecting; + } +} diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts new file mode 100644 index 0000000..0e35064 --- /dev/null +++ b/src/core/services/mpv.test.ts @@ -0,0 +1,388 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + MpvIpcClient, + MpvIpcClientDeps, + MpvIpcClientProtocolDeps, + MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, +} from './mpv'; +import { MPV_REQUEST_ID_TRACK_LIST_AUDIO } from './mpv-protocol'; + +function makeDeps(overrides: Partial = {}): MpvIpcClientDeps { + return { + getResolvedConfig: () => ({}) as any, + autoStartOverlay: false, + setOverlayVisible: () => {}, + shouldBindVisibleOverlayToMpvSubVisibility: () => false, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + ...overrides, + }; +} + +function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise { + return (client as unknown as { handleMessage: (msg: unknown) => Promise }).handleMessage( + msg, + ); +} + +test('MpvIpcClient resolves pending request by request_id', async () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + let resolved: unknown = null; + (client as any).pendingRequests.set(1234, (msg: unknown) => { + resolved = msg; + }); + + await invokeHandleMessage(client, { request_id: 1234, data: 'ok' }); + + assert.deepEqual(resolved, { request_id: 1234, data: 'ok' }); + assert.equal((client as any).pendingRequests.size, 0); +}); + +test('MpvIpcClient handles sub-text property change and broadcasts tokenized subtitle', async () => { + const events: Array<{ text: string; isOverlayVisible: boolean }> = []; + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + client.on('subtitle-change', (payload) => { + events.push(payload); + }); + + await invokeHandleMessage(client, { + event: 'property-change', + name: 'sub-text', + data: '字幕', + }); + + assert.equal(events.length, 1); + assert.equal(events[0]!.text, '字幕'); + assert.equal(events[0]!.isOverlayVisible, false); +}); + +test('MpvIpcClient parses JSON line protocol in processBuffer', () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + const seen: Array> = []; + (client as any).handleMessage = (msg: Record) => { + seen.push(msg); + }; + (client as any).buffer = + '{"event":"property-change","name":"path","data":"a"}\n{"request_id":1,"data":"ok"}\n{"partial":'; + + (client as any).processBuffer(); + + assert.equal(seen.length, 2); + assert.equal(seen[0]!.name, 'path'); + assert.equal(seen[1]!.request_id, 1); + assert.equal((client as any).buffer, '{"partial":'); +}); + +test('MpvIpcClient request rejects when disconnected', async () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + await assert.rejects(async () => client.request(['get_property', 'path']), /MPV not connected/); +}); + +test('MpvIpcClient requestProperty throws on mpv error response', async () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + (client as any).request = async () => ({ error: 'property unavailable' }); + await assert.rejects( + async () => client.requestProperty('path'), + /Failed to read MPV property 'path': property unavailable/, + ); +}); + +test('MpvIpcClient connect does not log connect-request at info level', () => { + const originalLevel = process.env.SUBMINER_LOG_LEVEL; + const originalInfo = console.info; + const infoLines: string[] = []; + process.env.SUBMINER_LOG_LEVEL = 'info'; + console.info = (message?: unknown) => { + infoLines.push(String(message ?? '')); + }; + + try { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + (client as any).transport.connect = () => {}; + client.connect(); + } finally { + process.env.SUBMINER_LOG_LEVEL = originalLevel; + console.info = originalInfo; + } + + const requestLogs = infoLines.filter((line) => line.includes('MPV IPC connect requested.')); + assert.equal(requestLogs.length, 0); +}); + +test('MpvIpcClient connect logs connect-request at debug level', () => { + const originalLevel = process.env.SUBMINER_LOG_LEVEL; + const originalDebug = console.debug; + const debugLines: string[] = []; + process.env.SUBMINER_LOG_LEVEL = 'debug'; + console.debug = (message?: unknown) => { + debugLines.push(String(message ?? '')); + }; + + try { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + (client as any).transport.connect = () => {}; + client.connect(); + } finally { + process.env.SUBMINER_LOG_LEVEL = originalLevel; + console.debug = originalDebug; + } + + const requestLogs = debugLines.filter((line) => line.includes('MPV IPC connect requested.')); + assert.equal(requestLogs.length, 1); +}); + +test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + const resolved: unknown[] = []; + (client as any).pendingRequests.set(10, (msg: unknown) => { + resolved.push(msg); + }); + (client as any).pendingRequests.set(11, (msg: unknown) => { + resolved.push(msg); + }); + + (client as any).failPendingRequests(); + + assert.deepEqual(resolved, [ + { request_id: 10, error: 'disconnected' }, + { request_id: 11, error: 'disconnected' }, + ]); + assert.equal((client as any).pendingRequests.size, 0); +}); + +test('MpvIpcClient scheduleReconnect schedules timer and invokes connect', () => { + const timers: Array | null> = []; + const client = new MpvIpcClient( + '/tmp/mpv.sock', + makeDeps({ + getReconnectTimer: () => null, + setReconnectTimer: (timer) => { + timers.push(timer); + }, + }), + ); + + let connectCalled = false; + (client as any).connect = () => { + connectCalled = true; + }; + + const originalSetTimeout = globalThis.setTimeout; + (globalThis as any).setTimeout = (handler: () => void, _delay: number) => { + handler(); + return 1 as unknown as ReturnType; + }; + try { + (client as any).scheduleReconnect(); + } finally { + (globalThis as any).setTimeout = originalSetTimeout; + } + + assert.equal(timers.length, 1); + assert.equal(connectCalled, true); +}); + +test('MpvIpcClient scheduleReconnect clears existing reconnect timer', () => { + const timers: Array | null> = []; + const cleared: Array | null> = []; + const existingTimer = {} as ReturnType; + const client = new MpvIpcClient( + '/tmp/mpv.sock', + makeDeps({ + getReconnectTimer: () => existingTimer, + setReconnectTimer: (timer) => { + timers.push(timer); + }, + }), + ); + + let connectCalled = false; + (client as any).connect = () => { + connectCalled = true; + }; + + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + (globalThis as any).setTimeout = (handler: () => void, _delay: number) => { + handler(); + return 1 as unknown as ReturnType; + }; + (globalThis as any).clearTimeout = (timer: ReturnType | null) => { + cleared.push(timer); + }; + + try { + (client as any).scheduleReconnect(); + } finally { + (globalThis as any).setTimeout = originalSetTimeout; + (globalThis as any).clearTimeout = originalClearTimeout; + } + + assert.equal(cleared.length, 1); + assert.equal(cleared[0], existingTimer); + assert.equal(timers.length, 1); + assert.equal(connectCalled, true); +}); + +test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => { + const timers: Array | null> = []; + const client = new MpvIpcClient( + '/tmp/mpv.sock', + makeDeps({ + getReconnectTimer: () => null, + setReconnectTimer: (timer) => { + timers.push(timer); + }, + }), + ); + + const resolved: Array = []; + (client as any).pendingRequests.set(1, (message: unknown) => { + resolved.push(message); + }); + + let reconnectConnectCount = 0; + (client as any).connect = () => { + reconnectConnectCount += 1; + }; + + const originalSetTimeout = globalThis.setTimeout; + (globalThis as any).setTimeout = (handler: () => void, _delay: number) => { + handler(); + return 1 as unknown as ReturnType; + }; + + try { + (client as any).transport.callbacks.onClose(); + } finally { + (globalThis as any).setTimeout = originalSetTimeout; + } + + assert.equal(resolved.length, 1); + assert.deepEqual(resolved[0], { request_id: 1, error: 'disconnected' }); + assert.equal(reconnectConnectCount, 1); + assert.equal(timers.length, 1); +}); + +test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => { + const commands: unknown[] = []; + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + (client as any).send = (command: unknown) => { + commands.push(command); + return true; + }; + + const callbacks = (client as any).transport.callbacks; + callbacks.onConnect(); + + commands.length = 0; + callbacks.onConnect(); + + const hasSecondaryVisibilityReset = commands.some( + (command) => + Array.isArray((command as { command: unknown[] }).command) && + (command as { command: unknown[] }).command[0] === 'set_property' && + (command as { command: unknown[] }).command[1] === 'secondary-sub-visibility' && + (command as { command: unknown[] }).command[2] === 'no', + ); + const hasTrackSubscription = commands.some( + (command) => + Array.isArray((command as { command: unknown[] }).command) && + (command as { command: unknown[] }).command[0] === 'observe_property' && + (command as { command: unknown[] }).command[1] === 1 && + (command as { command: unknown[] }).command[2] === 'sub-text', + ); + const hasPathRequest = commands.some( + (command) => + Array.isArray((command as { command: unknown[] }).command) && + (command as { command: unknown[] }).command[0] === 'get_property' && + (command as { command: unknown[] }).command[1] === 'path', + ); + + assert.equal(hasSecondaryVisibilityReset, true); + assert.equal(hasTrackSubscription, true); + assert.equal(hasPathRequest, true); +}); + +test('MpvIpcClient captures and disables secondary subtitle visibility on request', async () => { + const commands: unknown[] = []; + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + const previous: boolean[] = []; + client.on('secondary-subtitle-visibility', ({ visible }) => { + previous.push(visible); + }); + + (client as any).send = (payload: unknown) => { + commands.push(payload); + return true; + }; + + await invokeHandleMessage(client, { + request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + data: 'yes', + }); + + assert.deepEqual(previous, [true]); + assert.deepEqual(commands, [ + { + command: ['set_property', 'secondary-sub-visibility', 'no'], + }, + ]); +}); + +test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value', async () => { + const commands: unknown[] = []; + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + const previous: boolean[] = []; + client.on('secondary-subtitle-visibility', ({ visible }) => { + previous.push(visible); + }); + + (client as any).send = (payload: unknown) => { + commands.push(payload); + return true; + }; + + await invokeHandleMessage(client, { + request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, + data: 'yes', + }); + client.restorePreviousSecondarySubVisibility(); + + assert.equal(previous[0], true); + assert.equal(previous.length, 1); + assert.deepEqual(commands, [ + { + command: ['set_property', 'secondary-sub-visibility', 'no'], + }, + { + command: ['set_property', 'secondary-sub-visibility', 'yes'], + }, + ]); + + client.restorePreviousSecondarySubVisibility(); + assert.equal(commands.length, 2); +}); + +test('MpvIpcClient updates current audio stream index from track list', async () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + + await invokeHandleMessage(client, { + event: 'property-change', + name: 'aid', + data: 3, + }); + await invokeHandleMessage(client, { + request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO, + data: [ + { type: 'sub', id: 5 }, + { type: 'audio', id: 1, selected: false, 'ff-index': 7 }, + { type: 'audio', id: 3, selected: false, 'ff-index': 11 }, + { type: 'audio', id: 4, selected: true, 'ff-index': 9 }, + ], + }); + + assert.equal(client.currentAudioStreamIndex, 11); +}); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts new file mode 100644 index 0000000..9f40a21 --- /dev/null +++ b/src/core/services/mpv.ts @@ -0,0 +1,496 @@ +import { EventEmitter } from 'events'; +import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types'; +import { + dispatchMpvProtocolMessage, + MPV_REQUEST_ID_TRACK_LIST_AUDIO, + MPV_REQUEST_ID_TRACK_LIST_SECONDARY, + MpvMessage, + MpvProtocolHandleMessageDeps, + splitMpvMessagesFromBuffer, +} from './mpv-protocol'; +import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties'; +import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:mpv'); + +export type MpvTrackProperty = { + type?: string; + id?: number; + selected?: boolean; + 'ff-index'?: number; +}; + +export function resolveCurrentAudioStreamIndex( + tracks: Array | null | undefined, + currentAudioTrackId: number | null, +): number | null { + if (!Array.isArray(tracks)) { + return null; + } + + const audioTracks = tracks.filter((track) => track.type === 'audio'); + const activeTrack = + audioTracks.find((track) => track.id === currentAudioTrackId) || + audioTracks.find((track) => track.selected === true); + + const ffIndex = activeTrack?.['ff-index']; + return typeof ffIndex === 'number' && Number.isInteger(ffIndex) && ffIndex >= 0 ? ffIndex : null; +} + +export interface MpvRuntimeClientLike { + connected: boolean; + send: (payload: { command: (string | number)[] }) => void; + replayCurrentSubtitle?: () => void; + playNextSubtitle?: () => void; + setSubVisibility?: (visible: boolean) => void; +} + +export function showMpvOsdRuntime( + mpvClient: MpvRuntimeClientLike | null, + text: string, + fallbackLog: (text: string) => void = (line) => logger.info(line), +): void { + if (mpvClient && mpvClient.connected) { + mpvClient.send({ command: ['show-text', text, '3000'] }); + return; + } + fallbackLog(`OSD (MPV not connected): ${text}`); +} + +export function replayCurrentSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void { + if (!mpvClient?.replayCurrentSubtitle) return; + mpvClient.replayCurrentSubtitle(); +} + +export function playNextSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void { + if (!mpvClient?.playNextSubtitle) return; + mpvClient.playNextSubtitle(); +} + +export function sendMpvCommandRuntime( + mpvClient: MpvRuntimeClientLike | null, + command: (string | number)[], +): void { + if (!mpvClient) return; + mpvClient.send({ command }); +} + +export function setMpvSubVisibilityRuntime( + mpvClient: MpvRuntimeClientLike | null, + visible: boolean, +): void { + if (!mpvClient?.setSubVisibility) return; + mpvClient.setSubVisibility(visible); +} + +export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol'; + +export interface MpvIpcClientProtocolDeps { + getResolvedConfig: () => Config; + autoStartOverlay: boolean; + setOverlayVisible: (visible: boolean) => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isVisibleOverlayVisible: () => boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; +} + +export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {} + +export interface MpvIpcClientEventMap { + 'connection-change': { connected: boolean }; + 'subtitle-change': { text: string; isOverlayVisible: boolean }; + 'subtitle-ass-change': { text: string }; + 'subtitle-timing': { text: string; start: number; end: number }; + 'time-pos-change': { time: number }; + 'pause-change': { paused: boolean }; + 'secondary-subtitle-change': { text: string }; + 'media-path-change': { path: string }; + 'media-title-change': { title: string | null }; + 'subtitle-metrics-change': { patch: Partial }; + 'secondary-subtitle-visibility': { visible: boolean }; +} + +type MpvIpcClientEventName = keyof MpvIpcClientEventMap; + +export class MpvIpcClient implements MpvClient { + private deps: MpvIpcClientProtocolDeps; + private transport: MpvSocketTransport; + public socket: ReturnType = null; + private eventBus = new EventEmitter(); + private buffer = ''; + public connected = false; + private connecting = false; + private reconnectAttempt = 0; + private firstConnection = true; + private hasConnectedOnce = false; + public currentVideoPath = ''; + public currentTimePos = 0; + public currentSubStart = 0; + public currentSubEnd = 0; + public currentSubText = ''; + public currentSecondarySubText = ''; + public currentAudioStreamIndex: number | null = null; + private currentAudioTrackId: number | null = null; + private mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: '', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 0, + osdDimensions: null, + }; + private previousSecondarySubVisibility: boolean | null = null; + private pauseAtTime: number | null = null; + private pendingPauseAtSubEnd = false; + private nextDynamicRequestId = 1000; + private pendingRequests = new Map void>(); + + constructor(socketPath: string, deps: MpvIpcClientDeps) { + this.deps = deps; + + this.transport = new MpvSocketTransport({ + socketPath, + onConnect: () => { + logger.debug('Connected to MPV socket'); + this.connected = true; + this.connecting = false; + this.socket = this.transport.getSocket(); + this.emit('connection-change', { connected: true }); + this.reconnectAttempt = 0; + this.hasConnectedOnce = true; + this.setSecondarySubVisibility(false); + subscribeToMpvProperties(this.send.bind(this)); + requestMpvInitialState(this.send.bind(this)); + + const shouldAutoStart = + this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true; + if (this.firstConnection && shouldAutoStart) { + logger.debug('Auto-starting overlay, hiding mpv subtitles'); + setTimeout(() => { + this.deps.setOverlayVisible(true); + }, 100); + } else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) { + this.setSubVisibility(!this.deps.isVisibleOverlayVisible()); + } + + this.firstConnection = false; + }, + onData: (data) => { + this.buffer += data.toString(); + this.processBuffer(); + }, + onError: (err: Error) => { + logger.debug('MPV socket error:', err.message); + this.failPendingRequests(); + }, + onClose: () => { + logger.debug('MPV socket closed'); + this.connected = false; + this.connecting = false; + this.socket = null; + this.emit('connection-change', { connected: false }); + this.failPendingRequests(); + this.scheduleReconnect(); + }, + }); + } + + on( + event: EventName, + listener: (payload: MpvIpcClientEventMap[EventName]) => void, + ): void { + this.eventBus.on(event as string, listener); + } + + off( + event: EventName, + listener: (payload: MpvIpcClientEventMap[EventName]) => void, + ): void { + this.eventBus.off(event as string, listener); + } + + private emit( + event: EventName, + payload: MpvIpcClientEventMap[EventName], + ): void { + this.eventBus.emit(event as string, payload); + } + + private emitSubtitleMetricsChange(patch: Partial): void { + this.mpvSubtitleRenderMetrics = { + ...this.mpvSubtitleRenderMetrics, + ...patch, + }; + this.emit('subtitle-metrics-change', { patch }); + } + + setSocketPath(socketPath: string): void { + this.transport.setSocketPath(socketPath); + } + + connect(): void { + if (this.connected || this.connecting) { + logger.debug( + `MPV IPC connect request skipped; connected=${this.connected}, connecting=${this.connecting}`, + ); + return; + } + + logger.debug('MPV IPC connect requested.'); + this.connecting = true; + this.transport.connect(); + } + + private scheduleReconnect(): void { + this.reconnectAttempt = scheduleMpvReconnect({ + attempt: this.reconnectAttempt, + hasConnectedOnce: this.hasConnectedOnce, + getReconnectTimer: () => this.deps.getReconnectTimer(), + setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer), + onReconnectAttempt: (attempt, delay) => { + logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`); + }, + connect: () => { + this.connect(); + }, + }); + } + + private processBuffer(): void { + const parsed = splitMpvMessagesFromBuffer( + this.buffer, + (message) => { + this.handleMessage(message); + }, + (line, error) => { + logger.error('Failed to parse MPV message:', line, error); + }, + ); + this.buffer = parsed.nextBuffer; + } + + private async handleMessage(msg: MpvMessage): Promise { + await dispatchMpvProtocolMessage(msg, this.createProtocolMessageDeps()); + } + + private createProtocolMessageDeps(): MpvProtocolHandleMessageDeps { + return { + getResolvedConfig: () => this.deps.getResolvedConfig(), + getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics, + isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(), + emitSubtitleChange: (payload) => { + this.emit('subtitle-change', payload); + }, + emitSubtitleAssChange: (payload) => { + this.emit('subtitle-ass-change', payload); + }, + emitSubtitleTiming: (payload) => { + this.emit('subtitle-timing', payload); + }, + emitTimePosChange: (payload) => { + this.emit('time-pos-change', payload); + }, + emitPauseChange: (payload) => { + this.emit('pause-change', payload); + }, + emitSecondarySubtitleChange: (payload) => { + this.emit('secondary-subtitle-change', payload); + }, + getCurrentSubText: () => this.currentSubText, + setCurrentSubText: (text: string) => { + this.currentSubText = text; + }, + setCurrentSubStart: (value: number) => { + this.currentSubStart = value; + }, + getCurrentSubStart: () => this.currentSubStart, + setCurrentSubEnd: (value: number) => { + this.currentSubEnd = value; + }, + getCurrentSubEnd: () => this.currentSubEnd, + emitMediaPathChange: (payload) => { + this.emit('media-path-change', payload); + }, + emitMediaTitleChange: (payload) => { + this.emit('media-title-change', payload); + }, + emitSubtitleMetricsChange: (patch) => { + this.emitSubtitleMetricsChange(patch); + }, + setCurrentSecondarySubText: (text: string) => { + this.currentSecondarySubText = text; + }, + resolvePendingRequest: (requestId: number, message: MpvMessage) => + this.tryResolvePendingRequest(requestId, message), + setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible), + syncCurrentAudioStreamIndex: () => { + this.syncCurrentAudioStreamIndex(); + }, + setCurrentAudioTrackId: (value: number | null) => { + this.currentAudioTrackId = value; + }, + setCurrentTimePos: (value: number) => { + this.currentTimePos = value; + }, + getCurrentTimePos: () => this.currentTimePos, + getPendingPauseAtSubEnd: () => this.pendingPauseAtSubEnd, + setPendingPauseAtSubEnd: (value: boolean) => { + this.pendingPauseAtSubEnd = value; + }, + getPauseAtTime: () => this.pauseAtTime, + setPauseAtTime: (value: number | null) => { + this.pauseAtTime = value; + }, + autoLoadSecondarySubTrack: () => { + this.autoLoadSecondarySubTrack(); + }, + setCurrentVideoPath: (value: string) => { + this.currentVideoPath = value; + }, + emitSecondarySubtitleVisibility: (payload) => { + this.emit('secondary-subtitle-visibility', payload); + }, + setPreviousSecondarySubVisibility: (visible: boolean) => { + this.previousSecondarySubVisibility = visible; + }, + setCurrentAudioStreamIndex: (tracks) => { + this.updateCurrentAudioStreamIndex(tracks); + }, + sendCommand: (payload) => this.send(payload), + restorePreviousSecondarySubVisibility: () => { + this.restorePreviousSecondarySubVisibility(); + }, + }; + } + + private autoLoadSecondarySubTrack(): void { + const config = this.deps.getResolvedConfig(); + if (!config.secondarySub?.autoLoadSecondarySub) return; + const languages = config.secondarySub.secondarySubLanguages; + if (!languages || languages.length === 0) return; + + setTimeout(() => { + this.send({ + command: ['get_property', 'track-list'], + request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY, + }); + }, 500); + } + + private syncCurrentAudioStreamIndex(): void { + this.send({ + command: ['get_property', 'track-list'], + request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO, + }); + } + + private updateCurrentAudioStreamIndex( + tracks: Array<{ + type?: string; + id?: number; + selected?: boolean; + 'ff-index'?: number; + }>, + ): void { + this.currentAudioStreamIndex = resolveCurrentAudioStreamIndex(tracks, this.currentAudioTrackId); + } + + send(command: { command: unknown[]; request_id?: number }): boolean { + if (!this.connected || !this.socket) { + return false; + } + return this.transport.send(command); + } + + request(command: unknown[]): Promise { + return new Promise((resolve, reject) => { + if (!this.connected || !this.socket) { + reject(new Error('MPV not connected')); + return; + } + + const requestId = this.nextDynamicRequestId++; + this.pendingRequests.set(requestId, resolve); + const sent = this.send({ command, request_id: requestId }); + if (!sent) { + this.pendingRequests.delete(requestId); + reject(new Error('Failed to send MPV request')); + return; + } + + setTimeout(() => { + if (this.pendingRequests.delete(requestId)) { + reject(new Error('MPV request timed out')); + } + }, 4000); + }); + } + + async requestProperty(name: string): Promise { + const response = await this.request(['get_property', name]); + if (response.error && response.error !== 'success') { + throw new Error(`Failed to read MPV property '${name}': ${response.error}`); + } + return response.data; + } + + private failPendingRequests(): void { + for (const [requestId, resolve] of this.pendingRequests.entries()) { + resolve({ request_id: requestId, error: 'disconnected' }); + } + this.pendingRequests.clear(); + } + + private tryResolvePendingRequest(requestId: number, message: MpvMessage): boolean { + const pending = this.pendingRequests.get(requestId); + if (!pending) { + return false; + } + this.pendingRequests.delete(requestId); + pending(message); + return true; + } + + setSubVisibility(visible: boolean): void { + this.send({ + command: ['set_property', 'sub-visibility', visible ? 'yes' : 'no'], + }); + } + + replayCurrentSubtitle(): void { + this.pendingPauseAtSubEnd = true; + this.send({ command: ['sub-seek', 0] }); + } + + playNextSubtitle(): void { + this.pendingPauseAtSubEnd = true; + this.send({ command: ['sub-seek', 1] }); + } + + restorePreviousSecondarySubVisibility(): void { + const previous = this.previousSecondarySubVisibility; + if (previous === null) return; + this.send({ + command: ['set_property', 'secondary-sub-visibility', previous ? 'yes' : 'no'], + }); + this.previousSecondarySubVisibility = null; + } + + private setSecondarySubVisibility(visible: boolean): void { + this.send({ + command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'], + }); + } +} diff --git a/src/core/services/numeric-shortcut-session.test.ts b/src/core/services/numeric-shortcut-session.test.ts new file mode 100644 index 0000000..4f98492 --- /dev/null +++ b/src/core/services/numeric-shortcut-session.test.ts @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNumericShortcutRuntime, createNumericShortcutSession } from './numeric-shortcut'; + +test('createNumericShortcutRuntime creates sessions wired to globalShortcut', () => { + const registered: string[] = []; + const unregistered: string[] = []; + const osd: string[] = []; + const handlers = new Map void>(); + + const runtime = createNumericShortcutRuntime({ + globalShortcut: { + register: (accelerator, callback) => { + registered.push(accelerator); + handlers.set(accelerator, callback); + return true; + }, + unregister: (accelerator) => { + unregistered.push(accelerator); + handlers.delete(accelerator); + }, + }, + showMpvOsd: (text) => { + osd.push(text); + }, + setTimer: () => setTimeout(() => {}, 1000), + clearTimer: (timer) => clearTimeout(timer), + }); + + const session = runtime.createSession(); + session.start({ + timeoutMs: 5000, + onDigit: () => {}, + messages: { + prompt: 'Select count', + timeout: 'Timed out', + }, + }); + + assert.equal(session.isActive(), true); + assert.ok(registered.includes('1')); + assert.ok(registered.includes('Escape')); + assert.equal(osd[0], 'Select count'); + + handlers.get('Escape')?.(); + assert.equal(session.isActive(), false); + assert.ok(unregistered.includes('Escape')); +}); + +test('numeric shortcut session handles digit selection and unregisters shortcuts', () => { + const handlers = new Map void>(); + const unregistered: string[] = []; + const osd: string[] = []; + const session = createNumericShortcutSession({ + registerShortcut: (accelerator, handler) => { + handlers.set(accelerator, handler); + return true; + }, + unregisterShortcut: (accelerator) => { + unregistered.push(accelerator); + handlers.delete(accelerator); + }, + setTimer: () => setTimeout(() => {}, 0), + clearTimer: (timer) => clearTimeout(timer), + showMpvOsd: (text) => { + osd.push(text); + }, + }); + + const digits: number[] = []; + session.start({ + timeoutMs: 5000, + onDigit: (digit) => { + digits.push(digit); + }, + messages: { + prompt: 'Pick a digit', + timeout: 'Timed out', + }, + }); + + assert.equal(session.isActive(), true); + assert.equal(osd[0], 'Pick a digit'); + assert.ok(handlers.has('3')); + handlers.get('3')?.(); + + assert.deepEqual(digits, [3]); + assert.equal(session.isActive(), false); + assert.ok(unregistered.includes('Escape')); + assert.ok(unregistered.includes('1')); + assert.ok(unregistered.includes('9')); +}); + +test('numeric shortcut session emits timeout message', () => { + const osd: string[] = []; + const session = createNumericShortcutSession({ + registerShortcut: () => true, + unregisterShortcut: () => {}, + setTimer: (handler) => { + handler(); + return setTimeout(() => {}, 0); + }, + clearTimer: (timer) => clearTimeout(timer), + showMpvOsd: (text) => { + osd.push(text); + }, + }); + + session.start({ + timeoutMs: 5000, + onDigit: () => {}, + messages: { + prompt: 'Pick a digit', + timeout: 'Timed out', + cancelled: 'Aborted', + }, + }); + + assert.equal(session.isActive(), false); + assert.ok(osd.includes('Timed out')); +}); + +test('numeric shortcut session handles escape cancellation', () => { + const handlers = new Map void>(); + const osd: string[] = []; + const session = createNumericShortcutSession({ + registerShortcut: (accelerator, handler) => { + handlers.set(accelerator, handler); + return true; + }, + unregisterShortcut: (accelerator) => { + handlers.delete(accelerator); + }, + setTimer: () => setTimeout(() => {}, 10000), + clearTimer: (timer) => clearTimeout(timer), + showMpvOsd: (text) => { + osd.push(text); + }, + }); + + session.start({ + timeoutMs: 5000, + onDigit: () => {}, + messages: { + prompt: 'Pick a digit', + timeout: 'Timed out', + cancelled: 'Aborted', + }, + }); + handlers.get('Escape')?.(); + assert.equal(session.isActive(), false); + assert.ok(osd.includes('Aborted')); +}); diff --git a/src/core/services/numeric-shortcut.ts b/src/core/services/numeric-shortcut.ts new file mode 100644 index 0000000..4098b26 --- /dev/null +++ b/src/core/services/numeric-shortcut.ts @@ -0,0 +1,121 @@ +interface GlobalShortcutLike { + register: (accelerator: string, callback: () => void) => boolean; + unregister: (accelerator: string) => void; +} + +export interface NumericShortcutRuntimeOptions { + globalShortcut: GlobalShortcutLike; + showMpvOsd: (text: string) => void; + setTimer: (handler: () => void, timeoutMs: number) => ReturnType; + clearTimer: (timer: ReturnType) => void; +} + +export function createNumericShortcutRuntime(options: NumericShortcutRuntimeOptions) { + const createSession = () => + createNumericShortcutSession({ + registerShortcut: (accelerator, handler) => + options.globalShortcut.register(accelerator, handler), + unregisterShortcut: (accelerator) => options.globalShortcut.unregister(accelerator), + setTimer: options.setTimer, + clearTimer: options.clearTimer, + showMpvOsd: options.showMpvOsd, + }); + + return { + createSession, + }; +} + +export interface NumericShortcutSessionMessages { + prompt: string; + timeout: string; + cancelled?: string; +} + +export interface NumericShortcutSessionDeps { + registerShortcut: (accelerator: string, handler: () => void) => boolean; + unregisterShortcut: (accelerator: string) => void; + setTimer: (handler: () => void, timeoutMs: number) => ReturnType; + clearTimer: (timer: ReturnType) => void; + showMpvOsd: (text: string) => void; +} + +export interface NumericShortcutSessionStartParams { + timeoutMs: number; + onDigit: (digit: number) => void; + messages: NumericShortcutSessionMessages; +} + +export function createNumericShortcutSession(deps: NumericShortcutSessionDeps) { + let active = false; + let timeout: ReturnType | null = null; + let digitShortcuts: string[] = []; + let escapeShortcut: string | null = null; + + let cancelledMessage = 'Cancelled'; + + const cancel = (showCancelled = false): void => { + if (!active) return; + active = false; + + if (timeout) { + deps.clearTimer(timeout); + timeout = null; + } + + for (const shortcut of digitShortcuts) { + deps.unregisterShortcut(shortcut); + } + digitShortcuts = []; + + if (escapeShortcut) { + deps.unregisterShortcut(escapeShortcut); + escapeShortcut = null; + } + + if (showCancelled) { + deps.showMpvOsd(cancelledMessage); + } + }; + + const start = ({ timeoutMs, onDigit, messages }: NumericShortcutSessionStartParams): void => { + cancel(); + cancelledMessage = messages.cancelled ?? 'Cancelled'; + active = true; + + for (let i = 1; i <= 9; i++) { + const shortcut = i.toString(); + if ( + deps.registerShortcut(shortcut, () => { + if (!active) return; + cancel(); + onDigit(i); + }) + ) { + digitShortcuts.push(shortcut); + } + } + + if ( + deps.registerShortcut('Escape', () => { + cancel(true); + }) + ) { + escapeShortcut = 'Escape'; + } + + timeout = deps.setTimer(() => { + if (!active) return; + cancel(); + deps.showMpvOsd(messages.timeout); + }, timeoutMs); + + deps.showMpvOsd(messages.prompt); + }; + + return { + start, + cancel, + isActive: (): boolean => active, + }; +} diff --git a/src/core/services/overlay-bridge.test.ts b/src/core/services/overlay-bridge.test.ts new file mode 100644 index 0000000..3a46d15 --- /dev/null +++ b/src/core/services/overlay-bridge.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { KikuFieldGroupingChoice } from '../../types'; +import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge'; + +test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => { + const sent: unknown[][] = []; + const restoreSet = new Set<'runtime-options' | 'subsync'>(); + let visibleOverlayVisible = false; + + const ok = sendToVisibleOverlayRuntime({ + mainWindow: { + isDestroyed: () => false, + webContents: { + isLoading: () => false, + send: (...args: unknown[]) => { + sent.push(args); + }, + }, + } as unknown as Electron.BrowserWindow, + visibleOverlayVisible, + setVisibleOverlayVisible: (visible: boolean) => { + visibleOverlayVisible = visible; + }, + channel: 'runtime-options:open', + restoreOnModalClose: 'runtime-options', + restoreVisibleOverlayOnModalClose: restoreSet, + }); + + assert.equal(ok, true); + assert.equal(visibleOverlayVisible, true); + assert.equal(restoreSet.has('runtime-options'), true); + assert.deepEqual(sent, [['runtime-options:open']]); +}); + +test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => { + let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; + const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({ + getVisibleOverlayVisible: () => false, + getInvisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + setInvisibleOverlayVisible: () => {}, + getResolver: () => resolver, + setResolver: (next) => { + resolver = next; + }, + sendToVisibleOverlay: () => false, + }); + + const result = await callback({ + original: { + noteId: 1, + expression: 'a', + sentencePreview: 'a', + hasAudio: false, + hasImage: false, + isOriginal: true, + }, + duplicate: { + noteId: 2, + expression: 'b', + sentencePreview: 'b', + hasAudio: false, + hasImage: false, + isOriginal: false, + }, + }); + + assert.equal(result.cancelled, true); + assert.equal(result.keepNoteId, 0); + assert.equal(result.deleteNoteId, 0); +}); diff --git a/src/core/services/overlay-bridge.ts b/src/core/services/overlay-bridge.ts new file mode 100644 index 0000000..a5fc509 --- /dev/null +++ b/src/core/services/overlay-bridge.ts @@ -0,0 +1,68 @@ +import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types'; +import { createFieldGroupingCallback } from './field-grouping'; +import { BrowserWindow } from 'electron'; + +export function sendToVisibleOverlayRuntime(options: { + mainWindow: BrowserWindow | null; + visibleOverlayVisible: boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + channel: string; + payload?: unknown; + restoreOnModalClose?: T; + restoreVisibleOverlayOnModalClose: Set; +}): boolean { + if (!options.mainWindow || options.mainWindow.isDestroyed()) return false; + const wasVisible = options.visibleOverlayVisible; + if (!options.visibleOverlayVisible) { + options.setVisibleOverlayVisible(true); + } + if (!wasVisible && options.restoreOnModalClose) { + options.restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose); + } + const sendNow = (): void => { + if (options.payload === undefined) { + options.mainWindow!.webContents.send(options.channel); + } else { + options.mainWindow!.webContents.send(options.channel, options.payload); + } + }; + if (options.mainWindow.webContents.isLoading()) { + options.mainWindow.webContents.once('did-finish-load', () => { + if ( + options.mainWindow && + !options.mainWindow.isDestroyed() && + !options.mainWindow.webContents.isLoading() + ) { + sendNow(); + } + }); + return true; + } + sendNow(); + return true; +} + +export function createFieldGroupingCallbackRuntime(options: { + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + setInvisibleOverlayVisible: (visible: boolean) => void; + getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; + setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; + sendToVisibleOverlay: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: T }, + ) => boolean; +}): (data: KikuFieldGroupingRequestData) => Promise { + return createFieldGroupingCallback({ + getVisibleOverlayVisible: options.getVisibleOverlayVisible, + getInvisibleOverlayVisible: options.getInvisibleOverlayVisible, + setVisibleOverlayVisible: options.setVisibleOverlayVisible, + setInvisibleOverlayVisible: options.setInvisibleOverlayVisible, + getResolver: options.getResolver, + setResolver: options.setResolver, + sendRequestToVisibleOverlay: (data) => + options.sendToVisibleOverlay('kiku:field-grouping-request', data), + }); +} diff --git a/src/core/services/overlay-content-measurement.test.ts b/src/core/services/overlay-content-measurement.test.ts new file mode 100644 index 0000000..43e0fa4 --- /dev/null +++ b/src/core/services/overlay-content-measurement.test.ts @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createOverlayContentMeasurementStore, + sanitizeOverlayContentMeasurement, +} from './overlay-content-measurement'; + +test('sanitizeOverlayContentMeasurement accepts valid payload with null rect', () => { + const measurement = sanitizeOverlayContentMeasurement( + { + layer: 'visible', + measuredAtMs: 100, + viewport: { width: 1920, height: 1080 }, + contentRect: null, + }, + 500, + ); + + assert.deepEqual(measurement, { + layer: 'visible', + measuredAtMs: 100, + viewport: { width: 1920, height: 1080 }, + contentRect: null, + }); +}); + +test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => { + const measurement = sanitizeOverlayContentMeasurement( + { + layer: 'invisible', + measuredAtMs: 100, + viewport: { width: 0, height: 1080 }, + contentRect: { x: 0, y: 0, width: 100, height: 20 }, + }, + 500, + ); + + assert.equal(measurement, null); +}); + +test('overlay measurement store keeps latest payload per layer', () => { + const store = createOverlayContentMeasurementStore({ + now: () => 1000, + warn: () => { + // noop + }, + }); + + const visible = store.report({ + layer: 'visible', + measuredAtMs: 900, + viewport: { width: 1280, height: 720 }, + contentRect: { x: 50, y: 60, width: 400, height: 80 }, + }); + const invisible = store.report({ + layer: 'invisible', + measuredAtMs: 910, + viewport: { width: 1280, height: 720 }, + contentRect: { x: 20, y: 30, width: 300, height: 40 }, + }); + + assert.equal(visible?.layer, 'visible'); + assert.equal(invisible?.layer, 'invisible'); + assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400); + assert.equal(store.getLatestByLayer('invisible')?.contentRect?.height, 40); +}); + +test('overlay measurement store rate-limits invalid payload warnings', () => { + let now = 1_000; + const warnings: string[] = []; + const store = createOverlayContentMeasurementStore({ + now: () => now, + warn: (message) => { + warnings.push(message); + }, + }); + + store.report({ layer: 'visible' }); + store.report({ layer: 'visible' }); + assert.equal(warnings.length, 0); + + now = 11_000; + store.report({ layer: 'visible' }); + assert.equal(warnings.length, 1); + assert.match(warnings[0]!, /Dropped 3 invalid measurement payload/); +}); diff --git a/src/core/services/overlay-content-measurement.ts b/src/core/services/overlay-content-measurement.ts new file mode 100644 index 0000000..4e86823 --- /dev/null +++ b/src/core/services/overlay-content-measurement.ts @@ -0,0 +1,148 @@ +import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from '../../types'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:overlay-content-measurement'); +const MAX_VIEWPORT = 10000; +const MAX_RECT_DIMENSION = 10000; +const MAX_RECT_OFFSET = 50000; +const MAX_FUTURE_TIMESTAMP_MS = 60_000; +const INVALID_LOG_THROTTLE_MS = 10_000; + +type OverlayMeasurementStore = Record; + +export function sanitizeOverlayContentMeasurement( + payload: unknown, + nowMs: number, +): OverlayContentMeasurement | null { + if (!payload || typeof payload !== 'object') return null; + + const candidate = payload as { + layer?: unknown; + measuredAtMs?: unknown; + viewport?: { width?: unknown; height?: unknown }; + contentRect?: { + x?: unknown; + y?: unknown; + width?: unknown; + height?: unknown; + } | null; + }; + + if (candidate.layer !== 'visible' && candidate.layer !== 'invisible') { + return null; + } + + const viewportWidth = readFiniteInRange(candidate.viewport?.width, 1, MAX_VIEWPORT); + const viewportHeight = readFiniteInRange(candidate.viewport?.height, 1, MAX_VIEWPORT); + + if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) { + return null; + } + + const measuredAtMs = readFiniteInRange( + candidate.measuredAtMs, + 1, + nowMs + MAX_FUTURE_TIMESTAMP_MS, + ); + if (!Number.isFinite(measuredAtMs)) { + return null; + } + + const contentRect = sanitizeOverlayContentRect(candidate.contentRect); + if (candidate.contentRect !== null && !contentRect) { + return null; + } + + return { + layer: candidate.layer, + measuredAtMs, + viewport: { width: viewportWidth, height: viewportHeight }, + contentRect, + }; +} + +function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null { + if (rect === null || rect === undefined) { + return null; + } + + if (!rect || typeof rect !== 'object') { + return null; + } + + const candidate = rect as { + x?: unknown; + y?: unknown; + width?: unknown; + height?: unknown; + }; + + const width = readFiniteInRange(candidate.width, 0, MAX_RECT_DIMENSION); + const height = readFiniteInRange(candidate.height, 0, MAX_RECT_DIMENSION); + const x = readFiniteInRange(candidate.x, -MAX_RECT_OFFSET, MAX_RECT_OFFSET); + const y = readFiniteInRange(candidate.y, -MAX_RECT_OFFSET, MAX_RECT_OFFSET); + + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + !Number.isFinite(x) || + !Number.isFinite(y) + ) { + return null; + } + + return { x, y, width, height }; +} + +function readFiniteInRange(value: unknown, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return Number.NaN; + } + if (value < min || value > max) { + return Number.NaN; + } + return value; +} + +export function createOverlayContentMeasurementStore(options?: { + now?: () => number; + warn?: (message: string) => void; +}) { + const now = options?.now ?? (() => Date.now()); + const warn = options?.warn ?? ((message: string) => logger.warn(message)); + const latestByLayer: OverlayMeasurementStore = { + visible: null, + invisible: null, + }; + + let droppedInvalid = 0; + let lastInvalidLogAtMs = 0; + + function report(payload: unknown): OverlayContentMeasurement | null { + const nowMs = now(); + const measurement = sanitizeOverlayContentMeasurement(payload, nowMs); + if (!measurement) { + droppedInvalid += 1; + if (droppedInvalid > 0 && nowMs - lastInvalidLogAtMs >= INVALID_LOG_THROTTLE_MS) { + warn( + `[overlay-content-bounds] Dropped ${droppedInvalid} invalid measurement payload(s) in the last ${INVALID_LOG_THROTTLE_MS}ms.`, + ); + droppedInvalid = 0; + lastInvalidLogAtMs = nowMs; + } + return null; + } + + latestByLayer[measurement.layer] = measurement; + return measurement; + } + + function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null { + return latestByLayer[layer]; + } + + return { + getLatestByLayer, + report, + }; +} diff --git a/src/core/services/overlay-drop.test.ts b/src/core/services/overlay-drop.test.ts new file mode 100644 index 0000000..a1e0806 --- /dev/null +++ b/src/core/services/overlay-drop.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildMpvLoadfileCommands, + collectDroppedVideoPaths, + parseClipboardVideoPath, + type DropDataTransferLike, +} from './overlay-drop'; + +function makeTransfer(data: Partial): DropDataTransferLike { + return { + files: data.files, + getData: data.getData, + }; +} + +test('collectDroppedVideoPaths keeps supported dropped file paths in order', () => { + const transfer = makeTransfer({ + files: [ + { path: '/videos/ep02.mkv' }, + { path: '/videos/notes.txt' }, + { path: '/videos/ep03.MP4' }, + ], + }); + + const result = collectDroppedVideoPaths(transfer); + + assert.deepEqual(result, ['/videos/ep02.mkv', '/videos/ep03.MP4']); +}); + +test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates', () => { + const transfer = makeTransfer({ + getData: (format: string) => + format === 'text/uri-list' + ? '#comment\nfile:///tmp/ep01.mkv\nfile:///tmp/ep01.mkv\nfile:///tmp/ep02.webm\nfile:///tmp/readme.md\n' + : '', + }); + + const result = collectDroppedVideoPaths(transfer); + + assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']); +}); + +test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => { + const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false); + + assert.deepEqual(commands, [ + ['loadfile', '/tmp/ep01.mkv', 'replace'], + ['loadfile', '/tmp/ep02.mkv', 'append'], + ]); +}); + +test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () => { + const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], true); + + assert.deepEqual(commands, [ + ['loadfile', '/tmp/ep01.mkv', 'append'], + ['loadfile', '/tmp/ep02.mkv', 'append'], + ]); +}); + +test('parseClipboardVideoPath accepts quoted local paths', () => { + assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv'); +}); + +test('parseClipboardVideoPath accepts file URI and rejects non-video', () => { + assert.equal(parseClipboardVideoPath('file:///tmp/ep11.mp4'), '/tmp/ep11.mp4'); + assert.equal(parseClipboardVideoPath('/tmp/notes.txt'), null); +}); diff --git a/src/core/services/overlay-drop.ts b/src/core/services/overlay-drop.ts new file mode 100644 index 0000000..8698307 --- /dev/null +++ b/src/core/services/overlay-drop.ts @@ -0,0 +1,130 @@ +export type DropFileLike = { path?: string } | { name: string }; + +export interface DropDataTransferLike { + files?: ArrayLike; + getData?: (format: string) => string; +} + +const VIDEO_EXTENSIONS = new Set([ + '.3gp', + '.avi', + '.flv', + '.m2ts', + '.m4v', + '.mkv', + '.mov', + '.mp4', + '.mpeg', + '.mpg', + '.mts', + '.ts', + '.webm', + '.wmv', +]); + +function getPathExtension(pathValue: string): string { + const normalized = pathValue.split(/[?#]/, 1)[0] ?? ''; + const dot = normalized.lastIndexOf('.'); + return dot >= 0 ? normalized.slice(dot).toLowerCase() : ''; +} + +function isSupportedVideoPath(pathValue: string): boolean { + return VIDEO_EXTENSIONS.has(getPathExtension(pathValue)); +} + +function parseUriList(data: string): string[] { + if (!data.trim()) return []; + const out: string[] = []; + + for (const line of data.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (!trimmed.toLowerCase().startsWith('file://')) continue; + + try { + const parsed = new URL(trimmed); + let filePath = decodeURIComponent(parsed.pathname); + if (/^\/[A-Za-z]:\//.test(filePath)) { + filePath = filePath.slice(1); + } + if (filePath && isSupportedVideoPath(filePath)) { + out.push(filePath); + } + } catch { + continue; + } + } + + return out; +} + +export function parseClipboardVideoPath(text: string): string | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + const unquoted = + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ? trimmed.slice(1, -1).trim() + : trimmed; + if (!unquoted) return null; + + if (unquoted.toLowerCase().startsWith('file://')) { + try { + const parsed = new URL(unquoted); + let filePath = decodeURIComponent(parsed.pathname); + if (/^\/[A-Za-z]:\//.test(filePath)) { + filePath = filePath.slice(1); + } + return filePath && isSupportedVideoPath(filePath) ? filePath : null; + } catch { + return null; + } + } + + return isSupportedVideoPath(unquoted) ? unquoted : null; +} + +export function collectDroppedVideoPaths(dataTransfer: DropDataTransferLike | null | undefined): string[] { + if (!dataTransfer) return []; + + const out: string[] = []; + const seen = new Set(); + + const addPath = (candidate: string | null | undefined): void => { + if (!candidate) return; + const trimmed = candidate.trim(); + if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return; + seen.add(trimmed); + out.push(trimmed); + }; + + if (dataTransfer.files) { + for (let i = 0; i < dataTransfer.files.length; i += 1) { + const file = dataTransfer.files[i] as { path?: string } | undefined; + addPath(file?.path); + } + } + + if (typeof dataTransfer.getData === 'function') { + for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) { + addPath(pathValue); + } + } + + return out; +} + +export function buildMpvLoadfileCommands( + paths: string[], + append: boolean, +): Array<(string | number)[]> { + if (append) { + return paths.map((pathValue) => ['loadfile', pathValue, 'append']); + } + return paths.map((pathValue, index) => [ + 'loadfile', + pathValue, + index === 0 ? 'replace' : 'append', + ]); +} diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts new file mode 100644 index 0000000..1a45d52 --- /dev/null +++ b/src/core/services/overlay-manager.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + broadcastRuntimeOptionsChangedRuntime, + createOverlayManager, + setOverlayDebugVisualizationEnabledRuntime, +} from './overlay-manager'; + +test('overlay manager initializes with empty windows and hidden overlays', () => { + const manager = createOverlayManager(); + assert.equal(manager.getMainWindow(), null); + assert.equal(manager.getInvisibleWindow(), null); + assert.equal(manager.getSecondaryWindow(), null); + assert.equal(manager.getVisibleOverlayVisible(), false); + assert.equal(manager.getInvisibleOverlayVisible(), false); + assert.deepEqual(manager.getOverlayWindows(), []); +}); + +test('overlay manager stores window references and returns stable window order', () => { + const manager = createOverlayManager(); + const visibleWindow = { + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + const invisibleWindow = { + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + const secondaryWindow = { + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + + manager.setMainWindow(visibleWindow); + manager.setInvisibleWindow(invisibleWindow); + manager.setSecondaryWindow(secondaryWindow); + + assert.equal(manager.getMainWindow(), visibleWindow); + assert.equal(manager.getInvisibleWindow(), invisibleWindow); + assert.equal(manager.getSecondaryWindow(), secondaryWindow); + assert.equal(manager.getOverlayWindow('visible'), visibleWindow); + assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow); + assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]); +}); + +test('overlay manager excludes destroyed windows', () => { + const manager = createOverlayManager(); + manager.setMainWindow({ + isDestroyed: () => true, + } as unknown as Electron.BrowserWindow); + manager.setInvisibleWindow({ + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow); + manager.setSecondaryWindow({ + isDestroyed: () => true, + } as unknown as Electron.BrowserWindow); + + assert.equal(manager.getOverlayWindows().length, 1); +}); + +test('overlay manager stores visibility state', () => { + const manager = createOverlayManager(); + + manager.setVisibleOverlayVisible(true); + manager.setInvisibleOverlayVisible(true); + assert.equal(manager.getVisibleOverlayVisible(), true); + assert.equal(manager.getInvisibleOverlayVisible(), true); +}); + +test('overlay manager broadcasts to non-destroyed windows', () => { + const manager = createOverlayManager(); + const calls: unknown[][] = []; + const aliveWindow = { + isDestroyed: () => false, + webContents: { + send: (...args: unknown[]) => { + calls.push(args); + }, + }, + } as unknown as Electron.BrowserWindow; + const deadWindow = { + isDestroyed: () => true, + webContents: { + send: (..._args: unknown[]) => {}, + }, + } as unknown as Electron.BrowserWindow; + const secondaryWindow = { + isDestroyed: () => false, + webContents: { + send: (...args: unknown[]) => { + calls.push(args); + }, + }, + } as unknown as Electron.BrowserWindow; + + manager.setMainWindow(aliveWindow); + manager.setInvisibleWindow(deadWindow); + manager.setSecondaryWindow(secondaryWindow); + manager.broadcastToOverlayWindows('x', 1, 'a'); + + assert.deepEqual(calls, [ + ['x', 1, 'a'], + ['x', 1, 'a'], + ]); +}); + +test('overlay manager applies bounds by layer', () => { + const manager = createOverlayManager(); + const visibleCalls: Electron.Rectangle[] = []; + const invisibleCalls: Electron.Rectangle[] = []; + const visibleWindow = { + isDestroyed: () => false, + setBounds: (bounds: Electron.Rectangle) => { + visibleCalls.push(bounds); + }, + } as unknown as Electron.BrowserWindow; + const invisibleWindow = { + isDestroyed: () => false, + setBounds: (bounds: Electron.Rectangle) => { + invisibleCalls.push(bounds); + }, + } as unknown as Electron.BrowserWindow; + const secondaryWindow = { + isDestroyed: () => false, + setBounds: (bounds: Electron.Rectangle) => { + invisibleCalls.push(bounds); + }, + } as unknown as Electron.BrowserWindow; + manager.setMainWindow(visibleWindow); + manager.setInvisibleWindow(invisibleWindow); + manager.setSecondaryWindow(secondaryWindow); + + manager.setOverlayWindowBounds('visible', { + x: 10, + y: 20, + width: 30, + height: 40, + }); + manager.setOverlayWindowBounds('invisible', { + x: 1, + y: 2, + width: 3, + height: 4, + }); + manager.setSecondaryWindowBounds({ + x: 8, + y: 9, + width: 10, + height: 11, + }); + + assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]); + assert.deepEqual(invisibleCalls, [ + { x: 1, y: 2, width: 3, height: 4 }, + { x: 8, y: 9, width: 10, height: 11 }, + ]); +}); + +test('runtime-option and debug broadcasts use expected channels', () => { + const broadcasts: unknown[][] = []; + broadcastRuntimeOptionsChangedRuntime( + () => [], + (channel, ...args) => { + broadcasts.push([channel, ...args]); + }, + ); + let state = false; + const changed = setOverlayDebugVisualizationEnabledRuntime( + state, + true, + (enabled) => { + state = enabled; + }, + (channel, ...args) => { + broadcasts.push([channel, ...args]); + }, + ); + assert.equal(changed, true); + assert.equal(state, true); + assert.deepEqual(broadcasts, [ + ['runtime-options:changed', []], + ['overlay-debug-visualization:set', true], + ]); +}); diff --git a/src/core/services/overlay-manager.ts b/src/core/services/overlay-manager.ts new file mode 100644 index 0000000..42633c6 --- /dev/null +++ b/src/core/services/overlay-manager.ts @@ -0,0 +1,108 @@ +import { BrowserWindow } from 'electron'; +import { RuntimeOptionState, WindowGeometry } from '../../types'; +import { updateOverlayWindowBounds } from './overlay-window'; + +type OverlayLayer = 'visible' | 'invisible'; + +export interface OverlayManager { + getMainWindow: () => BrowserWindow | null; + setMainWindow: (window: BrowserWindow | null) => void; + getInvisibleWindow: () => BrowserWindow | null; + setInvisibleWindow: (window: BrowserWindow | null) => void; + getSecondaryWindow: () => BrowserWindow | null; + setSecondaryWindow: (window: BrowserWindow | null) => void; + getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null; + setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void; + setSecondaryWindowBounds: (geometry: WindowGeometry) => void; + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + getInvisibleOverlayVisible: () => boolean; + setInvisibleOverlayVisible: (visible: boolean) => void; + getOverlayWindows: () => BrowserWindow[]; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; +} + +export function createOverlayManager(): OverlayManager { + let mainWindow: BrowserWindow | null = null; + let invisibleWindow: BrowserWindow | null = null; + let secondaryWindow: BrowserWindow | null = null; + let visibleOverlayVisible = false; + let invisibleOverlayVisible = false; + + return { + getMainWindow: () => mainWindow, + setMainWindow: (window) => { + mainWindow = window; + }, + getInvisibleWindow: () => invisibleWindow, + setInvisibleWindow: (window) => { + invisibleWindow = window; + }, + getSecondaryWindow: () => secondaryWindow, + setSecondaryWindow: (window) => { + secondaryWindow = window; + }, + getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow), + setOverlayWindowBounds: (layer, geometry) => { + updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow); + }, + setSecondaryWindowBounds: (geometry) => { + updateOverlayWindowBounds(geometry, secondaryWindow); + }, + getVisibleOverlayVisible: () => visibleOverlayVisible, + setVisibleOverlayVisible: (visible) => { + visibleOverlayVisible = visible; + }, + getInvisibleOverlayVisible: () => invisibleOverlayVisible, + setInvisibleOverlayVisible: (visible) => { + invisibleOverlayVisible = visible; + }, + getOverlayWindows: () => { + const windows: BrowserWindow[] = []; + if (mainWindow && !mainWindow.isDestroyed()) { + windows.push(mainWindow); + } + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + windows.push(invisibleWindow); + } + if (secondaryWindow && !secondaryWindow.isDestroyed()) { + windows.push(secondaryWindow); + } + return windows; + }, + broadcastToOverlayWindows: (channel, ...args) => { + const windows: BrowserWindow[] = []; + if (mainWindow && !mainWindow.isDestroyed()) { + windows.push(mainWindow); + } + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + windows.push(invisibleWindow); + } + if (secondaryWindow && !secondaryWindow.isDestroyed()) { + windows.push(secondaryWindow); + } + for (const window of windows) { + window.webContents.send(channel, ...args); + } + }, + }; +} + +export function broadcastRuntimeOptionsChangedRuntime( + getRuntimeOptionsState: () => RuntimeOptionState[], + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, +): void { + broadcastToOverlayWindows('runtime-options:changed', getRuntimeOptionsState()); +} + +export function setOverlayDebugVisualizationEnabledRuntime( + currentEnabled: boolean, + nextEnabled: boolean, + setState: (enabled: boolean) => void, + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, +): boolean { + if (currentEnabled === nextEnabled) return false; + setState(nextEnabled); + broadcastToOverlayWindows('overlay-debug-visualization:set', nextEnabled); + return true; +} diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts new file mode 100644 index 0000000..6bd1db2 --- /dev/null +++ b/src/core/services/overlay-runtime-init.ts @@ -0,0 +1,107 @@ +import { BrowserWindow } from 'electron'; +import { AnkiIntegration } from '../../anki-integration'; +import { BaseWindowTracker, createWindowTracker } from '../../window-trackers'; +import { + AnkiConnectConfig, + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, + WindowGeometry, +} from '../../types'; + +export function initializeOverlayRuntime(options: { + backendOverride: string | null; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + isVisibleOverlayVisible: () => boolean; + isInvisibleOverlayVisible: () => boolean; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + setWindowTracker: (tracker: BaseWindowTracker | null) => void; + getMpvSocketPath: () => string; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + 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; + getKnownWordCacheStatePath: () => string; +}): { + invisibleOverlayVisible: boolean; +} { + options.createMainWindow(); + options.createInvisibleWindow(); + const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility(); + options.registerGlobalShortcuts(); + + const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath()); + options.setWindowTracker(windowTracker); + if (windowTracker) { + windowTracker.onGeometryChange = (geometry: WindowGeometry) => { + options.updateVisibleOverlayBounds(geometry); + options.updateInvisibleOverlayBounds(geometry); + }; + windowTracker.onWindowFound = (geometry: WindowGeometry) => { + options.updateVisibleOverlayBounds(geometry); + options.updateInvisibleOverlayBounds(geometry); + if (options.isVisibleOverlayVisible()) { + options.updateVisibleOverlayVisibility(); + } + if (options.isInvisibleOverlayVisible()) { + options.updateInvisibleOverlayVisibility(); + } + }; + windowTracker.onWindowLost = () => { + for (const window of options.getOverlayWindows()) { + window.hide(); + } + options.syncOverlayShortcuts(); + }; + windowTracker.start(); + } + + const config = options.getResolvedConfig(); + const subtitleTimingTracker = options.getSubtitleTimingTracker(); + const mpvClient = options.getMpvClient(); + const runtimeOptionsManager = options.getRuntimeOptionsManager(); + + if (config.ankiConnect && subtitleTimingTracker && mpvClient && runtimeOptionsManager) { + const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig( + config.ankiConnect, + ); + const integration = new AnkiIntegration( + effectiveAnkiConfig, + subtitleTimingTracker as never, + mpvClient as never, + (text: string) => { + if (mpvClient && typeof mpvClient.send === 'function') { + mpvClient.send({ + command: ['show-text', text, '3000'], + }); + } + }, + options.showDesktopNotification, + options.createFieldGroupingCallback(), + options.getKnownWordCacheStatePath(), + ); + integration.start(); + options.setAnkiIntegration(integration); + } + + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + + return { invisibleOverlayVisible }; +} diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts new file mode 100644 index 0000000..72a5a5c --- /dev/null +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -0,0 +1,282 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ConfiguredShortcuts } from '../utils/shortcut-config'; +import { + createOverlayShortcutRuntimeHandlers, + OverlayShortcutRuntimeDeps, + runOverlayShortcutLocalFallback, +} from './overlay-shortcut-handler'; + +function makeShortcuts(overrides: Partial = {}): ConfiguredShortcuts { + return { + toggleVisibleOverlayGlobal: null, + toggleInvisibleOverlayGlobal: null, + copySubtitle: null, + copySubtitleMultiple: null, + updateLastCardFromClipboard: null, + triggerFieldGrouping: null, + triggerSubsync: null, + mineSentence: null, + mineSentenceMultiple: null, + multiCopyTimeoutMs: 2500, + toggleSecondarySub: null, + markAudioCard: null, + openRuntimeOptions: null, + openJimaku: null, + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}) { + const calls: string[] = []; + const osd: string[] = []; + const deps: OverlayShortcutRuntimeDeps = { + showMpvOsd: (text) => { + osd.push(text); + }, + openRuntimeOptions: () => { + calls.push('openRuntimeOptions'); + }, + openJimaku: () => { + calls.push('openJimaku'); + }, + markAudioCard: async () => { + calls.push('markAudioCard'); + }, + copySubtitleMultiple: (timeoutMs) => { + calls.push(`copySubtitleMultiple:${timeoutMs}`); + }, + copySubtitle: () => { + calls.push('copySubtitle'); + }, + toggleSecondarySub: () => { + calls.push('toggleSecondarySub'); + }, + updateLastCardFromClipboard: async () => { + calls.push('updateLastCardFromClipboard'); + }, + triggerFieldGrouping: async () => { + calls.push('triggerFieldGrouping'); + }, + triggerSubsync: async () => { + calls.push('triggerSubsync'); + }, + mineSentence: async () => { + calls.push('mineSentence'); + }, + mineSentenceMultiple: (timeoutMs) => { + calls.push(`mineSentenceMultiple:${timeoutMs}`); + }, + ...overrides, + }; + + return { deps, calls, osd }; +} + +test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers', async () => { + const { deps, calls } = createDeps(); + const { overlayHandlers, fallbackHandlers } = createOverlayShortcutRuntimeHandlers(deps); + + overlayHandlers.copySubtitle(); + overlayHandlers.copySubtitleMultiple(1111); + overlayHandlers.toggleSecondarySub(); + overlayHandlers.openRuntimeOptions(); + overlayHandlers.openJimaku(); + overlayHandlers.mineSentenceMultiple(2222); + overlayHandlers.updateLastCardFromClipboard(); + fallbackHandlers.mineSentence(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, [ + 'copySubtitle', + 'copySubtitleMultiple:1111', + 'toggleSecondarySub', + 'openRuntimeOptions', + 'openJimaku', + 'mineSentenceMultiple:2222', + 'updateLastCardFromClipboard', + 'mineSentence', + ]); +}); + +test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', async () => { + const logs: unknown[][] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + logs.push(args); + }; + + try { + const { deps, osd } = createDeps({ + markAudioCard: async () => { + throw new Error('audio boom'); + }, + }); + const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps); + + overlayHandlers.markAudioCard(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(logs.length, 1); + assert.equal(typeof logs[0]?.[0], 'string'); + assert.ok(String(logs[0]?.[0]).includes('markLastCardAsAudioCard failed:')); + assert.ok(String(logs[0]?.[0]).includes('audio boom')); + assert.ok(osd.some((entry) => entry.includes('Audio card failed: audio boom'))); + } finally { + console.error = originalError; + } +}); + +test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', () => { + const handled: string[] = []; + const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; + const shortcuts = makeShortcuts({ + copySubtitleMultiple: 'Ctrl+M', + multiCopyTimeoutMs: 4321, + }); + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator, allowWhenRegistered) => { + matched.push({ + accelerator, + allowWhenRegistered: allowWhenRegistered === true, + }); + return accelerator === 'Ctrl+M'; + }, + { + openRuntimeOptions: () => handled.push('openRuntimeOptions'), + openJimaku: () => handled.push('openJimaku'), + markAudioCard: () => handled.push('markAudioCard'), + copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), + copySubtitle: () => handled.push('copySubtitle'), + toggleSecondarySub: () => handled.push('toggleSecondarySub'), + updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'), + triggerFieldGrouping: () => handled.push('triggerFieldGrouping'), + triggerSubsync: () => handled.push('triggerSubsync'), + mineSentence: () => handled.push('mineSentence'), + mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`), + }, + ); + + assert.equal(result, true); + assert.deepEqual(handled, ['copySubtitleMultiple:4321']); + assert.deepEqual(matched, [{ accelerator: 'Ctrl+M', allowWhenRegistered: false }]); +}); + +test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => { + const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; + const shortcuts = makeShortcuts({ + toggleSecondarySub: 'Ctrl+2', + }); + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator, allowWhenRegistered) => { + matched.push({ + accelerator, + allowWhenRegistered: allowWhenRegistered === true, + }); + return accelerator === 'Ctrl+2'; + }, + { + openRuntimeOptions: () => {}, + openJimaku: () => {}, + markAudioCard: () => {}, + copySubtitleMultiple: () => {}, + copySubtitle: () => {}, + toggleSecondarySub: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + }, + ); + + assert.equal(result, true); + assert.deepEqual(matched, [{ accelerator: 'Ctrl+2', allowWhenRegistered: true }]); +}); + +test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', () => { + const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; + const shortcuts = makeShortcuts({ + openJimaku: 'Ctrl+J', + }); + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator, allowWhenRegistered) => { + matched.push({ + accelerator, + allowWhenRegistered: allowWhenRegistered === true, + }); + return accelerator === 'Ctrl+J'; + }, + { + openRuntimeOptions: () => {}, + openJimaku: () => {}, + markAudioCard: () => {}, + copySubtitleMultiple: () => {}, + copySubtitle: () => {}, + toggleSecondarySub: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + }, + ); + + assert.equal(result, true); + assert.deepEqual(matched, [{ accelerator: 'Ctrl+J', allowWhenRegistered: true }]); +}); + +test('runOverlayShortcutLocalFallback returns false when no action matches', () => { + const shortcuts = makeShortcuts({ + copySubtitle: 'Ctrl+C', + }); + let called = false; + + const result = runOverlayShortcutLocalFallback({} as Electron.Input, shortcuts, () => false, { + openRuntimeOptions: () => { + called = true; + }, + openJimaku: () => { + called = true; + }, + markAudioCard: () => { + called = true; + }, + copySubtitleMultiple: () => { + called = true; + }, + copySubtitle: () => { + called = true; + }, + toggleSecondarySub: () => { + called = true; + }, + updateLastCardFromClipboard: () => { + called = true; + }, + triggerFieldGrouping: () => { + called = true; + }, + triggerSubsync: () => { + called = true; + }, + mineSentence: () => { + called = true; + }, + mineSentenceMultiple: () => { + called = true; + }, + }); + + assert.equal(result, false); + assert.equal(called, false); +}); diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts new file mode 100644 index 0000000..09d6e06 --- /dev/null +++ b/src/core/services/overlay-shortcut-handler.ts @@ -0,0 +1,208 @@ +import { ConfiguredShortcuts } from '../utils/shortcut-config'; +import { OverlayShortcutHandlers } from './overlay-shortcut'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:overlay-shortcut-handler'); + +export interface OverlayShortcutFallbackHandlers { + openRuntimeOptions: () => void; + openJimaku: () => void; + markAudioCard: () => void; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySub: () => void; + updateLastCardFromClipboard: () => void; + triggerFieldGrouping: () => void; + triggerSubsync: () => void; + mineSentence: () => void; + mineSentenceMultiple: (timeoutMs: number) => void; +} + +export interface OverlayShortcutRuntimeDeps { + showMpvOsd: (text: string) => void; + openRuntimeOptions: () => void; + openJimaku: () => void; + markAudioCard: () => Promise; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySub: () => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsync: () => Promise; + mineSentence: () => Promise; + mineSentenceMultiple: (timeoutMs: number) => void; +} + +function wrapAsync( + task: () => Promise, + deps: OverlayShortcutRuntimeDeps, + logLabel: string, + osdLabel: string, +): () => void { + return () => { + task().catch((err) => { + logger.error(`${logLabel} failed:`, err); + deps.showMpvOsd(`${osdLabel}: ${(err as Error).message}`); + }); + }; +} + +export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntimeDeps): { + overlayHandlers: OverlayShortcutHandlers; + fallbackHandlers: OverlayShortcutFallbackHandlers; +} { + const overlayHandlers: OverlayShortcutHandlers = { + copySubtitle: () => { + deps.copySubtitle(); + }, + copySubtitleMultiple: (timeoutMs) => { + deps.copySubtitleMultiple(timeoutMs); + }, + updateLastCardFromClipboard: wrapAsync( + () => deps.updateLastCardFromClipboard(), + deps, + 'updateLastCardFromClipboard', + 'Update failed', + ), + triggerFieldGrouping: wrapAsync( + () => deps.triggerFieldGrouping(), + deps, + 'triggerFieldGrouping', + 'Field grouping failed', + ), + triggerSubsync: wrapAsync( + () => deps.triggerSubsync(), + deps, + 'triggerSubsyncFromConfig', + 'Subsync failed', + ), + mineSentence: wrapAsync( + () => deps.mineSentence(), + deps, + 'mineSentenceCard', + 'Mine sentence failed', + ), + mineSentenceMultiple: (timeoutMs) => { + deps.mineSentenceMultiple(timeoutMs); + }, + toggleSecondarySub: () => deps.toggleSecondarySub(), + markAudioCard: wrapAsync( + () => deps.markAudioCard(), + deps, + 'markLastCardAsAudioCard', + 'Audio card failed', + ), + openRuntimeOptions: () => { + deps.openRuntimeOptions(); + }, + openJimaku: () => { + deps.openJimaku(); + }, + }; + + const fallbackHandlers: OverlayShortcutFallbackHandlers = { + openRuntimeOptions: overlayHandlers.openRuntimeOptions, + openJimaku: overlayHandlers.openJimaku, + markAudioCard: overlayHandlers.markAudioCard, + copySubtitleMultiple: overlayHandlers.copySubtitleMultiple, + copySubtitle: overlayHandlers.copySubtitle, + toggleSecondarySub: overlayHandlers.toggleSecondarySub, + updateLastCardFromClipboard: overlayHandlers.updateLastCardFromClipboard, + triggerFieldGrouping: overlayHandlers.triggerFieldGrouping, + triggerSubsync: overlayHandlers.triggerSubsync, + mineSentence: overlayHandlers.mineSentence, + mineSentenceMultiple: overlayHandlers.mineSentenceMultiple, + }; + + return { overlayHandlers, fallbackHandlers }; +} + +export function runOverlayShortcutLocalFallback( + input: Electron.Input, + shortcuts: ConfiguredShortcuts, + matcher: (input: Electron.Input, accelerator: string, allowWhenRegistered?: boolean) => boolean, + handlers: OverlayShortcutFallbackHandlers, +): boolean { + const actions: Array<{ + accelerator: string | null | undefined; + run: () => void; + allowWhenRegistered?: boolean; + }> = [ + { + accelerator: shortcuts.openRuntimeOptions, + run: () => { + handlers.openRuntimeOptions(); + }, + }, + { + accelerator: shortcuts.openJimaku, + run: () => { + handlers.openJimaku(); + }, + allowWhenRegistered: true, + }, + { + accelerator: shortcuts.markAudioCard, + run: () => { + handlers.markAudioCard(); + }, + }, + { + accelerator: shortcuts.copySubtitleMultiple, + run: () => { + handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + { + accelerator: shortcuts.copySubtitle, + run: () => { + handlers.copySubtitle(); + }, + }, + { + accelerator: shortcuts.toggleSecondarySub, + run: () => handlers.toggleSecondarySub(), + allowWhenRegistered: true, + }, + { + accelerator: shortcuts.updateLastCardFromClipboard, + run: () => { + handlers.updateLastCardFromClipboard(); + }, + }, + { + accelerator: shortcuts.triggerFieldGrouping, + run: () => { + handlers.triggerFieldGrouping(); + }, + }, + { + accelerator: shortcuts.triggerSubsync, + run: () => { + handlers.triggerSubsync(); + }, + }, + { + accelerator: shortcuts.mineSentence, + run: () => { + handlers.mineSentence(); + }, + }, + { + accelerator: shortcuts.mineSentenceMultiple, + run: () => { + handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs); + }, + }, + ]; + + for (const action of actions) { + if (!action.accelerator) continue; + if (matcher(input, action.accelerator, action.allowWhenRegistered === true)) { + action.run(); + return true; + } + } + + return false; +} diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts new file mode 100644 index 0000000..ddb07b6 --- /dev/null +++ b/src/core/services/overlay-shortcut.ts @@ -0,0 +1,198 @@ +import { globalShortcut } from 'electron'; +import { ConfiguredShortcuts } from '../utils/shortcut-config'; +import { isGlobalShortcutRegisteredSafe } from './shortcut-fallback'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:overlay-shortcut-service'); + +export interface OverlayShortcutHandlers { + copySubtitle: () => void; + copySubtitleMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => void; + triggerFieldGrouping: () => void; + triggerSubsync: () => void; + mineSentence: () => void; + mineSentenceMultiple: (timeoutMs: number) => void; + toggleSecondarySub: () => void; + markAudioCard: () => void; + openRuntimeOptions: () => void; + openJimaku: () => void; +} + +export interface OverlayShortcutLifecycleDeps { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getOverlayHandlers: () => OverlayShortcutHandlers; + cancelPendingMultiCopy: () => void; + cancelPendingMineSentenceMultiple: () => void; +} + +export function registerOverlayShortcuts( + shortcuts: ConfiguredShortcuts, + handlers: OverlayShortcutHandlers, +): boolean { + let registeredAny = false; + const registerOverlayShortcut = ( + accelerator: string, + handler: () => void, + label: string, + ): void => { + if (isGlobalShortcutRegisteredSafe(accelerator)) { + registeredAny = true; + return; + } + const ok = globalShortcut.register(accelerator, handler); + if (!ok) { + logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`); + return; + } + registeredAny = true; + }; + + if (shortcuts.copySubtitleMultiple) { + registerOverlayShortcut( + shortcuts.copySubtitleMultiple, + () => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs), + 'copySubtitleMultiple', + ); + } + + if (shortcuts.copySubtitle) { + registerOverlayShortcut(shortcuts.copySubtitle, () => handlers.copySubtitle(), 'copySubtitle'); + } + + if (shortcuts.triggerFieldGrouping) { + registerOverlayShortcut( + shortcuts.triggerFieldGrouping, + () => handlers.triggerFieldGrouping(), + 'triggerFieldGrouping', + ); + } + + if (shortcuts.triggerSubsync) { + registerOverlayShortcut( + shortcuts.triggerSubsync, + () => handlers.triggerSubsync(), + 'triggerSubsync', + ); + } + + if (shortcuts.mineSentence) { + registerOverlayShortcut(shortcuts.mineSentence, () => handlers.mineSentence(), 'mineSentence'); + } + + if (shortcuts.mineSentenceMultiple) { + registerOverlayShortcut( + shortcuts.mineSentenceMultiple, + () => handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs), + 'mineSentenceMultiple', + ); + } + + if (shortcuts.toggleSecondarySub) { + registerOverlayShortcut( + shortcuts.toggleSecondarySub, + () => handlers.toggleSecondarySub(), + 'toggleSecondarySub', + ); + } + + if (shortcuts.updateLastCardFromClipboard) { + registerOverlayShortcut( + shortcuts.updateLastCardFromClipboard, + () => handlers.updateLastCardFromClipboard(), + 'updateLastCardFromClipboard', + ); + } + + if (shortcuts.markAudioCard) { + registerOverlayShortcut( + shortcuts.markAudioCard, + () => handlers.markAudioCard(), + 'markAudioCard', + ); + } + + if (shortcuts.openRuntimeOptions) { + registerOverlayShortcut( + shortcuts.openRuntimeOptions, + () => handlers.openRuntimeOptions(), + 'openRuntimeOptions', + ); + } + if (shortcuts.openJimaku) { + registerOverlayShortcut(shortcuts.openJimaku, () => handlers.openJimaku(), 'openJimaku'); + } + + return registeredAny; +} + +export function unregisterOverlayShortcuts(shortcuts: ConfiguredShortcuts): void { + if (shortcuts.copySubtitle) { + globalShortcut.unregister(shortcuts.copySubtitle); + } + if (shortcuts.copySubtitleMultiple) { + globalShortcut.unregister(shortcuts.copySubtitleMultiple); + } + if (shortcuts.updateLastCardFromClipboard) { + globalShortcut.unregister(shortcuts.updateLastCardFromClipboard); + } + if (shortcuts.triggerFieldGrouping) { + globalShortcut.unregister(shortcuts.triggerFieldGrouping); + } + if (shortcuts.triggerSubsync) { + globalShortcut.unregister(shortcuts.triggerSubsync); + } + if (shortcuts.mineSentence) { + globalShortcut.unregister(shortcuts.mineSentence); + } + if (shortcuts.mineSentenceMultiple) { + globalShortcut.unregister(shortcuts.mineSentenceMultiple); + } + if (shortcuts.toggleSecondarySub) { + globalShortcut.unregister(shortcuts.toggleSecondarySub); + } + if (shortcuts.markAudioCard) { + globalShortcut.unregister(shortcuts.markAudioCard); + } + if (shortcuts.openRuntimeOptions) { + globalShortcut.unregister(shortcuts.openRuntimeOptions); + } + if (shortcuts.openJimaku) { + globalShortcut.unregister(shortcuts.openJimaku); + } +} + +export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean { + return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers()); +} + +export function unregisterOverlayShortcutsRuntime( + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + if (!shortcutsRegistered) return shortcutsRegistered; + deps.cancelPendingMultiCopy(); + deps.cancelPendingMineSentenceMultiple(); + unregisterOverlayShortcuts(deps.getConfiguredShortcuts()); + return false; +} + +export function syncOverlayShortcutsRuntime( + shouldBeActive: boolean, + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + if (shouldBeActive) { + return registerOverlayShortcutsRuntime(deps); + } + return unregisterOverlayShortcutsRuntime(shortcutsRegistered, deps); +} + +export function refreshOverlayShortcutsRuntime( + shouldBeActive: boolean, + shortcutsRegistered: boolean, + deps: OverlayShortcutLifecycleDeps, +): boolean { + const cleared = unregisterOverlayShortcutsRuntime(shortcutsRegistered, deps); + return syncOverlayShortcutsRuntime(shouldBeActive, cleared, deps); +} diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts new file mode 100644 index 0000000..a0f0117 --- /dev/null +++ b/src/core/services/overlay-visibility.ts @@ -0,0 +1,176 @@ +import { BrowserWindow, screen } from 'electron'; +import { BaseWindowTracker } from '../../window-trackers'; +import { WindowGeometry } from '../../types'; + +export function updateVisibleOverlayVisibility(args: { + visibleOverlayVisible: boolean; + mainWindow: BrowserWindow | null; + windowTracker: BaseWindowTracker | null; + trackerNotReadyWarningShown: boolean; + setTrackerNotReadyWarningShown: (shown: boolean) => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; +}): void { + if (!args.mainWindow || args.mainWindow.isDestroyed()) { + return; + } + + if (!args.visibleOverlayVisible) { + args.mainWindow.hide(); + args.syncOverlayShortcuts(); + return; + } + + if (args.windowTracker && args.windowTracker.isTracking()) { + args.setTrackerNotReadyWarningShown(false); + const geometry = args.windowTracker.getGeometry(); + if (geometry) { + args.updateVisibleOverlayBounds(geometry); + } + args.ensureOverlayWindowLevel(args.mainWindow); + args.mainWindow.show(); + args.mainWindow.focus(); + args.enforceOverlayLayerOrder(); + args.syncOverlayShortcuts(); + return; + } + + if (!args.windowTracker) { + args.setTrackerNotReadyWarningShown(false); + args.ensureOverlayWindowLevel(args.mainWindow); + args.mainWindow.show(); + args.mainWindow.focus(); + args.enforceOverlayLayerOrder(); + args.syncOverlayShortcuts(); + return; + } + + if (!args.trackerNotReadyWarningShown) { + args.setTrackerNotReadyWarningShown(true); + } + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + args.updateVisibleOverlayBounds({ + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }); + args.ensureOverlayWindowLevel(args.mainWindow); + args.mainWindow.show(); + args.mainWindow.focus(); + args.enforceOverlayLayerOrder(); + args.syncOverlayShortcuts(); +} + +export function updateInvisibleOverlayVisibility(args: { + invisibleWindow: BrowserWindow | null; + visibleOverlayVisible: boolean; + invisibleOverlayVisible: boolean; + windowTracker: BaseWindowTracker | null; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; +}): void { + if (!args.invisibleWindow || args.invisibleWindow.isDestroyed()) { + return; + } + + if (args.visibleOverlayVisible) { + args.invisibleWindow.hide(); + args.syncOverlayShortcuts(); + return; + } + + const showInvisibleWithoutFocus = (): void => { + args.ensureOverlayWindowLevel(args.invisibleWindow!); + if (typeof args.invisibleWindow!.showInactive === 'function') { + args.invisibleWindow!.showInactive(); + } else { + args.invisibleWindow!.show(); + } + args.enforceOverlayLayerOrder(); + }; + + if (!args.invisibleOverlayVisible) { + args.invisibleWindow.hide(); + args.syncOverlayShortcuts(); + return; + } + + if (args.windowTracker && args.windowTracker.isTracking()) { + const geometry = args.windowTracker.getGeometry(); + if (geometry) { + args.updateInvisibleOverlayBounds(geometry); + } + showInvisibleWithoutFocus(); + args.syncOverlayShortcuts(); + return; + } + + if (!args.windowTracker) { + showInvisibleWithoutFocus(); + args.syncOverlayShortcuts(); + return; + } + + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + args.updateInvisibleOverlayBounds({ + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }); + showInvisibleWithoutFocus(); + args.syncOverlayShortcuts(); +} + +export function syncInvisibleOverlayMousePassthrough(options: { + hasInvisibleWindow: () => boolean; + setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void; + visibleOverlayVisible: boolean; + invisibleOverlayVisible: boolean; +}): void { + if (!options.hasInvisibleWindow()) return; + if (options.visibleOverlayVisible) { + options.setIgnoreMouseEvents(true, { forward: true }); + } else if (options.invisibleOverlayVisible) { + options.setIgnoreMouseEvents(false); + } +} + +export function setVisibleOverlayVisible(options: { + visible: boolean; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isMpvConnected: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; +}): void { + options.setVisibleOverlayVisibleState(options.visible); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) { + options.setMpvSubVisibility(!options.visible); + } +} + +export function setInvisibleOverlayVisible(options: { + visible: boolean; + setInvisibleOverlayVisibleState: (visible: boolean) => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; +}): void { + options.setInvisibleOverlayVisibleState(options.visible); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); +} diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts new file mode 100644 index 0000000..c6964a2 --- /dev/null +++ b/src/core/services/overlay-window-config.test.ts @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => { + const sourcePath = path.join(process.cwd(), 'src/core/services/overlay-window.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + + assert.match(source, /webPreferences:\s*\{[\s\S]*sandbox:\s*false[\s\S]*\}/m); +}); diff --git a/src/core/services/overlay-window-geometry.ts b/src/core/services/overlay-window-geometry.ts new file mode 100644 index 0000000..328c186 --- /dev/null +++ b/src/core/services/overlay-window-geometry.ts @@ -0,0 +1,41 @@ +import type { WindowGeometry } from '../../types'; + +export const SECONDARY_OVERLAY_MAX_HEIGHT_RATIO = 0.2; + +function toInteger(value: number): number { + return Number.isFinite(value) ? Math.round(value) : 0; +} + +function clampPositive(value: number): number { + return Math.max(1, toInteger(value)); +} + +export function splitOverlayGeometryForSecondaryBar(geometry: WindowGeometry): { + secondary: WindowGeometry; + primary: WindowGeometry; +} { + const x = toInteger(geometry.x); + const y = toInteger(geometry.y); + const width = clampPositive(geometry.width); + const totalHeight = clampPositive(geometry.height); + + const secondaryHeight = clampPositive( + Math.min(totalHeight, Math.round(totalHeight * SECONDARY_OVERLAY_MAX_HEIGHT_RATIO)), + ); + const primaryHeight = clampPositive(totalHeight - secondaryHeight); + + return { + secondary: { + x, + y, + width, + height: secondaryHeight, + }, + primary: { + x, + y: y + secondaryHeight, + width, + height: primaryHeight, + }, + }; +} diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts new file mode 100644 index 0000000..31d68af --- /dev/null +++ b/src/core/services/overlay-window.test.ts @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { splitOverlayGeometryForSecondaryBar } from './overlay-window-geometry'; + +test('splitOverlayGeometryForSecondaryBar returns 20/80 top-bottom regions', () => { + const regions = splitOverlayGeometryForSecondaryBar({ + x: 100, + y: 50, + width: 1200, + height: 900, + }); + + assert.deepEqual(regions.secondary, { + x: 100, + y: 50, + width: 1200, + height: 180, + }); + assert.deepEqual(regions.primary, { + x: 100, + y: 230, + width: 1200, + height: 720, + }); +}); + +test('splitOverlayGeometryForSecondaryBar keeps positive sizes for tiny heights', () => { + const regions = splitOverlayGeometryForSecondaryBar({ + x: 0, + y: 0, + width: 300, + height: 1, + }); + + assert.ok(regions.secondary.height >= 1); + assert.ok(regions.primary.height >= 1); +}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts new file mode 100644 index 0000000..3428c11 --- /dev/null +++ b/src/core/services/overlay-window.ts @@ -0,0 +1,140 @@ +import { BrowserWindow } from 'electron'; +import * as path from 'path'; +import { WindowGeometry } from '../../types'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:overlay-window'); + +export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; + +export function updateOverlayWindowBounds( + geometry: WindowGeometry, + window: BrowserWindow | null, +): void { + if (!geometry || !window || window.isDestroyed()) return; + window.setBounds({ + x: geometry.x, + y: geometry.y, + width: geometry.width, + height: geometry.height, + }); +} + +export function ensureOverlayWindowLevel(window: BrowserWindow): void { + if (process.platform === 'darwin') { + window.setAlwaysOnTop(true, 'screen-saver', 1); + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + window.setFullScreenable(false); + return; + } + window.setAlwaysOnTop(true); +} + +export function enforceOverlayLayerOrder(options: { + visibleOverlayVisible: boolean; + invisibleOverlayVisible: boolean; + mainWindow: BrowserWindow | null; + invisibleWindow: BrowserWindow | null; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; +}): void { + if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return; + if (!options.mainWindow || options.mainWindow.isDestroyed()) return; + if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return; + + options.ensureOverlayWindowLevel(options.mainWindow); + options.mainWindow.moveTop(); +} + +export function createOverlayWindow( + kind: OverlayWindowKind, + options: { + isDev: boolean; + overlayDebugVisualizationEnabled: boolean; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (kind: OverlayWindowKind) => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (kind: OverlayWindowKind) => void; + }, +): BrowserWindow { + const window = new BrowserWindow({ + show: false, + width: 800, + height: 600, + x: 0, + y: 0, + transparent: true, + frame: false, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + hasShadow: false, + focusable: true, + webPreferences: { + preload: path.join(__dirname, '..', '..', 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + webSecurity: true, + additionalArguments: [`--overlay-layer=${kind}`], + }, + }); + + options.ensureOverlayWindowLevel(window); + + const htmlPath = path.join(__dirname, '..', '..', 'renderer', 'index.html'); + + window + .loadFile(htmlPath, { + query: { layer: kind }, + }) + .catch((err) => { + logger.error('Failed to load HTML file:', err); + }); + + window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { + logger.error('Page failed to load:', errorCode, errorDescription, validatedURL); + }); + + window.webContents.on('did-finish-load', () => { + options.onRuntimeOptionsChanged(); + window.webContents.send( + 'overlay-debug-visualization:set', + options.overlayDebugVisualizationEnabled, + ); + }); + + if (kind === 'visible') { + window.webContents.on('devtools-opened', () => { + options.setOverlayDebugVisualizationEnabled(true); + }); + window.webContents.on('devtools-closed', () => { + options.setOverlayDebugVisualizationEnabled(false); + }); + } + + window.webContents.on('before-input-event', (event, input) => { + if (!options.isOverlayVisible(kind)) return; + if (!options.tryHandleOverlayShortcutLocalFallback(input)) return; + event.preventDefault(); + }); + + window.hide(); + + window.on('closed', () => { + options.onWindowClosed(kind); + }); + + window.on('blur', () => { + if (!window.isDestroyed()) { + options.ensureOverlayWindowLevel(window); + } + }); + + if (options.isDev && kind === 'visible') { + window.webContents.openDevTools({ mode: 'detach' }); + } + + return window; +} diff --git a/src/core/services/runtime-config.test.ts b/src/core/services/runtime-config.test.ts new file mode 100644 index 0000000..6726638 --- /dev/null +++ b/src/core/services/runtime-config.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + getInitialInvisibleOverlayVisibility, + isAutoUpdateEnabledRuntime, + shouldAutoInitializeOverlayRuntimeFromConfig, + shouldBindVisibleOverlayToMpvSubVisibility, +} from './startup'; + +const BASE_CONFIG = { + auto_start_overlay: false, + bind_visible_overlay_to_mpv_sub_visibility: true, + invisibleOverlay: { + startupVisibility: 'platform-default' as const, + }, + ankiConnect: { + behavior: { + autoUpdateNewCards: true, + }, + }, +}; + +test('getInitialInvisibleOverlayVisibility handles visibility + platform', () => { + assert.equal( + getInitialInvisibleOverlayVisibility( + { ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'visible' } }, + 'linux', + ), + true, + ); + assert.equal( + getInitialInvisibleOverlayVisibility( + { ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'hidden' } }, + 'darwin', + ), + false, + ); + assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'linux'), false); + assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'darwin'), true); +}); + +test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visible startup', () => { + assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false); + assert.equal( + shouldAutoInitializeOverlayRuntimeFromConfig({ + ...BASE_CONFIG, + auto_start_overlay: true, + }), + true, + ); + assert.equal( + shouldAutoInitializeOverlayRuntimeFromConfig({ + ...BASE_CONFIG, + invisibleOverlay: { startupVisibility: 'visible' }, + }), + true, + ); +}); + +test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => { + assert.equal(shouldBindVisibleOverlayToMpvSubVisibility(BASE_CONFIG), true); + assert.equal( + shouldBindVisibleOverlayToMpvSubVisibility({ + ...BASE_CONFIG, + bind_visible_overlay_to_mpv_sub_visibility: false, + }), + false, + ); +}); + +test('isAutoUpdateEnabledRuntime prefers runtime option and falls back to config', () => { + assert.equal( + isAutoUpdateEnabledRuntime(BASE_CONFIG, { + getOptionValue: () => false, + }), + false, + ); + assert.equal( + isAutoUpdateEnabledRuntime( + { + ...BASE_CONFIG, + ankiConnect: { behavior: { autoUpdateNewCards: false } }, + }, + null, + ), + false, + ); + assert.equal(isAutoUpdateEnabledRuntime(BASE_CONFIG, null), true); +}); diff --git a/src/core/services/runtime-options-ipc.test.ts b/src/core/services/runtime-options-ipc.test.ts new file mode 100644 index 0000000..fa659cf --- /dev/null +++ b/src/core/services/runtime-options-ipc.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyRuntimeOptionResultRuntime, + cycleRuntimeOptionFromIpcRuntime, + setRuntimeOptionFromIpcRuntime, +} from './runtime-options-ipc'; + +test('applyRuntimeOptionResultRuntime emits success OSD message', () => { + const osd: string[] = []; + const result = applyRuntimeOptionResultRuntime({ ok: true, osdMessage: 'Updated' }, (text) => { + osd.push(text); + }); + + assert.equal(result.ok, true); + assert.deepEqual(osd, ['Updated']); +}); + +test('setRuntimeOptionFromIpcRuntime returns unavailable when manager missing', () => { + const osd: string[] = []; + const result = setRuntimeOptionFromIpcRuntime(null, 'anki.autoUpdateNewCards', true, (text) => { + osd.push(text); + }); + assert.equal(result.ok, false); + assert.equal(result.error, 'Runtime options manager unavailable'); + assert.deepEqual(osd, []); +}); + +test('cycleRuntimeOptionFromIpcRuntime reports errors once', () => { + const osd: string[] = []; + const result = cycleRuntimeOptionFromIpcRuntime( + { + setOptionValue: () => ({ ok: true }), + cycleOption: () => ({ ok: false, error: 'bad option' }), + }, + 'anki.kikuFieldGrouping', + 1, + (text) => { + osd.push(text); + }, + ); + assert.equal(result.ok, false); + assert.equal(result.error, 'bad option'); + assert.deepEqual(osd, ['bad option']); +}); diff --git a/src/core/services/runtime-options-ipc.ts b/src/core/services/runtime-options-ipc.ts new file mode 100644 index 0000000..e87c89f --- /dev/null +++ b/src/core/services/runtime-options-ipc.ts @@ -0,0 +1,48 @@ +import { RuntimeOptionApplyResult, RuntimeOptionId, RuntimeOptionValue } from '../../types'; + +export interface RuntimeOptionsManagerLike { + setOptionValue: (id: RuntimeOptionId, value: RuntimeOptionValue) => RuntimeOptionApplyResult; + cycleOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; +} + +export function applyRuntimeOptionResultRuntime( + result: RuntimeOptionApplyResult, + showMpvOsd: (text: string) => void, +): RuntimeOptionApplyResult { + if (result.ok && result.osdMessage) { + showMpvOsd(result.osdMessage); + } + return result; +} + +export function setRuntimeOptionFromIpcRuntime( + manager: RuntimeOptionsManagerLike | null, + id: RuntimeOptionId, + value: RuntimeOptionValue, + showMpvOsd: (text: string) => void, +): RuntimeOptionApplyResult { + if (!manager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + const result = applyRuntimeOptionResultRuntime(manager.setOptionValue(id, value), showMpvOsd); + if (!result.ok && result.error) { + showMpvOsd(result.error); + } + return result; +} + +export function cycleRuntimeOptionFromIpcRuntime( + manager: RuntimeOptionsManagerLike | null, + id: RuntimeOptionId, + direction: 1 | -1, + showMpvOsd: (text: string) => void, +): RuntimeOptionApplyResult { + if (!manager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + const result = applyRuntimeOptionResultRuntime(manager.cycleOption(id, direction), showMpvOsd); + if (!result.ok && result.error) { + showMpvOsd(result.error); + } + return result; +} diff --git a/src/core/services/secondary-subtitle.test.ts b/src/core/services/secondary-subtitle.test.ts new file mode 100644 index 0000000..b2f9e17 --- /dev/null +++ b/src/core/services/secondary-subtitle.test.ts @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SecondarySubMode } from '../../types'; +import { cycleSecondarySubMode } from './subtitle-position'; + +test('cycleSecondarySubMode cycles and emits broadcast + OSD', () => { + let mode: SecondarySubMode = 'hover'; + let lastToggleAt = 0; + const broadcasts: SecondarySubMode[] = []; + const osd: string[] = []; + + cycleSecondarySubMode({ + getSecondarySubMode: () => mode, + setSecondarySubMode: (next) => { + mode = next; + }, + getLastSecondarySubToggleAtMs: () => lastToggleAt, + setLastSecondarySubToggleAtMs: (value) => { + lastToggleAt = value; + }, + broadcastSecondarySubMode: (next) => { + broadcasts.push(next); + }, + showMpvOsd: (text) => { + osd.push(text); + }, + now: () => 1000, + }); + + assert.equal(mode, 'hidden'); + assert.deepEqual(broadcasts, ['hidden']); + assert.deepEqual(osd, ['Secondary subtitle: hidden']); + assert.equal(lastToggleAt, 1000); +}); + +test('cycleSecondarySubMode obeys debounce window', () => { + let mode: SecondarySubMode = 'visible'; + let lastToggleAt = 950; + let broadcasted = false; + let osdShown = false; + + cycleSecondarySubMode({ + getSecondarySubMode: () => mode, + setSecondarySubMode: (next) => { + mode = next; + }, + getLastSecondarySubToggleAtMs: () => lastToggleAt, + setLastSecondarySubToggleAtMs: (value) => { + lastToggleAt = value; + }, + broadcastSecondarySubMode: () => { + broadcasted = true; + }, + showMpvOsd: () => { + osdShown = true; + }, + now: () => 1000, + }); + + assert.equal(mode, 'visible'); + assert.equal(lastToggleAt, 950); + assert.equal(broadcasted, false); + assert.equal(osdShown, false); +}); diff --git a/src/core/services/shortcut-fallback.ts b/src/core/services/shortcut-fallback.ts new file mode 100644 index 0000000..d89a567 --- /dev/null +++ b/src/core/services/shortcut-fallback.ts @@ -0,0 +1,77 @@ +import { globalShortcut } from 'electron'; + +export function isGlobalShortcutRegisteredSafe(accelerator: string): boolean { + try { + return globalShortcut.isRegistered(accelerator); + } catch { + return false; + } +} + +export function shortcutMatchesInputForLocalFallback( + input: Electron.Input, + accelerator: string, + allowWhenRegistered = false, +): boolean { + if (input.type !== 'keyDown' || input.isAutoRepeat) return false; + if (!accelerator) return false; + if (!allowWhenRegistered && isGlobalShortcutRegisteredSafe(accelerator)) { + return false; + } + + const normalized = accelerator + .replace(/\s+/g, '') + .replace(/cmdorctrl/gi, 'CommandOrControl') + .toLowerCase(); + const parts = normalized.split('+').filter(Boolean); + if (parts.length === 0) return false; + + const keyToken = parts[parts.length - 1]!; + const modifierTokens = new Set(parts.slice(0, -1)); + const allowedModifiers = new Set(['shift', 'alt', 'meta', 'control', 'commandorcontrol']); + for (const token of modifierTokens) { + if (!allowedModifiers.has(token)) return false; + } + + const inputKey = (input.key || '').toLowerCase(); + if (keyToken.length === 1) { + if (inputKey !== keyToken) return false; + } else if (keyToken.startsWith('key') && keyToken.length === 4) { + if (inputKey !== keyToken.slice(3)) return false; + } else { + return false; + } + + const expectedShift = modifierTokens.has('shift'); + const expectedAlt = modifierTokens.has('alt'); + const expectedMeta = modifierTokens.has('meta'); + const expectedControl = modifierTokens.has('control'); + const expectedCommandOrControl = modifierTokens.has('commandorcontrol'); + + if (Boolean(input.shift) !== expectedShift) return false; + if (Boolean(input.alt) !== expectedAlt) return false; + + if (expectedCommandOrControl) { + const hasCmdOrCtrl = + process.platform === 'darwin' ? Boolean(input.meta || input.control) : Boolean(input.control); + if (!hasCmdOrCtrl) return false; + } else { + if (process.platform === 'darwin') { + if (input.meta || input.control) return false; + } else if (!expectedControl && input.control) { + return false; + } + } + + if (expectedMeta && !input.meta) return false; + if (!expectedMeta && modifierTokens.has('meta') === false && input.meta) { + if (!expectedCommandOrControl) return false; + } + + if (expectedControl && !input.control) return false; + if (!expectedControl && modifierTokens.has('control') === false && input.control) { + if (!expectedCommandOrControl) return false; + } + + return true; +} diff --git a/src/core/services/shortcut.ts b/src/core/services/shortcut.ts new file mode 100644 index 0000000..76e9a18 --- /dev/null +++ b/src/core/services/shortcut.ts @@ -0,0 +1,100 @@ +import { BrowserWindow, globalShortcut } from 'electron'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:shortcut'); + +export interface GlobalShortcutConfig { + toggleVisibleOverlayGlobal: string | null | undefined; + toggleInvisibleOverlayGlobal: string | null | undefined; + openJimaku?: string | null | undefined; +} + +export interface RegisterGlobalShortcutsServiceOptions { + shortcuts: GlobalShortcutConfig; + onToggleVisibleOverlay: () => void; + onToggleInvisibleOverlay: () => void; + onOpenYomitanSettings: () => void; + onOpenJimaku?: () => void; + isDev: boolean; + getMainWindow: () => BrowserWindow | null; +} + +export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void { + const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal; + const invisibleShortcut = options.shortcuts.toggleInvisibleOverlayGlobal; + const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase(); + const normalizedInvisible = invisibleShortcut?.replace(/\s+/g, '').toLowerCase(); + const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase(); + const normalizedSettings = 'alt+shift+y'; + + if (visibleShortcut) { + const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => { + options.onToggleVisibleOverlay(); + }); + if (!toggleVisibleRegistered) { + logger.warn( + `Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`, + ); + } + } + + if (invisibleShortcut && normalizedInvisible && normalizedInvisible !== normalizedVisible) { + const toggleInvisibleRegistered = globalShortcut.register(invisibleShortcut, () => { + options.onToggleInvisibleOverlay(); + }); + if (!toggleInvisibleRegistered) { + logger.warn( + `Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`, + ); + } + } else if ( + invisibleShortcut && + normalizedInvisible && + normalizedInvisible === normalizedVisible + ) { + logger.warn( + 'Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal', + ); + } + + if (options.shortcuts.openJimaku && options.onOpenJimaku) { + if ( + normalizedJimaku && + (normalizedJimaku === normalizedVisible || + normalizedJimaku === normalizedInvisible || + normalizedJimaku === normalizedSettings) + ) { + logger.warn( + 'Skipped registering openJimaku because it collides with another global shortcut', + ); + } else { + const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => { + options.onOpenJimaku?.(); + }); + if (!openJimakuRegistered) { + logger.warn( + `Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`, + ); + } + } + } + + const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => { + options.onOpenYomitanSettings(); + }); + if (!settingsRegistered) { + logger.warn('Failed to register global shortcut: Alt+Shift+Y'); + } + + if (options.isDev) { + const devtoolsRegistered = globalShortcut.register('F12', () => { + const mainWindow = options.getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.toggleDevTools(); + } + }); + if (!devtoolsRegistered) { + logger.warn('Failed to register global shortcut: F12'); + } + } +} diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts new file mode 100644 index 0000000..db7d24f --- /dev/null +++ b/src/core/services/startup-bootstrap.test.ts @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runStartupBootstrapRuntime } from './startup'; +import { CliArgs } from '../../cli/args'; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + background: false, + start: false, + stop: false, + toggle: false, + toggleVisibleOverlay: false, + toggleInvisibleOverlay: false, + settings: false, + show: false, + hide: false, + showVisibleOverlay: false, + hideVisibleOverlay: false, + showInvisibleOverlay: false, + hideInvisibleOverlay: false, + copySubtitle: false, + copySubtitleMultiple: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + refreshKnownWords: false, + toggleSecondarySub: false, + triggerFieldGrouping: false, + triggerSubsync: false, + markAudioCard: false, + openRuntimeOptions: false, + anilistStatus: false, + anilistLogout: false, + anilistSetup: false, + anilistRetryQueue: false, + jellyfin: false, + jellyfinLogin: false, + jellyfinLogout: false, + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + jellyfinSubtitleUrlsOnly: false, + jellyfinPlay: false, + jellyfinRemoteAnnounce: false, + texthooker: false, + help: false, + autoStartOverlay: false, + generateConfig: false, + backupOverwrite: false, + debug: false, + ...overrides, + }; +} + +test('runStartupBootstrapRuntime configures startup state and starts lifecycle', () => { + const calls: string[] = []; + const args = makeArgs({ + logLevel: 'debug', + socketPath: '/tmp/custom.sock', + texthookerPort: 9001, + backend: 'x11', + autoStartOverlay: true, + texthooker: true, + }); + + const result = runStartupBootstrapRuntime({ + argv: ['node', 'main.ts', '--log-level', 'debug'], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push('forceX11'), + enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), + getDefaultSocketPath: () => '/tmp/default.sock', + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push('startLifecycle'), + }); + + assert.equal(result.initialArgs, args); + assert.equal(result.mpvSocketPath, '/tmp/custom.sock'); + assert.equal(result.texthookerPort, 9001); + assert.equal(result.backendOverride, 'x11'); + assert.equal(result.autoStartOverlay, true); + assert.equal(result.texthookerOnlyMode, true); + assert.equal(result.backgroundMode, false); + assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']); +}); + +test('runStartupBootstrapRuntime keeps log-level precedence for repeated calls', () => { + const calls: string[] = []; + const args = makeArgs({ + logLevel: 'warn', + }); + + runStartupBootstrapRuntime({ + argv: ['node', 'main.ts', '--log-level', 'warn'], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push('forceX11'), + enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), + getDefaultSocketPath: () => '/tmp/default.sock', + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push('startLifecycle'), + }); + + assert.deepEqual(calls.slice(0, 3), ['setLog:warn:cli', 'forceX11', 'enforceWayland']); +}); + +test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags', () => { + const calls: string[] = []; + const args = makeArgs({ + jellyfin: true, + jellyfinLibraries: true, + socketPath: '/tmp/stable.sock', + texthookerPort: 8888, + }); + + const result = runStartupBootstrapRuntime({ + argv: ['node', 'main.ts', '--jellyfin', '--jellyfin-libraries'], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push('forceX11'), + enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), + getDefaultSocketPath: () => '/tmp/default.sock', + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push('startLifecycle'), + }); + + assert.equal(result.mpvSocketPath, '/tmp/stable.sock'); + assert.equal(result.texthookerPort, 8888); + assert.equal(result.backendOverride, null); + assert.equal(result.autoStartOverlay, false); + assert.equal(result.texthookerOnlyMode, false); + assert.equal(result.backgroundMode, false); + assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']); +}); + +test('runStartupBootstrapRuntime keeps --debug separate from log verbosity', () => { + const calls: string[] = []; + const args = makeArgs({ + debug: true, + }); + + runStartupBootstrapRuntime({ + argv: ['node', 'main.ts', '--debug'], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push('forceX11'), + enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), + getDefaultSocketPath: () => '/tmp/default.sock', + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push('startLifecycle'), + }); + + assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']); +}); + +test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => { + const calls: string[] = []; + const args = makeArgs({ generateConfig: true, logLevel: 'warn' }); + + const result = runStartupBootstrapRuntime({ + argv: ['node', 'main.ts', '--generate-config'], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push('forceX11'), + enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), + getDefaultSocketPath: () => '/tmp/default.sock', + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => true, + startAppLifecycle: () => calls.push('startLifecycle'), + }); + + assert.equal(result.mpvSocketPath, '/tmp/default.sock'); + assert.equal(result.texthookerPort, 5174); + assert.equal(result.backendOverride, null); + assert.equal(result.backgroundMode, false); + assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']); +}); + +test('runStartupBootstrapRuntime enables quiet background mode by default', () => { + const calls: string[] = []; + const args = makeArgs({ background: true }); + + const result = runStartupBootstrapRuntime({ + argv: ['node', 'main.ts', '--background'], + parseArgs: () => args, + setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), + forceX11Backend: () => calls.push('forceX11'), + enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), + getDefaultSocketPath: () => '/tmp/default.sock', + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push('startLifecycle'), + }); + + assert.equal(result.backgroundMode, true); + assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts new file mode 100644 index 0000000..7b14dc1 --- /dev/null +++ b/src/core/services/startup.ts @@ -0,0 +1,248 @@ +import { CliArgs } from '../../cli/args'; +import type { LogLevelSource } from '../../logger'; +import { ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types'; + +export interface StartupBootstrapRuntimeState { + initialArgs: CliArgs; + mpvSocketPath: string; + texthookerPort: number; + backendOverride: string | null; + autoStartOverlay: boolean; + texthookerOnlyMode: boolean; + backgroundMode: boolean; +} + +interface RuntimeAutoUpdateOptionManagerLike { + getOptionValue: (id: 'anki.autoUpdateNewCards') => unknown; +} + +export interface RuntimeConfigLike { + auto_start_overlay?: boolean; + bind_visible_overlay_to_mpv_sub_visibility: boolean; + invisibleOverlay: { + startupVisibility: 'visible' | 'hidden' | 'platform-default'; + }; + ankiConnect?: { + behavior?: { + autoUpdateNewCards?: boolean; + }; + }; +} + +export interface StartupBootstrapRuntimeDeps { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevel: (level: string, source: LogLevelSource) => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + runGenerateConfigFlow: (args: CliArgs) => boolean; + startAppLifecycle: (args: CliArgs) => void; +} + +export function runStartupBootstrapRuntime( + deps: StartupBootstrapRuntimeDeps, +): StartupBootstrapRuntimeState { + const initialArgs = deps.parseArgs(deps.argv); + + if (initialArgs.logLevel) { + deps.setLogLevel(initialArgs.logLevel, 'cli'); + } else if (initialArgs.background) { + deps.setLogLevel('warn', 'cli'); + } + + deps.forceX11Backend(initialArgs); + deps.enforceUnsupportedWaylandMode(initialArgs); + + const state: StartupBootstrapRuntimeState = { + initialArgs, + mpvSocketPath: initialArgs.socketPath ?? deps.getDefaultSocketPath(), + texthookerPort: initialArgs.texthookerPort ?? deps.defaultTexthookerPort, + backendOverride: initialArgs.backend ?? null, + autoStartOverlay: initialArgs.autoStartOverlay, + texthookerOnlyMode: initialArgs.texthooker, + backgroundMode: initialArgs.background, + }; + + if (!deps.runGenerateConfigFlow(initialArgs)) { + deps.startAppLifecycle(initialArgs); + } + + return state; +} + +interface AppReadyConfigLike { + secondarySub?: { + defaultMode?: SecondarySubMode; + }; + ankiConnect?: { + enabled?: boolean; + fields?: { + audio?: string; + image?: string; + sentence?: string; + miscInfo?: string; + translation?: string; + }; + }; + websocket?: { + enabled?: boolean | 'auto'; + port?: number; + }; + logging?: { + level?: 'debug' | 'info' | 'warn' | 'error'; + }; +} + +export interface AppReadyRuntimeDeps { + loadSubtitlePosition: () => void; + resolveKeybindings: () => void; + createMpvClient: () => void; + reloadConfig: () => void; + getResolvedConfig: () => AppReadyConfigLike; + getConfigWarnings: () => ConfigValidationWarning[]; + logConfigWarning: (warning: ConfigValidationWarning) => void; + setLogLevel: (level: string, source: LogLevelSource) => void; + initRuntimeOptionsManager: () => void; + setSecondarySubMode: (mode: SecondarySubMode) => void; + defaultSecondarySubMode: SecondarySubMode; + defaultWebsocketPort: number; + hasMpvWebsocketPlugin: () => boolean; + startSubtitleWebsocket: (port: number) => void; + log: (message: string) => void; + createMecabTokenizerAndCheck: () => Promise; + createSubtitleTimingTracker: () => void; + createImmersionTracker?: () => void; + startJellyfinRemoteSession?: () => Promise; + loadYomitanExtension: () => Promise; + prewarmSubtitleDictionaries?: () => Promise; + startBackgroundWarmups: () => void; + texthookerOnlyMode: boolean; + shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; + initializeOverlayRuntime: () => void; + handleInitialArgs: () => void; + logDebug?: (message: string) => void; + onCriticalConfigErrors?: (errors: string[]) => void; + now?: () => number; +} + +const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [ + 'audio', + 'image', + 'sentence', + 'miscInfo', + 'translation', +] as const; + +function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] { + if (!config.ankiConnect?.enabled) { + return []; + } + + const errors: string[] = []; + const fields = config.ankiConnect.fields ?? {}; + + for (const key of REQUIRED_ANKI_FIELD_MAPPING_KEYS) { + const value = fields[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push( + `ankiConnect.fields.${key} must be a non-empty string when ankiConnect is enabled.`, + ); + } + } + + return errors; +} + +export function getInitialInvisibleOverlayVisibility( + config: RuntimeConfigLike, + platform: NodeJS.Platform, +): boolean { + const visibility = config.invisibleOverlay.startupVisibility; + if (visibility === 'visible') return true; + if (visibility === 'hidden') return false; + if (platform === 'linux') return false; + return true; +} + +export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean { + if (config.auto_start_overlay === true) return true; + if (config.invisibleOverlay.startupVisibility === 'visible') return true; + return false; +} + +export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean { + return config.bind_visible_overlay_to_mpv_sub_visibility; +} + +export function isAutoUpdateEnabledRuntime( + config: ResolvedConfig | RuntimeConfigLike, + runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null, +): boolean { + const value = runtimeOptionsManager?.getOptionValue('anki.autoUpdateNewCards'); + if (typeof value === 'boolean') return value; + return (config as ResolvedConfig).ankiConnect?.behavior?.autoUpdateNewCards !== false; +} + +export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { + const now = deps.now ?? (() => Date.now()); + const startupStartedAtMs = now(); + deps.logDebug?.('App-ready critical path started.'); + + deps.reloadConfig(); + const config = deps.getResolvedConfig(); + const criticalConfigErrors = getStartupCriticalConfigErrors(config); + if (criticalConfigErrors.length > 0) { + deps.onCriticalConfigErrors?.(criticalConfigErrors); + deps.logDebug?.( + `App-ready critical path aborted after config validation in ${now() - startupStartedAtMs}ms.`, + ); + return; + } + + deps.setLogLevel(config.logging?.level ?? 'info', 'config'); + for (const warning of deps.getConfigWarnings()) { + deps.logConfigWarning(warning); + } + + deps.loadSubtitlePosition(); + deps.resolveKeybindings(); + deps.createMpvClient(); + deps.initRuntimeOptionsManager(); + deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode); + + const wsConfig = config.websocket || {}; + const wsEnabled = wsConfig.enabled ?? 'auto'; + const wsPort = wsConfig.port || deps.defaultWebsocketPort; + + if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) { + deps.startSubtitleWebsocket(wsPort); + } else if (wsEnabled === 'auto') { + deps.log('mpv_websocket detected, skipping built-in WebSocket server'); + } + + deps.createSubtitleTimingTracker(); + if (deps.createImmersionTracker) { + deps.log('Runtime ready: invoking createImmersionTracker.'); + try { + deps.createImmersionTracker(); + } catch (error) { + deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`); + } + } else { + deps.log('Runtime ready: createImmersionTracker dependency is missing.'); + } + + if (deps.texthookerOnlyMode) { + deps.log('Texthooker-only mode enabled; skipping overlay window.'); + } else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) { + deps.initializeOverlayRuntime(); + } else { + deps.log('Overlay runtime deferred: waiting for explicit overlay command.'); + } + + deps.handleInitialArgs(); + deps.startBackgroundWarmups(); + deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`); +} diff --git a/src/core/services/subsync-runner.ts b/src/core/services/subsync-runner.ts new file mode 100644 index 0000000..2092729 --- /dev/null +++ b/src/core/services/subsync-runner.ts @@ -0,0 +1,75 @@ +import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types'; +import { SubsyncResolvedConfig } from '../../subsync/utils'; +import { runSubsyncManualFromIpc } from './ipc-command'; +import { + TriggerSubsyncFromConfigDeps, + runSubsyncManual, + triggerSubsyncFromConfig, +} from './subsync'; + +const AUTOSUBSYNC_SPINNER_FRAMES = ['|', '/', '-', '\\']; + +interface MpvClientLike { + connected: boolean; + currentAudioStreamIndex: number | null; + send: (payload: { command: (string | number)[] }) => void; + requestProperty: (name: string) => Promise; +} + +export interface SubsyncRuntimeDeps { + getMpvClient: () => MpvClientLike | null; + getResolvedSubsyncConfig: () => SubsyncResolvedConfig; + isSubsyncInProgress: () => boolean; + setSubsyncInProgress: (inProgress: boolean) => void; + showMpvOsd: (text: string) => void; + openManualPicker: (payload: SubsyncManualPayload) => void; +} + +async function runWithSubsyncSpinnerService( + task: () => Promise, + showMpvOsd: (text: string) => void, + label = 'Subsync: syncing', +): Promise { + let frame = 0; + showMpvOsd(`${label} ${AUTOSUBSYNC_SPINNER_FRAMES[0]}`); + const timer = setInterval(() => { + frame = (frame + 1) % AUTOSUBSYNC_SPINNER_FRAMES.length; + showMpvOsd(`${label} ${AUTOSUBSYNC_SPINNER_FRAMES[frame]}`); + }, 150); + try { + return await task(); + } finally { + clearInterval(timer); + } +} + +function buildTriggerSubsyncDeps(deps: SubsyncRuntimeDeps): TriggerSubsyncFromConfigDeps { + return { + getMpvClient: deps.getMpvClient, + getResolvedConfig: deps.getResolvedSubsyncConfig, + isSubsyncInProgress: deps.isSubsyncInProgress, + setSubsyncInProgress: deps.setSubsyncInProgress, + showMpvOsd: deps.showMpvOsd, + runWithSubsyncSpinner: (task: () => Promise) => + runWithSubsyncSpinnerService(task, deps.showMpvOsd), + openManualPicker: deps.openManualPicker, + }; +} + +export async function triggerSubsyncFromConfigRuntime(deps: SubsyncRuntimeDeps): Promise { + await triggerSubsyncFromConfig(buildTriggerSubsyncDeps(deps)); +} + +export async function runSubsyncManualFromIpcRuntime( + request: SubsyncManualRunRequest, + deps: SubsyncRuntimeDeps, +): Promise { + const triggerDeps = buildTriggerSubsyncDeps(deps); + return runSubsyncManualFromIpc(request, { + isSubsyncInProgress: triggerDeps.isSubsyncInProgress, + setSubsyncInProgress: triggerDeps.setSubsyncInProgress, + showMpvOsd: triggerDeps.showMpvOsd, + runWithSpinner: (task) => triggerDeps.runWithSubsyncSpinner(() => task()), + runSubsyncManual: (subsyncRequest) => runSubsyncManual(subsyncRequest, triggerDeps), + }); +} diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts new file mode 100644 index 0000000..d33be08 --- /dev/null +++ b/src/core/services/subsync.test.ts @@ -0,0 +1,344 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + TriggerSubsyncFromConfigDeps, + runSubsyncManual, + triggerSubsyncFromConfig, +} from './subsync'; + +function makeDeps( + overrides: Partial = {}, +): TriggerSubsyncFromConfigDeps { + const mpvClient = { + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === 'path') return '/tmp/video.mkv'; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { id: 1, type: 'sub', selected: true, lang: 'jpn' }, + { + id: 2, + type: 'sub', + selected: false, + external: true, + lang: 'eng', + 'external-filename': '/tmp/ref.srt', + }, + { id: 3, type: 'audio', selected: true, 'ff-index': 1 }, + ]; + } + return null; + }, + }; + + return { + getMpvClient: () => mpvClient, + getResolvedConfig: () => ({ + defaultMode: 'manual', + alassPath: '/usr/bin/alass', + ffsubsyncPath: '/usr/bin/ffsubsync', + ffmpegPath: '/usr/bin/ffmpeg', + }), + isSubsyncInProgress: () => false, + setSubsyncInProgress: () => {}, + showMpvOsd: () => {}, + runWithSubsyncSpinner: async (task: () => Promise) => task(), + openManualPicker: () => {}, + ...overrides, + }; +} + +test('triggerSubsyncFromConfig returns early when already in progress', async () => { + const osd: string[] = []; + await triggerSubsyncFromConfig( + makeDeps({ + isSubsyncInProgress: () => true, + showMpvOsd: (text) => { + osd.push(text); + }, + }), + ); + assert.deepEqual(osd, ['Subsync already running']); +}); + +test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => { + const osd: string[] = []; + let payloadTrackCount = 0; + let inProgressState: boolean | null = null; + + await triggerSubsyncFromConfig( + makeDeps({ + openManualPicker: (payload) => { + payloadTrackCount = payload.sourceTracks.length; + }, + showMpvOsd: (text) => { + osd.push(text); + }, + setSubsyncInProgress: (value) => { + inProgressState = value; + }, + }), + ); + + assert.equal(payloadTrackCount, 1); + assert.ok(osd.includes('Subsync: choose engine and source')); + assert.equal(inProgressState, false); +}); + +test('triggerSubsyncFromConfig reports failures to OSD', async () => { + const osd: string[] = []; + await triggerSubsyncFromConfig( + makeDeps({ + getMpvClient: () => null, + showMpvOsd: (text) => { + osd.push(text); + }, + }), + ); + + assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected'))); +}); + +test('runSubsyncManual requires a source track for alass', async () => { + const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: null }, makeDeps()); + + assert.deepEqual(result, { + ok: false, + message: 'Select a subtitle source track for alass', + }); +}); + +test('triggerSubsyncFromConfig reports path validation failures', async () => { + const osd: string[] = []; + const inProgress: boolean[] = []; + + await triggerSubsyncFromConfig( + makeDeps({ + getResolvedConfig: () => ({ + defaultMode: 'auto', + alassPath: '/missing/alass', + ffsubsyncPath: '/missing/ffsubsync', + ffmpegPath: '/missing/ffmpeg', + }), + setSubsyncInProgress: (value) => { + inProgress.push(value); + }, + showMpvOsd: (text) => { + osd.push(text); + }, + }), + ); + + assert.deepEqual(inProgress, [true, false]); + assert.ok( + osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')), + ); +}); + +function writeExecutableScript(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o755 }); + fs.chmodSync(filePath, 0o755); +} + +test('runSubsyncManual constructs ffsubsync command and returns success', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-')); + const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log'); + const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); + const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh'); + const alassPath = path.join(tmpDir, 'alass.sh'); + const videoPath = path.join(tmpDir, 'video.mkv'); + const primaryPath = path.join(tmpDir, 'primary.srt'); + + fs.writeFileSync(videoPath, 'video'); + fs.writeFileSync(primaryPath, 'sub'); + writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript( + ffsubsyncPath, + `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, + ); + + const sentCommands: Array> = []; + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: 2, + send: (payload) => { + sentCommands.push(payload.command); + }, + requestProperty: async (name: string) => { + if (name === 'path') return videoPath; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { + id: 1, + type: 'sub', + selected: true, + external: true, + 'external-filename': primaryPath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: 'manual', + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps); + + assert.equal(result.ok, true); + assert.equal(result.message, 'Subtitle synchronized with ffsubsync'); + const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n'); + assert.equal(ffArgs[0], videoPath); + assert.ok(ffArgs.includes('-i')); + assert.ok(ffArgs.includes(primaryPath)); + assert.ok(ffArgs.includes('--reference-stream')); + assert.ok(ffArgs.includes('0:2')); + assert.equal(sentCommands[0]?.[0], 'sub_add'); + assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); +}); + +test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-')); + const alassLogPath = path.join(tmpDir, 'alass-args.log'); + const alassPath = path.join(tmpDir, 'alass.sh'); + const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh'); + const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); + const videoPath = path.join(tmpDir, 'video.mkv'); + const primaryPath = path.join(tmpDir, 'primary.srt'); + const sourcePath = path.join(tmpDir, 'source.srt'); + + fs.writeFileSync(videoPath, 'video'); + fs.writeFileSync(primaryPath, 'sub'); + fs.writeFileSync(sourcePath, 'sub2'); + writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript( + alassPath, + `#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\nexit 1\n`, + ); + + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === 'path') return videoPath; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { + id: 1, + type: 'sub', + selected: true, + external: true, + 'external-filename': primaryPath, + }, + { + id: 2, + type: 'sub', + selected: false, + external: true, + 'external-filename': sourcePath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: 'manual', + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps); + + assert.equal(result.ok, false); + assert.equal(typeof result.message, 'string'); + assert.equal(result.message.startsWith('alass synchronization failed'), true); + const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n'); + assert.equal(alassArgs[0], sourcePath); + assert.equal(alassArgs[1], primaryPath); +}); + +test('runSubsyncManual resolves string sid values from mpv stream properties', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-')); + const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); + const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log'); + const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh'); + const alassPath = path.join(tmpDir, 'alass.sh'); + const videoPath = path.join(tmpDir, 'video.mkv'); + const primaryPath = path.join(tmpDir, 'primary.srt'); + + fs.writeFileSync(videoPath, 'video'); + fs.writeFileSync(primaryPath, 'subtitle'); + writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); + writeExecutableScript( + ffsubsyncPath, + `#!/bin/sh\nmkdir -p "${tmpDir}"\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`, + ); + + const deps = makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === 'path') return videoPath; + if (name === 'sid') return '1'; + if (name === 'secondary-sid') return '2'; + if (name === 'track-list') { + return [ + { + id: '1', + type: 'sub', + selected: true, + external: true, + 'external-filename': primaryPath, + }, + ]; + } + return null; + }, + }), + getResolvedConfig: () => ({ + defaultMode: 'manual', + alassPath, + ffsubsyncPath, + ffmpegPath, + }), + }); + + const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps); + + assert.equal(result.ok, true); + assert.equal(result.message, 'Subtitle synchronized with ffsubsync'); + const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n'); + const syncOutputIndex = ffArgs.indexOf('-o'); + assert.equal(syncOutputIndex >= 0, true); + const outputPath = ffArgs[syncOutputIndex + 1]; + assert.equal(typeof outputPath, 'string'); + assert.ok(outputPath!.length > 0); + assert.equal(fs.readFileSync(outputPath!, 'utf8'), ''); +}); diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts new file mode 100644 index 0000000..e878fd2 --- /dev/null +++ b/src/core/services/subsync.ts @@ -0,0 +1,436 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../../types'; +import { + CommandResult, + codecToExtension, + fileExists, + formatTrackLabel, + getTrackById, + hasPathSeparators, + MpvTrack, + runCommand, + SubsyncContext, + SubsyncResolvedConfig, +} from '../../subsync/utils'; +import { isRemoteMediaPath } from '../../jimaku/utils'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:subsync'); + +interface FileExtractionResult { + path: string; + temporary: boolean; +} + +function summarizeCommandFailure(command: string, result: CommandResult): string { + const parts = [ + `code=${result.code ?? 'n/a'}`, + result.stderr ? `stderr: ${result.stderr}` : '', + result.stdout ? `stdout: ${result.stdout}` : '', + result.error ? `error: ${result.error}` : '', + ] + .map((value) => value.trim()) + .filter(Boolean); + + if (parts.length === 0) return `command failed (${command})`; + return `command failed (${command}) ${parts.join(' | ')}`; +} + +interface MpvClientLike { + connected: boolean; + currentAudioStreamIndex: number | null; + send: (payload: { command: (string | number)[] }) => void; + requestProperty: (name: string) => Promise; +} + +interface SubsyncCoreDeps { + getMpvClient: () => MpvClientLike | null; + getResolvedConfig: () => SubsyncResolvedConfig; +} + +function parseTrackId(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isInteger(value) ? value : null; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed.length) return null; + const parsed = Number(trimmed); + return Number.isInteger(parsed) && String(parsed) === trimmed ? parsed : null; + } + return null; +} + +function normalizeTrackIds(tracks: unknown[]): MpvTrack[] { + return tracks.map((track) => { + if (!track || typeof track !== 'object') return track as MpvTrack; + const typed = track as MpvTrack & { id?: unknown }; + const parsedId = parseTrackId(typed.id); + if (parsedId === null) { + const { id: _ignored, ...rest } = typed; + return rest as MpvTrack; + } + return { ...typed, id: parsedId }; + }); +} + +export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps { + isSubsyncInProgress: () => boolean; + setSubsyncInProgress: (inProgress: boolean) => void; + showMpvOsd: (text: string) => void; + runWithSubsyncSpinner: (task: () => Promise) => Promise; + openManualPicker: (payload: SubsyncManualPayload) => void; +} + +function getMpvClientForSubsync(deps: SubsyncCoreDeps): MpvClientLike { + const client = deps.getMpvClient(); + if (!client || !client.connected) { + throw new Error('MPV not connected'); + } + return client; +} + +async function gatherSubsyncContext(client: MpvClientLike): Promise { + const [videoPathRaw, sidRaw, secondarySidRaw, trackListRaw] = await Promise.all([ + client.requestProperty('path'), + client.requestProperty('sid'), + client.requestProperty('secondary-sid'), + client.requestProperty('track-list'), + ]); + + const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; + if (!videoPath) { + throw new Error('No video is currently loaded'); + } + + const tracks = Array.isArray(trackListRaw) ? normalizeTrackIds(trackListRaw as MpvTrack[]) : []; + const subtitleTracks = tracks.filter((track) => track.type === 'sub'); + const sid = parseTrackId(sidRaw); + const secondarySid = parseTrackId(secondarySidRaw); + + const primaryTrack = subtitleTracks.find((track) => track.id === sid); + if (!primaryTrack) { + throw new Error('No active subtitle track found'); + } + + const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null; + const sourceTracks = subtitleTracks + .filter((track) => track.id !== sid) + .filter((track) => { + if (!track.external) return true; + const filename = track['external-filename']; + return typeof filename === 'string' && filename.length > 0; + }); + + return { + videoPath, + primaryTrack, + secondaryTrack, + sourceTracks, + audioStreamIndex: client.currentAudioStreamIndex, + }; +} + +function ensureExecutablePath(pathOrName: string, name: string): string { + if (!pathOrName) { + throw new Error(`Missing ${name} path in config`); + } + + if (hasPathSeparators(pathOrName) && !fileExists(pathOrName)) { + throw new Error(`Configured ${name} executable not found: ${pathOrName}`); + } + return pathOrName; +} + +async function extractSubtitleTrackToFile( + ffmpegPath: string, + videoPath: string, + track: MpvTrack, +): Promise { + if (track.external) { + const externalPath = track['external-filename']; + if (typeof externalPath !== 'string' || externalPath.length === 0) { + throw new Error('External subtitle track has no file path'); + } + if (!fileExists(externalPath)) { + throw new Error(`Subtitle file not found: ${externalPath}`); + } + return { path: externalPath, temporary: false }; + } + + const ffIndex = track['ff-index']; + const extension = codecToExtension(track.codec); + if (typeof ffIndex !== 'number' || !Number.isInteger(ffIndex) || ffIndex < 0) { + throw new Error('Internal subtitle track has no valid ff-index'); + } + if (!extension) { + throw new Error(`Unsupported subtitle codec: ${track.codec ?? 'unknown'}`); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-')); + const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); + const extraction = await runCommand(ffmpegPath, [ + '-hide_banner', + '-nostdin', + '-y', + '-loglevel', + 'error', + '-an', + '-vn', + '-i', + videoPath, + '-map', + `0:${ffIndex}`, + '-f', + extension, + outputPath, + ]); + + if (!extraction.ok || !fileExists(outputPath)) { + throw new Error( + `Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure( + 'ffmpeg', + extraction, + )}`, + ); + } + + return { path: outputPath, temporary: true }; +} + +function cleanupTemporaryFile(extraction: FileExtractionResult): void { + if (!extraction.temporary) return; + try { + if (fileExists(extraction.path)) { + fs.unlinkSync(extraction.path); + } + } catch {} + try { + const dir = path.dirname(extraction.path); + if (fs.existsSync(dir)) { + fs.rmdirSync(dir); + } + } catch {} +} + +function buildRetimedPath(subPath: string): string { + const parsed = path.parse(subPath); + const suffix = `_retimed_${Date.now()}`; + return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`); +} + +async function runAlassSync( + alassPath: string, + referenceFile: string, + inputSubtitlePath: string, + outputPath: string, +): Promise { + return runCommand(alassPath, [referenceFile, inputSubtitlePath, outputPath]); +} + +async function runFfsubsyncSync( + ffsubsyncPath: string, + videoPath: string, + inputSubtitlePath: string, + outputPath: string, + audioStreamIndex: number | null, +): Promise { + const args = [videoPath, '-i', inputSubtitlePath, '-o', outputPath]; + if (audioStreamIndex !== null) { + args.push('--reference-stream', `0:${audioStreamIndex}`); + } + return runCommand(ffsubsyncPath, args); +} + +function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void { + if (!client.connected) { + throw new Error('MPV disconnected while loading subtitle'); + } + client.send({ command: ['sub_add', pathToLoad] }); + client.send({ command: ['set_property', 'sub-delay', 0] }); +} + +async function subsyncToReference( + engine: 'alass' | 'ffsubsync', + referenceFilePath: string, + context: SubsyncContext, + resolved: SubsyncResolvedConfig, + client: MpvClientLike, +): Promise { + const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); + const primaryExtraction = await extractSubtitleTrackToFile( + ffmpegPath, + context.videoPath, + context.primaryTrack, + ); + const outputPath = buildRetimedPath(primaryExtraction.path); + + try { + let result: CommandResult; + if (engine === 'alass') { + const alassPath = ensureExecutablePath(resolved.alassPath, 'alass'); + result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath); + } else { + const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync'); + result = await runFfsubsyncSync( + ffsubsyncPath, + context.videoPath, + primaryExtraction.path, + outputPath, + context.audioStreamIndex, + ); + } + + if (!result.ok || !fileExists(outputPath)) { + const details = summarizeCommandFailure(engine, result); + return { + ok: false, + message: `${engine} synchronization failed: ${details}`, + }; + } + + loadSyncedSubtitle(client, outputPath); + return { + ok: true, + message: `Subtitle synchronized with ${engine}`, + }; + } finally { + cleanupTemporaryFile(primaryExtraction); + } +} + +function validateFfsubsyncReference(videoPath: string): void { + if (isRemoteMediaPath(videoPath)) { + throw new Error( + 'FFsubsync cannot reliably sync stream URLs because it needs direct reference media access. Use Alass with a secondary subtitle source or play a local file.', + ); + } +} + +async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise { + const client = getMpvClientForSubsync(deps); + const context = await gatherSubsyncContext(client); + const resolved = deps.getResolvedConfig(); + const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); + + if (context.secondaryTrack) { + let secondaryExtraction: FileExtractionResult | null = null; + try { + secondaryExtraction = await extractSubtitleTrackToFile( + ffmpegPath, + context.videoPath, + context.secondaryTrack, + ); + const alassResult = await subsyncToReference( + 'alass', + secondaryExtraction.path, + context, + resolved, + client, + ); + if (alassResult.ok) { + return alassResult; + } + } catch (error) { + logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error); + } finally { + if (secondaryExtraction) { + cleanupTemporaryFile(secondaryExtraction); + } + } + } + + const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync'); + if (!ffsubsyncPath) { + return { + ok: false, + message: 'No secondary subtitle for alass and ffsubsync not configured', + }; + } + try { + validateFfsubsyncReference(context.videoPath); + } catch (error) { + return { + ok: false, + message: `ffsubsync synchronization failed: ${(error as Error).message}`, + }; + } + return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client); +} + +export async function runSubsyncManual( + request: SubsyncManualRunRequest, + deps: SubsyncCoreDeps, +): Promise { + const client = getMpvClientForSubsync(deps); + const context = await gatherSubsyncContext(client); + const resolved = deps.getResolvedConfig(); + + if (request.engine === 'ffsubsync') { + try { + validateFfsubsyncReference(context.videoPath); + } catch (error) { + return { + ok: false, + message: `ffsubsync synchronization failed: ${(error as Error).message}`, + }; + } + return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client); + } + + const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null); + if (!sourceTrack) { + return { ok: false, message: 'Select a subtitle source track for alass' }; + } + + const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); + let sourceExtraction: FileExtractionResult | null = null; + try { + sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack); + return subsyncToReference('alass', sourceExtraction.path, context, resolved, client); + } finally { + if (sourceExtraction) { + cleanupTemporaryFile(sourceExtraction); + } + } +} + +export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise { + const client = getMpvClientForSubsync(deps); + const context = await gatherSubsyncContext(client); + const payload: SubsyncManualPayload = { + sourceTracks: context.sourceTracks + .filter((track) => typeof track.id === 'number') + .map((track) => ({ + id: track.id as number, + label: formatTrackLabel(track), + })), + }; + deps.openManualPicker(payload); +} + +export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDeps): Promise { + if (deps.isSubsyncInProgress()) { + deps.showMpvOsd('Subsync already running'); + return; + } + + const resolved = deps.getResolvedConfig(); + try { + if (resolved.defaultMode === 'manual') { + await openSubsyncManualPicker(deps); + deps.showMpvOsd('Subsync: choose engine and source'); + return; + } + + deps.setSubsyncInProgress(true); + const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps)); + deps.showMpvOsd(result.message); + } catch (error) { + deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`); + } finally { + deps.setSubsyncInProgress(false); + } +} diff --git a/src/core/services/subtitle-position.ts b/src/core/services/subtitle-position.ts new file mode 100644 index 0000000..9974367 --- /dev/null +++ b/src/core/services/subtitle-position.ts @@ -0,0 +1,185 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { SecondarySubMode, SubtitlePosition } from '../../types'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:subtitle-position'); + +export interface CycleSecondarySubModeDeps { + getSecondarySubMode: () => SecondarySubMode; + setSecondarySubMode: (mode: SecondarySubMode) => void; + getLastSecondarySubToggleAtMs: () => number; + setLastSecondarySubToggleAtMs: (timestampMs: number) => void; + broadcastSecondarySubMode: (mode: SecondarySubMode) => void; + showMpvOsd: (text: string) => void; + now?: () => number; +} + +const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ['hidden', 'visible', 'hover']; +const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120; + +export function cycleSecondarySubMode(deps: CycleSecondarySubModeDeps): void { + const now = deps.now ? deps.now() : Date.now(); + if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) { + return; + } + deps.setLastSecondarySubToggleAtMs(now); + + const currentMode = deps.getSecondarySubMode(); + const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode); + const safeIndex = currentIndex >= 0 ? currentIndex : 0; + const nextMode = SECONDARY_SUB_CYCLE[(safeIndex + 1) % SECONDARY_SUB_CYCLE.length]!; + deps.setSecondarySubMode(nextMode); + deps.broadcastSecondarySubMode(nextMode); + deps.showMpvOsd(`Secondary subtitle: ${nextMode}`); +} + +function getSubtitlePositionFilePath(mediaPath: string, subtitlePositionsDir: string): string { + const key = normalizeMediaPathForSubtitlePosition(mediaPath); + const hash = crypto.createHash('sha256').update(key).digest('hex'); + return path.join(subtitlePositionsDir, `${hash}.json`); +} + +function normalizeMediaPathForSubtitlePosition(mediaPath: string): string { + const trimmed = mediaPath.trim(); + if (!trimmed) return trimmed; + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) || /^ytsearch:/.test(trimmed)) { + return trimmed; + } + + const resolved = path.resolve(trimmed); + let normalized = resolved; + try { + if (fs.existsSync(resolved)) { + normalized = fs.realpathSync(resolved); + } + } catch { + normalized = resolved; + } + + if (process.platform === 'win32') { + normalized = normalized.toLowerCase(); + } + + return normalized; +} + +function persistSubtitlePosition( + position: SubtitlePosition, + currentMediaPath: string | null, + subtitlePositionsDir: string, +): void { + if (!currentMediaPath) return; + if (!fs.existsSync(subtitlePositionsDir)) { + fs.mkdirSync(subtitlePositionsDir, { recursive: true }); + } + const positionPath = getSubtitlePositionFilePath(currentMediaPath, subtitlePositionsDir); + fs.writeFileSync(positionPath, JSON.stringify(position, null, 2)); +} + +export function loadSubtitlePosition( + options: { + currentMediaPath: string | null; + fallbackPosition: SubtitlePosition; + } & { subtitlePositionsDir: string }, +): SubtitlePosition | null { + if (!options.currentMediaPath) { + return options.fallbackPosition; + } + + try { + const positionPath = getSubtitlePositionFilePath( + options.currentMediaPath, + options.subtitlePositionsDir, + ); + if (!fs.existsSync(positionPath)) { + return options.fallbackPosition; + } + + const data = fs.readFileSync(positionPath, 'utf-8'); + const parsed = JSON.parse(data) as Partial; + if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) { + const position: SubtitlePosition = { yPercent: parsed.yPercent }; + if ( + typeof parsed.invisibleOffsetXPx === 'number' && + Number.isFinite(parsed.invisibleOffsetXPx) + ) { + position.invisibleOffsetXPx = parsed.invisibleOffsetXPx; + } + if ( + typeof parsed.invisibleOffsetYPx === 'number' && + Number.isFinite(parsed.invisibleOffsetYPx) + ) { + position.invisibleOffsetYPx = parsed.invisibleOffsetYPx; + } + return position; + } + return options.fallbackPosition; + } catch (err) { + logger.error('Failed to load subtitle position:', (err as Error).message); + return options.fallbackPosition; + } +} + +export function saveSubtitlePosition(options: { + position: SubtitlePosition; + currentMediaPath: string | null; + subtitlePositionsDir: string; + onQueuePending: (position: SubtitlePosition) => void; + onPersisted: () => void; +}): void { + if (!options.currentMediaPath) { + options.onQueuePending(options.position); + logger.warn('Queued subtitle position save - no media path yet'); + return; + } + + try { + persistSubtitlePosition( + options.position, + options.currentMediaPath, + options.subtitlePositionsDir, + ); + options.onPersisted(); + } catch (err) { + logger.error('Failed to save subtitle position:', (err as Error).message); + } +} + +export function updateCurrentMediaPath(options: { + mediaPath: unknown; + currentMediaPath: string | null; + pendingSubtitlePosition: SubtitlePosition | null; + subtitlePositionsDir: string; + loadSubtitlePosition: () => SubtitlePosition | null; + setCurrentMediaPath: (mediaPath: string | null) => void; + clearPendingSubtitlePosition: () => void; + setSubtitlePosition: (position: SubtitlePosition | null) => void; + broadcastSubtitlePosition: (position: SubtitlePosition | null) => void; +}): void { + const nextPath = + typeof options.mediaPath === 'string' && options.mediaPath.trim().length > 0 + ? options.mediaPath + : null; + if (nextPath === options.currentMediaPath) return; + options.setCurrentMediaPath(nextPath); + + if (nextPath && options.pendingSubtitlePosition) { + try { + persistSubtitlePosition( + options.pendingSubtitlePosition, + nextPath, + options.subtitlePositionsDir, + ); + options.setSubtitlePosition(options.pendingSubtitlePosition); + options.clearPendingSubtitlePosition(); + } catch (err) { + logger.error('Failed to persist queued subtitle position:', (err as Error).message); + } + } + + const position = options.loadSubtitlePosition(); + options.broadcastSubtitlePosition(position); +} diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts new file mode 100644 index 0000000..9a38eb8 --- /dev/null +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSubtitleProcessingController } from './subtitle-processing-controller'; +import type { SubtitleData } from '../../types'; + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('subtitle processing emits tokenized payload when tokenization succeeds', async () => { + const emitted: SubtitleData[] = []; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('字幕'); + await flushMicrotasks(); + assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]); +}); + +test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => { + const emitted: SubtitleData[] = []; + let firstResolve: ((value: SubtitleData | null) => void) | undefined; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + if (text === 'first') { + return await new Promise((resolve) => { + firstResolve = resolve; + }); + } + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('first'); + controller.onSubtitleChange('second'); + assert.ok(firstResolve); + firstResolve({ text: 'first', tokens: [] }); + await flushMicrotasks(); + await flushMicrotasks(); + + assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]); +}); + +test('subtitle processing skips duplicate subtitle emission', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('same'); + await flushMicrotasks(); + controller.onSubtitleChange('same'); + await flushMicrotasks(); + + assert.equal(emitted.length, 1); + assert.equal(tokenizeCalls, 1); +}); + +test('subtitle processing falls back to plain subtitle when tokenization returns null', async () => { + const emitted: SubtitleData[] = []; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async () => null, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('fallback'); + await flushMicrotasks(); + + assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]); +}); + +test('subtitle processing can refresh current subtitle without text change', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.onSubtitleChange('same'); + await flushMicrotasks(); + controller.refreshCurrentSubtitle(); + await flushMicrotasks(); + + assert.equal(tokenizeCalls, 2); + assert.deepEqual(emitted, [ + { text: 'same', tokens: [] }, + { text: 'same', tokens: [] }, + ]); +}); + +test('subtitle processing refresh can use explicit text override', async () => { + const emitted: SubtitleData[] = []; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.refreshCurrentSubtitle('initial'); + await flushMicrotasks(); + + assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]); +}); diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts new file mode 100644 index 0000000..ec6ddf4 --- /dev/null +++ b/src/core/services/subtitle-processing-controller.ts @@ -0,0 +1,101 @@ +import type { SubtitleData } from '../../types'; + +export interface SubtitleProcessingControllerDeps { + tokenizeSubtitle: (text: string) => Promise; + emitSubtitle: (payload: SubtitleData) => void; + logDebug?: (message: string) => void; + now?: () => number; +} + +export interface SubtitleProcessingController { + onSubtitleChange: (text: string) => void; + refreshCurrentSubtitle: (textOverride?: string) => void; +} + +export function createSubtitleProcessingController( + deps: SubtitleProcessingControllerDeps, +): SubtitleProcessingController { + let latestText = ''; + let lastEmittedText = ''; + let processing = false; + let staleDropCount = 0; + let refreshRequested = false; + const now = deps.now ?? (() => Date.now()); + + const processLatest = (): void => { + if (processing) { + return; + } + + processing = true; + + void (async () => { + while (true) { + const text = latestText; + const forceRefresh = refreshRequested; + refreshRequested = false; + const startedAtMs = now(); + + if (!text.trim()) { + deps.emitSubtitle({ text, tokens: null }); + lastEmittedText = text; + break; + } + + let output: SubtitleData = { text, tokens: null }; + try { + const tokenized = await deps.tokenizeSubtitle(text); + if (tokenized) { + output = tokenized; + } + } catch (error) { + deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`); + } + + if (latestText !== text) { + staleDropCount += 1; + deps.logDebug?.( + `Dropped stale subtitle tokenization result; dropped=${staleDropCount}, elapsed=${now() - startedAtMs}ms`, + ); + continue; + } + + deps.emitSubtitle(output); + lastEmittedText = text; + deps.logDebug?.( + `Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`, + ); + break; + } + })() + .catch((error) => { + deps.logDebug?.(`Subtitle processing loop failed: ${(error as Error).message}`); + }) + .finally(() => { + processing = false; + if (refreshRequested || latestText !== lastEmittedText) { + processLatest(); + } + }); + }; + + return { + onSubtitleChange: (text: string) => { + if (text === latestText) { + return; + } + latestText = text; + processLatest(); + }, + refreshCurrentSubtitle: (textOverride?: string) => { + if (typeof textOverride === 'string') { + latestText = textOverride; + } + if (!latestText.trim()) { + return; + } + refreshRequested = true; + processLatest(); + }, + }; +} diff --git a/src/core/services/subtitle-ws.test.ts b/src/core/services/subtitle-ws.test.ts new file mode 100644 index 0000000..a4f6d2e --- /dev/null +++ b/src/core/services/subtitle-ws.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { serializeSubtitleMarkup, serializeSubtitleWebsocketMessage } from './subtitle-ws'; +import { PartOfSpeech, type SubtitleData } from '../../types'; + +const frequencyOptions = { + enabled: true, + topX: 1000, + mode: 'banded' as const, +}; + +test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => { + const payload: SubtitleData = { + text: 'a < b\nx & y', + tokens: null, + }; + + assert.equal(serializeSubtitleMarkup(payload, frequencyOptions), 'a < b
x & y'); +}); + +test('serializeSubtitleMarkup includes known, n+1, jlpt, and frequency classes', () => { + const payload: SubtitleData = { + text: 'ignored', + tokens: [ + { + surface: '既知', + reading: '', + headword: '', + startPos: 0, + endPos: 2, + partOfSpeech: PartOfSpeech.other, + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: '新語', + reading: '', + headword: '', + startPos: 2, + endPos: 4, + partOfSpeech: PartOfSpeech.other, + isMerged: false, + isKnown: false, + isNPlusOneTarget: true, + }, + { + surface: '級', + reading: '', + headword: '', + startPos: 4, + endPos: 5, + partOfSpeech: PartOfSpeech.other, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + jlptLevel: 'N3', + }, + { + surface: '頻度', + reading: '', + headword: '', + startPos: 5, + endPos: 7, + partOfSpeech: PartOfSpeech.other, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + frequencyRank: 10, + }, + ], + }; + + const markup = serializeSubtitleMarkup(payload, frequencyOptions); + assert.match(markup, /word word-known/); + assert.match(markup, /word word-n-plus-one/); + assert.match(markup, /word word-jlpt-n3/); + assert.match(markup, /word word-frequency-band-1/); +}); + +test('serializeSubtitleWebsocketMessage emits sentence payload', () => { + const payload: SubtitleData = { + text: '字幕', + tokens: null, + }; + + const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions); + assert.deepEqual(JSON.parse(raw), { sentence: '字幕' }); +}); diff --git a/src/core/services/subtitle-ws.ts b/src/core/services/subtitle-ws.ts new file mode 100644 index 0000000..f416b4f --- /dev/null +++ b/src/core/services/subtitle-ws.ts @@ -0,0 +1,158 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import WebSocket from 'ws'; +import { createLogger } from '../../logger'; +import type { MergedToken, SubtitleData } from '../../types'; + +const logger = createLogger('main:subtitle-ws'); + +export function hasMpvWebsocketPlugin(): boolean { + const mpvWebsocketPath = path.join(os.homedir(), '.config', 'mpv', 'mpv_websocket'); + return fs.existsSync(mpvWebsocketPath); +} + +export type SubtitleWebsocketFrequencyOptions = { + enabled: boolean; + topX: number; + mode: 'single' | 'banded'; +}; + +function escapeHtml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function computeFrequencyClass( + token: MergedToken, + options: SubtitleWebsocketFrequencyOptions, +): string | null { + if (!options.enabled) return null; + if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) return null; + + const rank = Math.max(1, Math.floor(token.frequencyRank)); + const topX = Math.max(1, Math.floor(options.topX)); + if (rank > topX) return null; + + if (options.mode === 'banded') { + const band = Math.min(5, Math.max(1, Math.ceil((rank / topX) * 5))); + return `word-frequency-band-${band}`; + } + + return 'word-frequency-single'; +} + +function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequencyOptions): string { + const classes = ['word']; + + if (token.isNPlusOneTarget) { + classes.push('word-n-plus-one'); + } else if (token.isKnown) { + classes.push('word-known'); + } + + if (token.jlptLevel) { + classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`); + } + + if (!token.isKnown && !token.isNPlusOneTarget) { + const frequencyClass = computeFrequencyClass(token, options); + if (frequencyClass) { + classes.push(frequencyClass); + } + } + + return classes.join(' '); +} + +export function serializeSubtitleMarkup( + payload: SubtitleData, + options: SubtitleWebsocketFrequencyOptions, +): string { + if (!payload.tokens || payload.tokens.length === 0) { + return escapeHtml(payload.text).replaceAll('\n', '
'); + } + + const chunks: string[] = []; + for (const token of payload.tokens) { + const klass = computeWordClass(token, options); + const parts = token.surface.split('\n'); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (part) { + chunks.push(`${escapeHtml(part)}`); + } + if (index < parts.length - 1) { + chunks.push('
'); + } + } + } + + return chunks.join(''); +} + +export function serializeSubtitleWebsocketMessage( + payload: SubtitleData, + options: SubtitleWebsocketFrequencyOptions, +): string { + return JSON.stringify({ sentence: serializeSubtitleMarkup(payload, options) }); +} + +export class SubtitleWebSocket { + private server: WebSocket.Server | null = null; + private latestMessage = ''; + + public isRunning(): boolean { + return this.server !== null; + } + + public hasClients(): boolean { + return (this.server?.clients.size ?? 0) > 0; + } + + public start(port: number, getCurrentSubtitleText: () => string): void { + this.server = new WebSocket.Server({ port, host: '127.0.0.1' }); + + this.server.on('connection', (ws: WebSocket) => { + logger.info('WebSocket client connected'); + if (this.latestMessage) { + ws.send(this.latestMessage); + return; + } + + const currentText = getCurrentSubtitleText(); + if (currentText) { + ws.send(JSON.stringify({ sentence: currentText })); + } + }); + + this.server.on('error', (err: Error) => { + logger.error('WebSocket server error:', err.message); + }); + + logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`); + } + + public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void { + if (!this.server) return; + const message = serializeSubtitleWebsocketMessage(payload, options); + this.latestMessage = message; + for (const client of this.server.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + } + } + + public stop(): void { + if (this.server) { + this.server.close(); + this.server = null; + } + this.latestMessage = ''; + } +} diff --git a/src/core/services/texthooker.ts b/src/core/services/texthooker.ts new file mode 100644 index 0000000..6d9e6ff --- /dev/null +++ b/src/core/services/texthooker.ts @@ -0,0 +1,76 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:texthooker'); + +export class Texthooker { + private server: http.Server | null = null; + + public isRunning(): boolean { + return this.server !== null; + } + + public start(port: number): http.Server | null { + const texthookerPath = this.getTexthookerPath(); + if (!texthookerPath) { + logger.error('texthooker-ui not found'); + return null; + } + + this.server = http.createServer((req, res) => { + const urlPath = (req.url || '/').split('?')[0] ?? '/'; + const filePath = path.join(texthookerPath, urlPath === '/' ? 'index.html' : urlPath); + + const ext = path.extname(filePath); + const mimeTypes: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + }; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + return; + } + res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' }); + res.end(data); + }); + }); + + this.server.listen(port, '127.0.0.1', () => { + logger.info(`Texthooker server running at http://127.0.0.1:${port}`); + }); + + return this.server; + } + + public stop(): void { + if (this.server) { + this.server.close(); + this.server = null; + } + } + + private getTexthookerPath(): string | null { + const searchPaths = [ + path.join(__dirname, '..', '..', '..', 'vendor', 'texthooker-ui', 'docs'), + path.join(process.resourcesPath, 'app', 'vendor', 'texthooker-ui', 'docs'), + ]; + for (const candidate of searchPaths) { + if (fs.existsSync(path.join(candidate, 'index.html'))) { + return candidate; + } + } + return null; + } +} diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts new file mode 100644 index 0000000..eccdc3f --- /dev/null +++ b/src/core/services/tokenizer.test.ts @@ -0,0 +1,1698 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PartOfSpeech } from '../../types'; +import { createTokenizerDepsRuntime, TokenizerServiceDeps, tokenizeSubtitle } from './tokenizer'; + +function makeDeps(overrides: Partial = {}): TokenizerServiceDeps { + return { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: () => false, + getKnownWordMatchMode: () => 'headword', + getJlptLevel: () => null, + tokenizeWithMecab: async () => null, + ...overrides, + }; +} + +interface YomitanTokenInput { + surface: string; + reading?: string; + headword?: string; +} + +function makeDepsFromYomitanTokens( + tokens: YomitanTokenInput[], + overrides: Partial = {}, +): TokenizerServiceDeps { + return makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: tokens.map((token) => [ + { + text: token.surface, + reading: token.reading ?? token.surface, + headwords: [[{ term: token.headword ?? token.surface }]], + }, + ]), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + ...overrides, + }); +} + +test('tokenizeSubtitle assigns JLPT level to parsed Yomitan tokens', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [[{ term: '猫' }]], + }, + { + text: 'です', + reading: 'です', + headwords: [[{ term: 'です' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => null, + getJlptLevel: (text) => (text === '猫' ? 'N5' : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); +}); + +test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => { + let lookupCalls = 0; + const result = await tokenizeSubtitle( + '猫猫', + makeDepsFromYomitanTokens( + [ + { surface: '猫', reading: 'ねこ', headword: '猫' }, + { surface: '猫', reading: 'ねこ', headword: '猫' }, + ], + { + getJlptLevel: (text) => { + lookupCalls += 1; + return text === '猫' ? 'N5' : null; + }, + }, + ), + ); + + assert.equal(result.tokens?.length, 2); + assert.equal(lookupCalls, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); + assert.equal(result.tokens?.[1]?.jlptLevel, 'N5'); +}); + +test('tokenizeSubtitle leaves JLPT unset for non-matching tokens', async () => { + const result = await tokenizeSubtitle( + '猫', + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { + getJlptLevel: () => null, + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, undefined); +}); + +test('tokenizeSubtitle skips JLPT lookups when disabled', async () => { + let lookupCalls = 0; + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { + getJlptLevel: () => { + lookupCalls += 1; + return 'N5'; + }, + getJlptEnabled: () => false, + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, undefined); + assert.equal(lookupCalls, 0); +}); + +test('tokenizeSubtitle applies frequency dictionary ranks', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens( + [ + { surface: '猫', reading: 'ねこ', headword: '猫' }, + { surface: 'です', reading: 'です', headword: 'です' }, + ], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '猫' ? 23 : 1200), + }, + ), + ); + + assert.equal(result.tokens?.length, 2); + assert.equal(result.tokens?.[0]?.frequencyRank, 23); + assert.equal(result.tokens?.[1]?.frequencyRank, 1200); +}); + +test('tokenizeSubtitle uses only selected Yomitan headword for frequency lookup', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫です', + reading: 'ねこです', + headwords: [[{ term: '猫です' }], [{ term: '猫' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => (text === '猫' ? 40 : text === '猫です' ? 1200 : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.frequencyRank, 1200); +}); + +test('tokenizeSubtitle keeps furigana-split Yomitan segments as one token', async () => { + const result = await tokenizeSubtitle( + '友達と話した', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '友', + reading: 'とも', + headwords: [[{ term: '友達' }]], + }, + { + text: '達', + reading: 'だち', + }, + ], + [ + { + text: 'と', + reading: 'と', + headwords: [[{ term: 'と' }]], + }, + ], + [ + { + text: '話した', + reading: 'はなした', + headwords: [[{ term: '話す' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => (text === '友達' ? 22 : text === '話す' ? 90 : null), + }), + ); + + assert.equal(result.tokens?.length, 3); + assert.equal(result.tokens?.[0]?.surface, '友達'); + assert.equal(result.tokens?.[0]?.reading, 'ともだち'); + assert.equal(result.tokens?.[0]?.headword, '友達'); + assert.equal(result.tokens?.[0]?.frequencyRank, 22); + assert.equal(result.tokens?.[1]?.surface, 'と'); + assert.equal(result.tokens?.[1]?.frequencyRank, undefined); + assert.equal(result.tokens?.[2]?.surface, '話した'); + assert.equal(result.tokens?.[2]?.frequencyRank, 90); +}); + +test('tokenizeSubtitle prefers exact headword frequency over surface/reading when available', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [[{ term: 'ネコ' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => (text === '猫' ? 1200 : text === 'ネコ' ? 8 : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.frequencyRank, 8); +}); + +test('tokenizeSubtitle keeps no frequency when only reading matches and headword misses', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [[{ term: '猫です' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => (text === 'ねこ' ? 77 : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); +}); + +test('tokenizeSubtitle ignores invalid frequency rank on selected headword', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫です', + reading: 'ねこです', + headwords: [[{ term: '猫' }], [{ term: '猫です' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => (text === '猫' ? Number.NaN : text === '猫です' ? 500 : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); +}); + +test('tokenizeSubtitle handles real-word frequency candidates and prefers most frequent term', async () => { + const result = await tokenizeSubtitle( + '昨日', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '昨日', + reading: 'きのう', + headwords: [[{ term: '昨日' }], [{ term: 'きのう' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => (text === 'きのう' ? 120 : text === '昨日' ? 40 : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.frequencyRank, 40); +}); + +test('tokenizeSubtitle ignores candidates with no dictionary rank when higher-frequency candidate exists', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [ + [{ term: '猫' }], + [{ term: '猫です' }], + [{ term: 'unknown-term' }], + ], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => + text === 'unknown-term' ? -1 : text === '猫' ? 88 : text === '猫です' ? 9000 : null, + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.frequencyRank, 88); +}); + +test('tokenizeSubtitle ignores frequency lookup failures', async () => { + const result = await tokenizeSubtitle( + '猫', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + tokenizeWithMecab: async () => [ + { + headword: '猫', + surface: '猫', + reading: 'ネコ', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + getFrequencyRank: () => { + throw new Error('frequency lookup unavailable'); + }, + }), + ); + + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); +}); + +test('tokenizeSubtitle skips frequency rank when Yomitan token is enriched as particle by mecab pos1', async () => { + const result = await tokenizeSubtitle( + 'は', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: 'は', + reading: 'は', + headwords: [[{ term: 'は' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => [ + { + headword: 'は', + surface: 'は', + reading: 'ハ', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + getFrequencyRank: (text) => (text === 'は' ? 10 : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.pos1, '助詞'); + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); +}); + +test('tokenizeSubtitle ignores invalid frequency ranks', async () => { + const result = await tokenizeSubtitle( + '猫', + makeDepsFromYomitanTokens( + [ + { surface: '猫', reading: 'ねこ', headword: '猫' }, + { surface: 'です', reading: 'です', headword: 'です' }, + ], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => { + if (text === '猫') return Number.NaN; + if (text === 'です') return -1; + return 100; + }, + }, + ), + ); + + assert.equal(result.tokens?.length, 2); + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); + assert.equal(result.tokens?.[1]?.frequencyRank, undefined); +}); + +test('tokenizeSubtitle skips frequency lookups when disabled', async () => { + let frequencyCalls = 0; + const result = await tokenizeSubtitle( + '猫', + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { + getFrequencyDictionaryEnabled: () => false, + getFrequencyRank: () => { + frequencyCalls += 1; + return 10; + }, + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); + assert.equal(frequencyCalls, 0); +}); + +test('tokenizeSubtitle skips JLPT level for excluded demonstratives', async () => { + const result = await tokenizeSubtitle( + 'この', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: 'この', + reading: 'この', + headwords: [[{ term: 'この' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => null, + getJlptLevel: (text) => (text === 'この' ? 'N5' : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, undefined); +}); + +test('tokenizeSubtitle skips JLPT level for repeated kana SFX', async () => { + const result = await tokenizeSubtitle( + 'ああ', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: 'ああ', + reading: 'ああ', + headwords: [[{ term: 'ああ' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => null, + getJlptLevel: (text) => (text === 'ああ' ? 'N5' : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, undefined); +}); + +test('tokenizeSubtitle assigns JLPT level to Yomitan tokens', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { + getJlptLevel: (text) => (text === '猫' ? 'N4' : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, 'N4'); +}); + +test('tokenizeSubtitle can assign JLPT level to Yomitan particle token', async () => { + const result = await tokenizeSubtitle( + 'は', + makeDepsFromYomitanTokens([{ surface: 'は', reading: 'は', headword: 'は' }], { + getJlptLevel: (text) => (text === 'は' ? 'N5' : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); +}); + +test('tokenizeSubtitle returns null tokens for empty normalized text', async () => { + const result = await tokenizeSubtitle(' \\n ', makeDeps()); + assert.deepEqual(result, { text: ' \\n ', tokens: null }); +}); + +test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async () => { + let parseInput = ''; + const result = await tokenizeSubtitle( + '猫\\Nです\nね', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async (script: string) => { + parseInput = script; + return null; + }, + }, + }) as unknown as Electron.BrowserWindow, + }), + ); + + assert.match(parseInput, /猫 です ね/); + assert.equal(result.text, '猫\nです\nね'); + assert.equal(result.tokens, null); +}); + +test('tokenizeSubtitle returns null tokens when Yomitan parsing is unavailable', async () => { + const result = await tokenizeSubtitle('猫です', makeDeps()); + + assert.deepEqual(result, { text: '猫です', tokens: null }); +}); + +test('tokenizeSubtitle returns null tokens when mecab throws', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + tokenizeWithMecab: async () => { + throw new Error('mecab failed'); + }, + }), + ); + + assert.deepEqual(result, { text: '猫です', tokens: null }); +}); + +test('tokenizeSubtitle uses Yomitan parser result when available', async () => { + const parserWindow = { + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [[{ term: '猫' }]], + }, + ], + [ + { + text: 'です', + reading: 'です', + }, + ], + ], + }, + ], + }, + } as unknown as Electron.BrowserWindow; + + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => parserWindow, + tokenizeWithMecab: async () => null, + }), + ); + + assert.equal(result.text, '猫です'); + assert.equal(result.tokens?.length, 2); + assert.equal(result.tokens?.[0]?.surface, '猫'); + assert.equal(result.tokens?.[0]?.reading, 'ねこ'); + assert.equal(result.tokens?.[0]?.isKnown, false); + assert.equal(result.tokens?.[1]?.surface, 'です'); + assert.equal(result.tokens?.[1]?.reading, 'です'); + assert.equal(result.tokens?.[1]?.isKnown, false); +}); + +test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => { + const infoLogs: string[] = []; + const originalInfo = console.info; + console.info = (...args: unknown[]) => { + infoLogs.push(args.map((value) => String(value)).join(' ')); + }; + + try { + await tokenizeSubtitle( + '友達と話した', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '友', + reading: 'とも', + headwords: [[{ term: '友達' }]], + }, + { + text: '達', + reading: 'だち', + }, + ], + [ + { + text: 'と', + reading: 'と', + headwords: [[{ term: 'と' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => null, + getYomitanGroupDebugEnabled: () => true, + }), + ); + } finally { + console.info = originalInfo; + } + + assert.ok(infoLogs.some((line) => line.includes('Selected Yomitan token groups'))); +}); + +test('tokenizeSubtitle does not log Yomitan groups when debug toggle is disabled', async () => { + const infoLogs: string[] = []; + const originalInfo = console.info; + console.info = (...args: unknown[]) => { + infoLogs.push(args.map((value) => String(value)).join(' ')); + }; + + try { + await tokenizeSubtitle( + '友達と話した', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '友', + reading: 'とも', + headwords: [[{ term: '友達' }]], + }, + { + text: '達', + reading: 'だち', + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => null, + getYomitanGroupDebugEnabled: () => false, + }), + ); + } finally { + console.info = originalInfo; + } + + assert.equal( + infoLogs.some((line) => line.includes('Selected Yomitan token groups')), + false, + ); +}); + +test('tokenizeSubtitle preserves segmented Yomitan line as one token', async () => { + const parserWindow = { + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [[{ term: '猫です' }]], + }, + { + text: 'です', + reading: 'です', + }, + ], + ], + }, + ], + }, + } as unknown as Electron.BrowserWindow; + + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => parserWindow, + tokenizeWithMecab: async () => null, + }), + ); + + assert.equal(result.text, '猫です'); + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.surface, '猫です'); + assert.equal(result.tokens?.[0]?.reading, 'ねこです'); + assert.equal(result.tokens?.[0]?.headword, '猫です'); + assert.equal(result.tokens?.[0]?.isKnown, false); +}); + +test('tokenizeSubtitle keeps scanning parser token when scanning parser returns one token', async () => { + const result = await tokenizeSubtitle( + '俺は小園にいきたい', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '俺は小園にいきたい', + reading: 'おれは小園にいきたい', + headwords: [[{ term: '俺は小園にいきたい' }]], + }, + ], + ], + }, + { + source: 'mecab', + index: 0, + content: [ + [ + { + text: '俺', + reading: 'おれ', + headwords: [[{ term: '俺' }]], + }, + ], + [ + { + text: 'は', + reading: 'は', + headwords: [[{ term: 'は' }]], + }, + ], + [ + { + text: '小園', + reading: 'おうえん', + headwords: [[{ term: '小園' }]], + }, + ], + [ + { + text: 'に', + reading: 'に', + headwords: [[{ term: 'に' }]], + }, + ], + [ + { + text: 'いきたい', + reading: 'いきたい', + headwords: [[{ term: 'いきたい' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyDictionaryEnabled: () => true, + tokenizeWithMecab: async () => null, + getFrequencyRank: (text) => (text === '小園' ? 25 : text === 'いきたい' ? 1500 : null), + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.map((token) => token.surface).join(','), '俺は小園にいきたい'); + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); +}); + +test('tokenizeSubtitle keeps scanning parser tokens when they are already split', async () => { + const result = await tokenizeSubtitle( + '小園に行きたい', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '小園', + reading: 'おうえん', + headwords: [[{ term: '小園' }]], + }, + ], + [ + { + text: 'に', + reading: 'に', + headwords: [[{ term: 'に' }]], + }, + ], + [ + { + text: '行きたい', + reading: 'いきたい', + headwords: [[{ term: '行きたい' }]], + }, + ], + ], + }, + { + source: 'mecab', + index: 0, + content: [ + [ + { + text: '小', + reading: 'お', + headwords: [[{ term: '小' }]], + }, + ], + [ + { + text: '園', + reading: 'えん', + headwords: [[{ term: '園' }]], + }, + ], + [ + { + text: 'に', + reading: 'に', + headwords: [[{ term: 'に' }]], + }, + ], + [ + { + text: '行き', + reading: 'いき', + headwords: [[{ term: '行き' }]], + }, + ], + [ + { + text: 'たい', + reading: 'たい', + headwords: [[{ term: 'たい' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '小園' ? 20 : null), + tokenizeWithMecab: async () => null, + }), + ); + + assert.equal(result.tokens?.length, 3); + assert.equal(result.tokens?.map((token) => token.surface).join(','), '小園,に,行きたい'); + assert.equal(result.tokens?.[0]?.frequencyRank, 20); + assert.equal(result.tokens?.[1]?.frequencyRank, undefined); + assert.equal(result.tokens?.[2]?.frequencyRank, undefined); +}); + +test('tokenizeSubtitle keeps parsing explicit by scanning-parser source only', async () => { + const result = await tokenizeSubtitle( + '俺は公園にいきたい', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '俺', + reading: 'おれ', + headwords: [[{ term: '俺' }]], + }, + ], + [{ text: 'は', reading: '', headwords: [[{ term: 'は' }]] }], + [ + { + text: '公園', + reading: 'こうえん', + headwords: [[{ term: '公園' }]], + }, + ], + [ + { + text: 'にい', + reading: '', + headwords: [[{ term: '兄' }], [{ term: '二位' }]], + }, + ], + [ + { + text: 'きたい', + reading: '', + headwords: [[{ term: '期待' }], [{ term: '来る' }]], + }, + ], + ], + }, + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '俺', + reading: 'おれ', + headwords: [[{ term: '俺' }]], + }, + ], + [ + { + text: 'は', + reading: 'は', + headwords: [[{ term: 'は' }]], + }, + ], + [ + { + text: '公園', + reading: 'こうえん', + headwords: [[{ term: '公園' }]], + }, + ], + [ + { + text: 'に', + reading: 'に', + headwords: [[{ term: 'に' }]], + }, + ], + [ + { + text: '行きたい', + reading: 'いきたい', + headwords: [[{ term: '行きたい' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => + text === '俺' ? 51 : text === '公園' ? 2304 : text === '行きたい' ? 1500 : null, + tokenizeWithMecab: async () => null, + }), + ); + + assert.equal(result.tokens?.map((token) => token.surface).join(','), '俺,は,公園,に,行きたい'); + assert.equal(result.tokens?.[1]?.frequencyRank, undefined); + assert.equal(result.tokens?.[3]?.frequencyRank, undefined); + assert.equal(result.tokens?.[4]?.frequencyRank, 1500); +}); + +test('tokenizeSubtitle still assigns frequency to non-known Yomitan tokens', async () => { + const result = await tokenizeSubtitle( + '小園に', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '小園', + reading: 'おうえん', + headwords: [[{ term: '小園' }]], + }, + ], + [ + { + text: 'に', + reading: 'に', + headwords: [[{ term: 'に' }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '小園' ? 75 : text === 'に' ? 3000 : null), + isKnownWord: (text) => text === '小園', + }), + ); + + assert.equal(result.tokens?.length, 2); + assert.equal(result.tokens?.[0]?.isKnown, true); + assert.equal(result.tokens?.[0]?.frequencyRank, 75); + assert.equal(result.tokens?.[1]?.isKnown, false); + assert.equal(result.tokens?.[1]?.frequencyRank, 3000); +}); + +test('tokenizeSubtitle marks tokens as known using callback', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { + isKnownWord: (text) => text === '猫', + }), + ); + + assert.equal(result.text, '猫です'); + assert.equal(result.tokens?.[0]?.isKnown, true); +}); + +test('tokenizeSubtitle still assigns frequency rank to non-known tokens', async () => { + const result = await tokenizeSubtitle( + '既知未知', + makeDepsFromYomitanTokens( + [ + { surface: '既知', reading: 'きち', headword: '既知' }, + { surface: '未知', reading: 'みち', headword: '未知' }, + ], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '既知' ? 20 : text === '未知' ? 30 : null), + isKnownWord: (text) => text === '既知', + }, + ), + ); + + assert.equal(result.tokens?.length, 2); + assert.equal(result.tokens?.[0]?.isKnown, true); + assert.equal(result.tokens?.[0]?.frequencyRank, 20); + assert.equal(result.tokens?.[1]?.isKnown, false); + assert.equal(result.tokens?.[1]?.frequencyRank, 30); +}); + +test('tokenizeSubtitle selects one N+1 target token', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens( + [ + { surface: '私', reading: 'わたし', headword: '私' }, + { surface: '犬', reading: 'いぬ', headword: '犬' }, + ], + { + getMinSentenceWordsForNPlusOne: () => 2, + isKnownWord: (text) => text === '私', + }, + ), + ); + + const targets = result.tokens?.filter((token) => token.isNPlusOneTarget) ?? []; + assert.equal(targets.length, 1); + assert.equal(targets[0]?.surface, '犬'); +}); + +test('tokenizeSubtitle does not mark target when sentence has multiple candidates', async () => { + const result = await tokenizeSubtitle( + '猫犬', + makeDepsFromYomitanTokens( + [ + { surface: '猫', reading: 'ねこ', headword: '猫' }, + { surface: '犬', reading: 'いぬ', headword: '犬' }, + ], + {}, + ), + ); + + assert.equal( + result.tokens?.some((token) => token.isNPlusOneTarget), + false, + ); +}); + +test('tokenizeSubtitle applies N+1 target marking to Yomitan results', async () => { + const parserWindow = { + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [ + { + text: '猫', + reading: 'ねこ', + headwords: [[{ term: '猫' }]], + }, + ], + [ + { + text: 'です', + reading: 'です', + headwords: [[{ term: 'です' }]], + }, + ], + ], + }, + ], + }, + } as unknown as Electron.BrowserWindow; + + const result = await tokenizeSubtitle( + '猫です', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => parserWindow, + tokenizeWithMecab: async () => null, + isKnownWord: (text) => text === 'です', + getMinSentenceWordsForNPlusOne: () => 2, + }), + ); + + assert.equal(result.text, '猫です'); + assert.equal(result.tokens?.length, 2); + assert.equal(result.tokens?.[0]?.surface, '猫'); + assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true); + assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); +}); + +test('tokenizeSubtitle ignores Yomitan functional tokens when evaluating N+1 candidates', async () => { + const parserWindow = { + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [{ text: '私', reading: 'わたし', headwords: [[{ term: '私' }]] }], + [{ text: 'も', reading: 'も', headwords: [[{ term: 'も' }]] }], + [{ text: 'あの', reading: 'あの', headwords: [[{ term: 'あの' }]] }], + [{ text: '仮面', reading: 'かめん', headwords: [[{ term: '仮面' }]] }], + [{ text: 'が', reading: 'が', headwords: [[{ term: 'が' }]] }], + [{ text: '欲しい', reading: 'ほしい', headwords: [[{ term: '欲しい' }]] }], + [{ text: 'です', reading: 'です', headwords: [[{ term: 'です' }]] }], + ], + }, + ], + }, + } as unknown as Electron.BrowserWindow; + + const result = await tokenizeSubtitle( + '私も あの仮面が欲しいです', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => parserWindow, + tokenizeWithMecab: async () => [ + { + surface: '私', + reading: 'ワタシ', + headword: '私', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: 'も', + reading: 'モ', + headword: 'も', + startPos: 1, + endPos: 2, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: 'あの', + reading: 'アノ', + headword: 'あの', + startPos: 2, + endPos: 4, + partOfSpeech: PartOfSpeech.other, + pos1: '連体詞', + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: '仮面', + reading: 'カメン', + headword: '仮面', + startPos: 4, + endPos: 6, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: 'が', + reading: 'ガ', + headword: 'が', + startPos: 6, + endPos: 7, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: '欲しい', + reading: 'ホシイ', + headword: '欲しい', + startPos: 7, + endPos: 10, + partOfSpeech: PartOfSpeech.i_adjective, + pos1: '形容詞', + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: 'です', + reading: 'デス', + headword: 'です', + startPos: 10, + endPos: 12, + partOfSpeech: PartOfSpeech.bound_auxiliary, + pos1: '助動詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + isKnownWord: (text) => text === '私' || text === 'あの' || text === '欲しい', + }), + ); + + const targets = result.tokens?.filter((token) => token.isNPlusOneTarget) ?? []; + assert.equal(targets.length, 1); + assert.equal(targets[0]?.surface, '仮面'); +}); + +test('tokenizeSubtitle keeps correct MeCab pos1 enrichment when Yomitan offsets skip spaces', async () => { + const parserWindow = { + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: 'scanning-parser', + index: 0, + content: [ + [{ text: '私', reading: 'わたし', headwords: [[{ term: '私' }]] }], + [{ text: 'も', reading: 'も', headwords: [[{ term: 'も' }]] }], + [{ text: 'あの', reading: 'あの', headwords: [[{ term: 'あの' }]] }], + [{ text: '仮面', reading: 'かめん', headwords: [[{ term: '仮面' }]] }], + [{ text: 'が', reading: 'が', headwords: [[{ term: 'が' }]] }], + [{ text: '欲しい', reading: 'ほしい', headwords: [[{ term: '欲しい' }]] }], + [{ text: 'です', reading: 'です', headwords: [[{ term: 'です' }]] }], + ], + }, + ], + }, + } as unknown as Electron.BrowserWindow; + + const result = await tokenizeSubtitle( + '私も あの仮面が欲しいです', + makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => parserWindow, + tokenizeWithMecab: async () => [ + { + surface: '私', + reading: 'ワタシ', + headword: '私', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: 'も', + reading: 'モ', + headword: 'も', + startPos: 1, + endPos: 2, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: ' ', + reading: '', + headword: ' ', + startPos: 2, + endPos: 3, + partOfSpeech: PartOfSpeech.symbol, + pos1: '記号', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: 'あの', + reading: 'アノ', + headword: 'あの', + startPos: 3, + endPos: 5, + partOfSpeech: PartOfSpeech.other, + pos1: '連体詞', + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: '仮面', + reading: 'カメン', + headword: '仮面', + startPos: 5, + endPos: 7, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: 'が', + reading: 'ガ', + headword: 'が', + startPos: 7, + endPos: 8, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: '欲しい', + reading: 'ホシイ', + headword: '欲しい', + startPos: 8, + endPos: 11, + partOfSpeech: PartOfSpeech.i_adjective, + pos1: '形容詞', + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: 'です', + reading: 'デス', + headword: 'です', + startPos: 11, + endPos: 13, + partOfSpeech: PartOfSpeech.bound_auxiliary, + pos1: '助動詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + isKnownWord: (text) => text === '私' || text === 'あの' || text === '欲しい', + }), + ); + + const targets = result.tokens?.filter((token) => token.isNPlusOneTarget) ?? []; + const gaToken = result.tokens?.find((token) => token.surface === 'が'); + const desuToken = result.tokens?.find((token) => token.surface === 'です'); + assert.equal(gaToken?.pos1, '助詞'); + assert.equal(desuToken?.pos1, '助動詞'); + assert.equal(targets.length, 1); + assert.equal(targets[0]?.surface, '仮面'); +}); + +test('tokenizeSubtitle does not color 1-2 word sentences by default', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens( + [ + { surface: '私', reading: 'わたし', headword: '私' }, + { surface: '犬', reading: 'いぬ', headword: '犬' }, + ], + {}, + ), + ); + + assert.equal( + result.tokens?.some((token) => token.isNPlusOneTarget), + false, + ); +}); + +test('tokenizeSubtitle checks known words by headword, not surface', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫です' }], { + isKnownWord: (text) => text === '猫です', + }), + ); + + assert.equal(result.text, '猫です'); + assert.equal(result.tokens?.[0]?.isKnown, true); +}); + +test('tokenizeSubtitle checks known words by surface when configured', async () => { + const result = await tokenizeSubtitle( + '猫です', + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫です' }], { + getKnownWordMatchMode: () => 'surface', + isKnownWord: (text) => text === '猫', + }), + ); + + assert.equal(result.text, '猫です'); + assert.equal(result.tokens?.[0]?.isKnown, true); +}); + +test('createTokenizerDepsRuntime checks MeCab availability before first tokenizeWithMecab call', async () => { + let available = false; + let checkCalls = 0; + + const deps = createTokenizerDepsRuntime({ + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: () => false, + getKnownWordMatchMode: () => 'headword', + getJlptLevel: () => null, + getMecabTokenizer: () => ({ + getStatus: () => ({ available }), + checkAvailability: async () => { + checkCalls += 1; + available = true; + return true; + }, + tokenize: async () => { + if (!available) { + return null; + } + return [ + { + word: '仮面', + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '一般', + pos3: '', + pos4: '', + inflectionType: '', + inflectionForm: '', + headword: '仮面', + katakanaReading: 'カメン', + pronunciation: 'カメン', + }, + ]; + }, + }), + }); + + const first = await deps.tokenizeWithMecab('仮面'); + const second = await deps.tokenizeWithMecab('仮面'); + + assert.equal(checkCalls, 1); + assert.equal(first?.[0]?.surface, '仮面'); + assert.equal(second?.[0]?.surface, '仮面'); +}); diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts new file mode 100644 index 0000000..0634cf0 --- /dev/null +++ b/src/core/services/tokenizer.ts @@ -0,0 +1,220 @@ +import type { BrowserWindow, Extension } from 'electron'; +import { mergeTokens } from '../../token-merger'; +import { createLogger } from '../../logger'; +import { + MergedToken, + NPlusOneMatchMode, + SubtitleData, + Token, + FrequencyDictionaryLookup, + JlptLevel, +} from '../../types'; +import { annotateTokens } from './tokenizer/annotation-stage'; +import { enrichTokensWithMecabPos1 } from './tokenizer/parser-enrichment-stage'; +import { selectYomitanParseTokens } from './tokenizer/parser-selection-stage'; +import { requestYomitanParseResults } from './tokenizer/yomitan-parser-runtime'; + +const logger = createLogger('main:tokenizer'); + +export interface TokenizerServiceDeps { + getYomitanExt: () => Extension | null; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; + isKnownWord: (text: string) => boolean; + getKnownWordMatchMode: () => NPlusOneMatchMode; + getJlptLevel: (text: string) => JlptLevel | null; + getJlptEnabled?: () => boolean; + getFrequencyDictionaryEnabled?: () => boolean; + getFrequencyRank?: FrequencyDictionaryLookup; + getMinSentenceWordsForNPlusOne?: () => number; + getYomitanGroupDebugEnabled?: () => boolean; + tokenizeWithMecab: (text: string) => Promise; +} + +interface MecabTokenizerLike { + tokenize: (text: string) => Promise; + checkAvailability?: () => Promise; + getStatus?: () => { available: boolean }; +} + +export interface TokenizerDepsRuntimeOptions { + getYomitanExt: () => Extension | null; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; + isKnownWord: (text: string) => boolean; + getKnownWordMatchMode: () => NPlusOneMatchMode; + getJlptLevel: (text: string) => JlptLevel | null; + getJlptEnabled?: () => boolean; + getFrequencyDictionaryEnabled?: () => boolean; + getFrequencyRank?: FrequencyDictionaryLookup; + getMinSentenceWordsForNPlusOne?: () => number; + getYomitanGroupDebugEnabled?: () => boolean; + getMecabTokenizer: () => MecabTokenizerLike | null; +} + +export function createTokenizerDepsRuntime( + options: TokenizerDepsRuntimeOptions, +): TokenizerServiceDeps { + const checkedMecabTokenizers = new WeakSet(); + + return { + getYomitanExt: options.getYomitanExt, + getYomitanParserWindow: options.getYomitanParserWindow, + setYomitanParserWindow: options.setYomitanParserWindow, + getYomitanParserReadyPromise: options.getYomitanParserReadyPromise, + setYomitanParserReadyPromise: options.setYomitanParserReadyPromise, + getYomitanParserInitPromise: options.getYomitanParserInitPromise, + setYomitanParserInitPromise: options.setYomitanParserInitPromise, + isKnownWord: options.isKnownWord, + getKnownWordMatchMode: options.getKnownWordMatchMode, + getJlptLevel: options.getJlptLevel, + getJlptEnabled: options.getJlptEnabled, + getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled, + getFrequencyRank: options.getFrequencyRank, + getMinSentenceWordsForNPlusOne: options.getMinSentenceWordsForNPlusOne ?? (() => 3), + getYomitanGroupDebugEnabled: options.getYomitanGroupDebugEnabled ?? (() => false), + tokenizeWithMecab: async (text) => { + const mecabTokenizer = options.getMecabTokenizer(); + if (!mecabTokenizer) { + return null; + } + + if ( + typeof mecabTokenizer.checkAvailability === 'function' && + typeof mecabTokenizer.getStatus === 'function' && + !checkedMecabTokenizers.has(mecabTokenizer as object) + ) { + const status = mecabTokenizer.getStatus(); + if (!status.available) { + await mecabTokenizer.checkAvailability(); + } + checkedMecabTokenizers.add(mecabTokenizer as object); + } + + const rawTokens = await mecabTokenizer.tokenize(text); + if (!rawTokens || rawTokens.length === 0) { + return null; + } + + return mergeTokens(rawTokens, options.isKnownWord, options.getKnownWordMatchMode()); + }, + }; +} + +function logSelectedYomitanGroups(text: string, tokens: MergedToken[]): void { + if (tokens.length === 0) { + return; + } + + logger.info('Selected Yomitan token groups', { + text, + tokenCount: tokens.length, + groups: tokens.map((token, index) => ({ + index, + surface: token.surface, + headword: token.headword, + reading: token.reading, + startPos: token.startPos, + endPos: token.endPos, + })), + }); +} + +function getAnnotationOptions(deps: TokenizerServiceDeps): { + jlptEnabled: boolean; + frequencyEnabled: boolean; + minSentenceWordsForNPlusOne: number | undefined; +} { + return { + jlptEnabled: deps.getJlptEnabled?.() !== false, + frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false, + minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(), + }; +} + +function applyAnnotationStage(tokens: MergedToken[], deps: TokenizerServiceDeps): MergedToken[] { + const options = getAnnotationOptions(deps); + + return annotateTokens( + tokens, + { + isKnownWord: deps.isKnownWord, + knownWordMatchMode: deps.getKnownWordMatchMode(), + getJlptLevel: deps.getJlptLevel, + getFrequencyRank: deps.getFrequencyRank, + }, + options, + ); +} + +async function parseWithYomitanInternalParser( + text: string, + deps: TokenizerServiceDeps, +): Promise { + const parseResults = await requestYomitanParseResults(text, deps, logger); + if (!parseResults) { + return null; + } + + const selectedTokens = selectYomitanParseTokens( + parseResults, + deps.isKnownWord, + deps.getKnownWordMatchMode(), + ); + if (!selectedTokens || selectedTokens.length === 0) { + return null; + } + + if (deps.getYomitanGroupDebugEnabled?.() === true) { + logSelectedYomitanGroups(text, selectedTokens); + } + + try { + const mecabTokens = await deps.tokenizeWithMecab(text); + return enrichTokensWithMecabPos1(selectedTokens, mecabTokens); + } catch (err) { + const error = err as Error; + logger.warn( + 'Failed to enrich Yomitan tokens with MeCab POS:', + error.message, + `tokenCount=${selectedTokens.length}`, + `textLength=${text.length}`, + ); + return selectedTokens; + } +} + +export async function tokenizeSubtitle( + text: string, + deps: TokenizerServiceDeps, +): Promise { + const displayText = text + .replace(/\r\n/g, '\n') + .replace(/\\N/g, '\n') + .replace(/\\n/g, '\n') + .trim(); + + if (!displayText) { + return { text, tokens: null }; + } + + const tokenizeText = displayText.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); + + const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps); + if (yomitanTokens && yomitanTokens.length > 0) { + return { + text: displayText, + tokens: applyAnnotationStage(yomitanTokens, deps), + }; + } + + return { text: displayText, tokens: null }; +} diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts new file mode 100644 index 0000000..98a5fd8 --- /dev/null +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MergedToken, PartOfSpeech } from '../../../types'; +import { annotateTokens, AnnotationStageDeps } from './annotation-stage'; + +function makeToken(overrides: Partial = {}): MergedToken { + return { + surface: '猫', + reading: 'ネコ', + headword: '猫', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}): AnnotationStageDeps { + return { + isKnownWord: () => false, + knownWordMatchMode: 'headword', + getJlptLevel: () => null, + ...overrides, + }; +} + +test('annotateTokens known-word match mode uses headword vs surface', () => { + const tokens = [makeToken({ surface: '食べた', headword: '食べる', reading: 'タベタ' })]; + const isKnownWord = (text: string): boolean => text === '食べる'; + + const headwordResult = annotateTokens( + tokens, + makeDeps({ + isKnownWord, + knownWordMatchMode: 'headword', + }), + ); + const surfaceResult = annotateTokens( + tokens, + makeDeps({ + isKnownWord, + knownWordMatchMode: 'surface', + }), + ); + + assert.equal(headwordResult[0]?.isKnown, true); + assert.equal(surfaceResult[0]?.isKnown, false); +}); + +test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 exclusions', () => { + const lookupCalls: string[] = []; + const tokens = [ + makeToken({ surface: 'は', headword: 'は', partOfSpeech: PartOfSpeech.particle }), + makeToken({ + surface: 'です', + headword: 'です', + partOfSpeech: PartOfSpeech.bound_auxiliary, + startPos: 1, + endPos: 3, + }), + makeToken({ + surface: 'の', + headword: 'の', + partOfSpeech: PartOfSpeech.other, + pos1: '助詞', + startPos: 3, + endPos: 4, + }), + makeToken({ + surface: '猫', + headword: '猫', + partOfSpeech: PartOfSpeech.noun, + startPos: 4, + endPos: 5, + }), + ]; + + const result = annotateTokens( + tokens, + makeDeps({ + getFrequencyRank: (text) => { + lookupCalls.push(text); + return text === '猫' ? 11 : 999; + }, + }), + ); + + assert.equal(result[0]?.frequencyRank, undefined); + assert.equal(result[1]?.frequencyRank, undefined); + assert.equal(result[2]?.frequencyRank, undefined); + assert.equal(result[3]?.frequencyRank, 11); + assert.deepEqual(lookupCalls, ['猫']); +}); + +test('annotateTokens handles JLPT disabled and eligibility exclusion paths', () => { + let disabledLookupCalls = 0; + const disabledResult = annotateTokens( + [makeToken({ surface: '猫', headword: '猫' })], + makeDeps({ + getJlptLevel: () => { + disabledLookupCalls += 1; + return 'N5'; + }, + }), + { jlptEnabled: false }, + ); + assert.equal(disabledResult[0]?.jlptLevel, undefined); + assert.equal(disabledLookupCalls, 0); + + let excludedLookupCalls = 0; + const excludedResult = annotateTokens( + [ + makeToken({ + surface: '!', + headword: '!', + reading: '', + pos1: '記号', + partOfSpeech: PartOfSpeech.symbol, + }), + ], + makeDeps({ + getJlptLevel: () => { + excludedLookupCalls += 1; + return 'N5'; + }, + }), + ); + assert.equal(excludedResult[0]?.jlptLevel, undefined); + assert.equal(excludedLookupCalls, 0); +}); + +test('annotateTokens N+1 handoff marks expected target when threshold is satisfied', () => { + const tokens = [ + makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }), + makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }), + makeToken({ + surface: '見る', + headword: '見る', + partOfSpeech: PartOfSpeech.verb, + startPos: 2, + endPos: 4, + }), + ]; + + const result = annotateTokens( + tokens, + makeDeps({ + isKnownWord: (text) => text === '私' || text === '見る', + }), + { minSentenceWordsForNPlusOne: 3 }, + ); + + assert.equal(result[0]?.isNPlusOneTarget, false); + assert.equal(result[1]?.isNPlusOneTarget, true); + assert.equal(result[2]?.isNPlusOneTarget, false); +}); diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts new file mode 100644 index 0000000..d40bf08 --- /dev/null +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -0,0 +1,375 @@ +import { markNPlusOneTargets } from '../../../token-merger'; +import { + FrequencyDictionaryLookup, + JlptLevel, + MergedToken, + NPlusOneMatchMode, + PartOfSpeech, +} from '../../../types'; +import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter'; + +const KATAKANA_TO_HIRAGANA_OFFSET = 0x60; +const KATAKANA_CODEPOINT_START = 0x30a1; +const KATAKANA_CODEPOINT_END = 0x30f6; +const JLPT_LEVEL_LOOKUP_CACHE_LIMIT = 2048; +const FREQUENCY_RANK_LOOKUP_CACHE_LIMIT = 2048; + +const jlptLevelLookupCaches = new WeakMap< + (text: string) => JlptLevel | null, + Map +>(); +const frequencyRankLookupCaches = new WeakMap< + FrequencyDictionaryLookup, + Map +>(); + +export interface AnnotationStageDeps { + isKnownWord: (text: string) => boolean; + knownWordMatchMode: NPlusOneMatchMode; + getJlptLevel: (text: string) => JlptLevel | null; + getFrequencyRank?: FrequencyDictionaryLookup; +} + +export interface AnnotationStageOptions { + jlptEnabled?: boolean; + frequencyEnabled?: boolean; + minSentenceWordsForNPlusOne?: number; +} + +function resolveKnownWordText( + surface: string, + headword: string, + matchMode: NPlusOneMatchMode, +): string { + return matchMode === 'surface' ? surface : headword; +} + +function applyKnownWordMarking( + tokens: MergedToken[], + isKnownWord: (text: string) => boolean, + knownWordMatchMode: NPlusOneMatchMode, +): MergedToken[] { + return tokens.map((token) => { + const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode); + + return { + ...token, + isKnown: token.isKnown || (matchText ? isKnownWord(matchText) : false), + }; + }); +} + +function normalizeFrequencyLookupText(rawText: string): string { + return rawText.trim().toLowerCase(); +} + +function getCachedFrequencyRank( + lookupText: string, + getFrequencyRank: FrequencyDictionaryLookup, +): number | null { + const normalizedText = normalizeFrequencyLookupText(lookupText); + if (!normalizedText) { + return null; + } + + let cache = frequencyRankLookupCaches.get(getFrequencyRank); + if (!cache) { + cache = new Map(); + frequencyRankLookupCaches.set(getFrequencyRank, cache); + } + + if (cache.has(normalizedText)) { + return cache.get(normalizedText) ?? null; + } + + let rank: number | null; + try { + rank = getFrequencyRank(normalizedText); + } catch { + rank = null; + } + if (rank !== null) { + if (!Number.isFinite(rank) || rank <= 0) { + rank = null; + } + } + + cache.set(normalizedText, rank); + while (cache.size > FREQUENCY_RANK_LOOKUP_CACHE_LIMIT) { + const firstKey = cache.keys().next().value; + if (firstKey !== undefined) { + cache.delete(firstKey); + } + } + + return rank; +} + +function resolveFrequencyLookupText(token: MergedToken): string { + if (token.headword && token.headword.length > 0) { + return token.headword; + } + if (token.reading && token.reading.length > 0) { + return token.reading; + } + return token.surface; +} + +function getFrequencyLookupTextCandidates(token: MergedToken): string[] { + const lookupText = resolveFrequencyLookupText(token).trim(); + return lookupText ? [lookupText] : []; +} + +function isFrequencyExcludedByPos(token: MergedToken): boolean { + if ( + token.partOfSpeech === PartOfSpeech.particle || + token.partOfSpeech === PartOfSpeech.bound_auxiliary + ) { + return true; + } + + return token.pos1 === '助詞' || token.pos1 === '助動詞'; +} + +function applyFrequencyMarking( + tokens: MergedToken[], + getFrequencyRank: FrequencyDictionaryLookup, +): MergedToken[] { + return tokens.map((token) => { + if (isFrequencyExcludedByPos(token)) { + return { ...token, frequencyRank: undefined }; + } + + const lookupTexts = getFrequencyLookupTextCandidates(token); + if (lookupTexts.length === 0) { + return { ...token, frequencyRank: undefined }; + } + + let bestRank: number | null = null; + for (const lookupText of lookupTexts) { + const rank = getCachedFrequencyRank(lookupText, getFrequencyRank); + if (rank === null) { + continue; + } + if (bestRank === null || rank < bestRank) { + bestRank = rank; + } + } + + return { + ...token, + frequencyRank: bestRank ?? undefined, + }; + }); +} + +function getCachedJlptLevel( + lookupText: string, + getJlptLevel: (text: string) => JlptLevel | null, +): JlptLevel | null { + const normalizedText = lookupText.trim(); + if (!normalizedText) { + return null; + } + + let cache = jlptLevelLookupCaches.get(getJlptLevel); + if (!cache) { + cache = new Map(); + jlptLevelLookupCaches.set(getJlptLevel, cache); + } + + if (cache.has(normalizedText)) { + return cache.get(normalizedText) ?? null; + } + + let level: JlptLevel | null; + try { + level = getJlptLevel(normalizedText); + } catch { + level = null; + } + + cache.set(normalizedText, level); + while (cache.size > JLPT_LEVEL_LOOKUP_CACHE_LIMIT) { + const firstKey = cache.keys().next().value; + if (firstKey !== undefined) { + cache.delete(firstKey); + } + } + + return level; +} + +function resolveJlptLookupText(token: MergedToken): string { + if (token.headword && token.headword.length > 0) { + return token.headword; + } + if (token.reading && token.reading.length > 0) { + return token.reading; + } + return token.surface; +} + +function normalizeJlptTextForExclusion(text: string): string { + const raw = text.trim(); + if (!raw) { + return ''; + } + + let normalized = ''; + for (const char of raw) { + const code = char.codePointAt(0); + if (code === undefined) { + continue; + } + + if (code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) { + normalized += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET); + continue; + } + + normalized += char; + } + + return normalized; +} + +function isKanaChar(char: string): boolean { + const code = char.codePointAt(0); + if (code === undefined) { + return false; + } + + return ( + (code >= 0x3041 && code <= 0x3096) || + (code >= 0x309b && code <= 0x309f) || + (code >= 0x30a0 && code <= 0x30fa) || + (code >= 0x30fd && code <= 0x30ff) + ); +} + +function isRepeatedKanaSfx(text: string): boolean { + const normalized = text.trim(); + if (!normalized) { + return false; + } + + const chars = [...normalized]; + if (!chars.every(isKanaChar)) { + return false; + } + + const counts = new Map(); + let hasAdjacentRepeat = false; + + for (let i = 0; i < chars.length; i += 1) { + const char = chars[i]!; + counts.set(char, (counts.get(char) ?? 0) + 1); + if (i > 0 && chars[i] === chars[i - 1]) { + hasAdjacentRepeat = true; + } + } + + const topCount = Math.max(...counts.values()); + if (chars.length <= 2) { + return hasAdjacentRepeat || topCount >= 2; + } + + if (hasAdjacentRepeat) { + return true; + } + + return topCount >= Math.ceil(chars.length / 2); +} + +function isJlptEligibleToken(token: MergedToken): boolean { + if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) { + return false; + } + + const candidates = [ + resolveJlptLookupText(token), + token.surface, + token.reading, + token.headword, + ].filter( + (candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0, + ); + + for (const candidate of candidates) { + const normalizedCandidate = normalizeJlptTextForExclusion(candidate); + if (!normalizedCandidate) { + continue; + } + + const trimmedCandidate = candidate.trim(); + if (shouldIgnoreJlptByTerm(trimmedCandidate) || shouldIgnoreJlptByTerm(normalizedCandidate)) { + return false; + } + + if (isRepeatedKanaSfx(candidate) || isRepeatedKanaSfx(normalizedCandidate)) { + return false; + } + } + + return true; +} + +function applyJlptMarking( + tokens: MergedToken[], + getJlptLevel: (text: string) => JlptLevel | null, +): MergedToken[] { + return tokens.map((token) => { + if (!isJlptEligibleToken(token)) { + return { ...token, jlptLevel: undefined }; + } + + const primaryLevel = getCachedJlptLevel(resolveJlptLookupText(token), getJlptLevel); + const fallbackLevel = + primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null; + + return { + ...token, + jlptLevel: primaryLevel ?? fallbackLevel ?? token.jlptLevel, + }; + }); +} + +export function annotateTokens( + tokens: MergedToken[], + deps: AnnotationStageDeps, + options: AnnotationStageOptions = {}, +): MergedToken[] { + const knownMarkedTokens = applyKnownWordMarking( + tokens, + deps.isKnownWord, + deps.knownWordMatchMode, + ); + + const frequencyEnabled = options.frequencyEnabled !== false; + const frequencyMarkedTokens = + frequencyEnabled && deps.getFrequencyRank + ? applyFrequencyMarking(knownMarkedTokens, deps.getFrequencyRank) + : knownMarkedTokens.map((token) => ({ + ...token, + frequencyRank: undefined, + })); + + const jlptEnabled = options.jlptEnabled !== false; + const jlptMarkedTokens = jlptEnabled + ? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel) + : frequencyMarkedTokens.map((token) => ({ + ...token, + jlptLevel: undefined, + })); + + const minSentenceWordsForNPlusOne = options.minSentenceWordsForNPlusOne; + const sanitizedMinSentenceWordsForNPlusOne = + minSentenceWordsForNPlusOne !== undefined && + Number.isInteger(minSentenceWordsForNPlusOne) && + minSentenceWordsForNPlusOne > 0 + ? minSentenceWordsForNPlusOne + : 3; + + return markNPlusOneTargets(jlptMarkedTokens, sanitizedMinSentenceWordsForNPlusOne); +} diff --git a/src/core/services/tokenizer/parser-enrichment-stage.test.ts b/src/core/services/tokenizer/parser-enrichment-stage.test.ts new file mode 100644 index 0000000..a00f82c --- /dev/null +++ b/src/core/services/tokenizer/parser-enrichment-stage.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MergedToken, PartOfSpeech } from '../../../types'; +import { enrichTokensWithMecabPos1 } from './parser-enrichment-stage'; + +function makeToken(overrides: Partial): MergedToken { + return { + surface: 'token', + reading: '', + headword: 'token', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.other, + isMerged: true, + isKnown: false, + isNPlusOneTarget: false, + pos1: '', + ...overrides, + }; +} + +test('enrichTokensWithMecabPos1 picks pos1 by best overlap when no surface match exists', () => { + const tokens = [makeToken({ surface: 'grouped', startPos: 2, endPos: 7 })]; + const mecabTokens = [ + makeToken({ surface: 'left', startPos: 0, endPos: 4, pos1: 'A' }), + makeToken({ surface: 'right', startPos: 2, endPos: 6, pos1: 'B' }), + ]; + + const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens); + assert.equal(enriched[0]?.pos1, 'B'); +}); + +test('enrichTokensWithMecabPos1 fills missing pos1 using surface-sequence fallback', () => { + const tokens = [makeToken({ surface: ' は ', startPos: 10, endPos: 13 })]; + const mecabTokens = [makeToken({ surface: 'は', startPos: 0, endPos: 1, pos1: '助詞' })]; + + const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens); + assert.equal(enriched[0]?.pos1, '助詞'); +}); + +test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are null or empty', () => { + const tokens = [makeToken({ surface: '猫', startPos: 0, endPos: 1 })]; + + const nullResult = enrichTokensWithMecabPos1(tokens, null); + assert.strictEqual(nullResult, tokens); + + const emptyResult = enrichTokensWithMecabPos1(tokens, []); + assert.strictEqual(emptyResult, tokens); +}); diff --git a/src/core/services/tokenizer/parser-enrichment-stage.ts b/src/core/services/tokenizer/parser-enrichment-stage.ts new file mode 100644 index 0000000..3c3aeb2 --- /dev/null +++ b/src/core/services/tokenizer/parser-enrichment-stage.ts @@ -0,0 +1,167 @@ +import { MergedToken } from '../../../types'; + +function pickClosestMecabPos1(token: MergedToken, mecabTokens: MergedToken[]): string | undefined { + if (mecabTokens.length === 0) { + return undefined; + } + + const tokenStart = token.startPos ?? 0; + const tokenEnd = token.endPos ?? tokenStart + token.surface.length; + let bestSurfaceMatchPos1: string | undefined; + let bestSurfaceMatchDistance = Number.MAX_SAFE_INTEGER; + let bestSurfaceMatchEndDistance = Number.MAX_SAFE_INTEGER; + + for (const mecabToken of mecabTokens) { + if (!mecabToken.pos1) { + continue; + } + + if (mecabToken.surface !== token.surface) { + continue; + } + + const mecabStart = mecabToken.startPos ?? 0; + const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length; + const startDistance = Math.abs(mecabStart - tokenStart); + const endDistance = Math.abs(mecabEnd - tokenEnd); + + if ( + startDistance < bestSurfaceMatchDistance || + (startDistance === bestSurfaceMatchDistance && endDistance < bestSurfaceMatchEndDistance) + ) { + bestSurfaceMatchDistance = startDistance; + bestSurfaceMatchEndDistance = endDistance; + bestSurfaceMatchPos1 = mecabToken.pos1; + } + } + + if (bestSurfaceMatchPos1) { + return bestSurfaceMatchPos1; + } + + let bestPos1: string | undefined; + let bestOverlap = 0; + let bestSpan = 0; + let bestStartDistance = Number.MAX_SAFE_INTEGER; + let bestStart = Number.MAX_SAFE_INTEGER; + + for (const mecabToken of mecabTokens) { + if (!mecabToken.pos1) { + continue; + } + + const mecabStart = mecabToken.startPos ?? 0; + const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length; + const overlapStart = Math.max(tokenStart, mecabStart); + const overlapEnd = Math.min(tokenEnd, mecabEnd); + const overlap = Math.max(0, overlapEnd - overlapStart); + if (overlap === 0) { + continue; + } + + const span = mecabEnd - mecabStart; + if ( + overlap > bestOverlap || + (overlap === bestOverlap && + (Math.abs(mecabStart - tokenStart) < bestStartDistance || + (Math.abs(mecabStart - tokenStart) === bestStartDistance && + (span > bestSpan || (span === bestSpan && mecabStart < bestStart))))) + ) { + bestOverlap = overlap; + bestSpan = span; + bestStartDistance = Math.abs(mecabStart - tokenStart); + bestStart = mecabStart; + bestPos1 = mecabToken.pos1; + } + } + + return bestOverlap > 0 ? bestPos1 : undefined; +} + +function fillMissingPos1BySurfaceSequence( + tokens: MergedToken[], + mecabTokens: MergedToken[], +): MergedToken[] { + const indexedMecabTokens = mecabTokens + .map((token, index) => ({ token, index })) + .filter(({ token }) => token.pos1 && token.surface.trim().length > 0); + + if (indexedMecabTokens.length === 0) { + return tokens; + } + + let cursor = 0; + return tokens.map((token) => { + if (token.pos1 && token.pos1.trim().length > 0) { + return token; + } + + const surface = token.surface.trim(); + if (!surface) { + return token; + } + + let best: { pos1: string; index: number } | null = null; + for (const candidate of indexedMecabTokens) { + if (candidate.token.surface !== surface) { + continue; + } + if (candidate.index < cursor) { + continue; + } + best = { pos1: candidate.token.pos1 as string, index: candidate.index }; + break; + } + + if (!best) { + for (const candidate of indexedMecabTokens) { + if (candidate.token.surface !== surface) { + continue; + } + best = { pos1: candidate.token.pos1 as string, index: candidate.index }; + break; + } + } + + if (!best) { + return token; + } + + cursor = best.index + 1; + return { + ...token, + pos1: best.pos1, + }; + }); +} + +export function enrichTokensWithMecabPos1( + tokens: MergedToken[], + mecabTokens: MergedToken[] | null, +): MergedToken[] { + if (!tokens || tokens.length === 0) { + return tokens; + } + + if (!mecabTokens || mecabTokens.length === 0) { + return tokens; + } + + const overlapEnriched = tokens.map((token) => { + if (token.pos1) { + return token; + } + + const pos1 = pickClosestMecabPos1(token, mecabTokens); + if (!pos1) { + return token; + } + + return { + ...token, + pos1, + }; + }); + + return fillMissingPos1BySurfaceSequence(overlapEnriched, mecabTokens); +} diff --git a/src/core/services/tokenizer/parser-selection-stage.test.ts b/src/core/services/tokenizer/parser-selection-stage.test.ts new file mode 100644 index 0000000..9856d10 --- /dev/null +++ b/src/core/services/tokenizer/parser-selection-stage.test.ts @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { selectYomitanParseTokens } from './parser-selection-stage'; + +interface ParseSegmentInput { + text: string; + reading?: string; + headword?: string; +} + +function makeParseItem( + source: string, + lines: ParseSegmentInput[][], +): { + source: string; + index: number; + content: Array< + Array<{ text: string; reading?: string; headwords?: Array> }> + >; +} { + return { + source, + index: 0, + content: lines.map((line) => + line.map((segment) => ({ + text: segment.text, + reading: segment.reading, + headwords: segment.headword ? [[{ term: segment.headword }]] : undefined, + })), + ), + }; +} + +test('prefers scanning parser when scanning candidate has more than one token', () => { + const parseResults = [ + makeParseItem('scanning-parser', [ + [{ text: '小園', reading: 'おうえん', headword: '小園' }], + [{ text: 'に', reading: 'に', headword: 'に' }], + ]), + makeParseItem('mecab', [ + [{ text: '小', reading: 'お', headword: '小' }], + [{ text: '園', reading: 'えん', headword: '園' }], + [{ text: 'に', reading: 'に', headword: 'に' }], + ]), + ]; + + const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); + assert.equal(tokens?.map((token) => token.surface).join(','), '小園,に'); +}); + +test('keeps scanning parser candidate when scanning candidate is single token', () => { + const parseResults = [ + makeParseItem('scanning-parser', [ + [{ text: '俺は公園にいきたい', reading: 'おれはこうえんにいきたい' }], + ]), + makeParseItem('mecab', [ + [{ text: '俺', reading: 'おれ', headword: '俺' }], + [{ text: 'は', reading: 'は', headword: 'は' }], + [{ text: '公園', reading: 'こうえん', headword: '公園' }], + [{ text: 'に', reading: 'に', headword: 'に' }], + [{ text: 'いきたい', reading: 'いきたい', headword: '行きたい' }], + ]), + ]; + + const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); + assert.equal(tokens?.map((token) => token.surface).join(','), '俺は公園にいきたい'); +}); + +test('tie-break prefers fewer suspicious kana fragments', () => { + const parseResults = [ + makeParseItem('scanning-parser', [ + [{ text: '俺', reading: 'おれ', headword: '俺' }], + [{ text: 'にい', reading: '', headword: '兄' }], + [{ text: 'きたい', reading: '', headword: '期待' }], + ]), + makeParseItem('scanning-parser', [ + [{ text: '俺', reading: 'おれ', headword: '俺' }], + [{ text: 'に', reading: 'に', headword: 'に' }], + [{ text: '行きたい', reading: 'いきたい', headword: '行きたい' }], + ]), + ]; + + const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); + assert.equal(tokens?.map((token) => token.surface).join(','), '俺,に,行きたい'); +}); + +test('returns null when only mecab-source candidates are present', () => { + const parseResults = [ + makeParseItem('mecab', [ + [{ text: '俺', reading: 'おれ', headword: '俺' }], + [{ text: 'は', reading: 'は', headword: 'は' }], + [{ text: '公園', reading: 'こうえん', headword: '公園' }], + ]), + ]; + + const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); + assert.equal(tokens, null); +}); diff --git a/src/core/services/tokenizer/parser-selection-stage.ts b/src/core/services/tokenizer/parser-selection-stage.ts new file mode 100644 index 0000000..43fe9ac --- /dev/null +++ b/src/core/services/tokenizer/parser-selection-stage.ts @@ -0,0 +1,264 @@ +import { MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types'; + +interface YomitanParseHeadword { + term?: unknown; +} + +interface YomitanParseSegment { + text?: string; + reading?: string; + headwords?: unknown; +} + +interface YomitanParseResultItem { + source?: unknown; + index?: unknown; + content?: unknown; +} + +type YomitanParseLine = YomitanParseSegment[]; + +export interface YomitanParseCandidate { + source: string; + index: number; + tokens: MergedToken[]; +} + +function isObject(value: unknown): value is Record { + return Boolean(value && typeof value === 'object'); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +function resolveKnownWordText( + surface: string, + headword: string, + matchMode: NPlusOneMatchMode, +): string { + return matchMode === 'surface' ? surface : headword; +} + +function isKanaChar(char: string): boolean { + const code = char.codePointAt(0); + if (code === undefined) { + return false; + } + + return ( + (code >= 0x3041 && code <= 0x3096) || + (code >= 0x309b && code <= 0x309f) || + (code >= 0x30a0 && code <= 0x30fa) || + (code >= 0x30fd && code <= 0x30ff) + ); +} + +function isYomitanParseLine(value: unknown): value is YomitanParseLine { + if (!Array.isArray(value)) { + return false; + } + + return value.every((segment) => { + if (!isObject(segment)) { + return false; + } + + const candidate = segment as YomitanParseSegment; + return isString(candidate.text); + }); +} + +export function isYomitanParseResultItem(value: unknown): value is YomitanParseResultItem { + if (!isObject(value)) { + return false; + } + if (!isString((value as YomitanParseResultItem).source)) { + return false; + } + if (!Array.isArray((value as YomitanParseResultItem).content)) { + return false; + } + return true; +} + +function isYomitanHeadwordRows(value: unknown): value is YomitanParseHeadword[][] { + return ( + Array.isArray(value) && + value.every( + (group) => + Array.isArray(group) && + group.every((item) => isObject(item) && isString((item as YomitanParseHeadword).term)), + ) + ); +} + +function extractYomitanHeadword(segment: YomitanParseSegment): string { + const headwords = segment.headwords; + if (!isYomitanHeadwordRows(headwords)) { + return ''; + } + + for (const group of headwords) { + if (group.length > 0) { + const firstHeadword = group[0] as YomitanParseHeadword; + if (isString(firstHeadword?.term)) { + return firstHeadword.term; + } + } + } + + return ''; +} + +export function mapYomitanParseResultItemToMergedTokens( + parseResult: YomitanParseResultItem, + isKnownWord: (text: string) => boolean, + knownWordMatchMode: NPlusOneMatchMode, +): YomitanParseCandidate | null { + const content = parseResult.content; + if (!Array.isArray(content) || content.length === 0) { + return null; + } + + const source = String(parseResult.source ?? ''); + const index = + typeof parseResult.index === 'number' && Number.isInteger(parseResult.index) + ? parseResult.index + : 0; + + const tokens: MergedToken[] = []; + let charOffset = 0; + let validLineCount = 0; + + for (const line of content) { + if (!isYomitanParseLine(line)) { + continue; + } + validLineCount += 1; + + let combinedSurface = ''; + let combinedReading = ''; + let combinedHeadword = ''; + + for (const segment of line) { + const segmentText = segment.text; + if (!segmentText || segmentText.length === 0) { + continue; + } + + combinedSurface += segmentText; + if (typeof segment.reading === 'string') { + combinedReading += segment.reading; + } + if (!combinedHeadword) { + combinedHeadword = extractYomitanHeadword(segment); + } + } + + if (!combinedSurface) { + continue; + } + + const start = charOffset; + const end = start + combinedSurface.length; + charOffset = end; + const headword = combinedHeadword || combinedSurface; + + tokens.push({ + surface: combinedSurface, + reading: combinedReading, + headword, + startPos: start, + endPos: end, + partOfSpeech: PartOfSpeech.other, + pos1: '', + isMerged: true, + isNPlusOneTarget: false, + isKnown: (() => { + const matchText = resolveKnownWordText(combinedSurface, headword, knownWordMatchMode); + return matchText ? isKnownWord(matchText) : false; + })(), + }); + } + + if (validLineCount === 0 || tokens.length === 0) { + return null; + } + + return { source, index, tokens }; +} + +export function selectBestYomitanParseCandidate( + candidates: YomitanParseCandidate[], +): MergedToken[] | null { + if (candidates.length === 0) { + return null; + } + + const scanningCandidates = candidates.filter( + (candidate) => candidate.source === 'scanning-parser', + ); + if (scanningCandidates.length === 0) { + return null; + } + + const getCandidateScore = (candidate: YomitanParseCandidate): number => { + const readableTokenCount = candidate.tokens.filter( + (token) => token.reading.trim().length > 0, + ).length; + const suspiciousKanaFragmentCount = candidate.tokens.filter( + (token) => + token.reading.trim().length === 0 && + token.surface.length >= 2 && + Array.from(token.surface).every((char) => isKanaChar(char)), + ).length; + + return readableTokenCount * 100 - suspiciousKanaFragmentCount * 50 - candidate.tokens.length; + }; + + const chooseBestCandidate = (items: YomitanParseCandidate[]): YomitanParseCandidate | null => { + if (items.length === 0) { + return null; + } + + return items.reduce((best, current) => { + const bestScore = getCandidateScore(best); + const currentScore = getCandidateScore(current); + if (currentScore !== bestScore) { + return currentScore > bestScore ? current : best; + } + + if (current.tokens.length !== best.tokens.length) { + return current.tokens.length < best.tokens.length ? current : best; + } + + return best; + }); + }; + + const multiTokenCandidates = scanningCandidates.filter( + (candidate) => candidate.tokens.length > 1, + ); + const pool = multiTokenCandidates.length > 0 ? multiTokenCandidates : scanningCandidates; + const bestCandidate = chooseBestCandidate(pool); + return bestCandidate ? bestCandidate.tokens : null; +} + +export function selectYomitanParseTokens( + parseResults: unknown, + isKnownWord: (text: string) => boolean, + knownWordMatchMode: NPlusOneMatchMode, +): MergedToken[] | null { + if (!Array.isArray(parseResults) || parseResults.length === 0) { + return null; + } + + const candidates = parseResults + .filter((item): item is YomitanParseResultItem => isYomitanParseResultItem(item)) + .map((item) => mapYomitanParseResultItemToMergedTokens(item, isKnownWord, knownWordMatchMode)) + .filter((candidate): candidate is YomitanParseCandidate => candidate !== null); + + const bestCandidate = selectBestYomitanParseCandidate(candidates); + return bestCandidate; +} diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts new file mode 100644 index 0000000..5955cfa --- /dev/null +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -0,0 +1,154 @@ +import type { BrowserWindow, Extension } from 'electron'; + +interface LoggerLike { + error: (message: string, ...args: unknown[]) => void; +} + +interface YomitanParserRuntimeDeps { + getYomitanExt: () => Extension | null; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; +} + +async function ensureYomitanParserWindow( + deps: YomitanParserRuntimeDeps, + logger: LoggerLike, +): Promise { + const electron = await import('electron'); + const yomitanExt = deps.getYomitanExt(); + if (!yomitanExt) { + return false; + } + + const currentWindow = deps.getYomitanParserWindow(); + if (currentWindow && !currentWindow.isDestroyed()) { + return true; + } + + const existingInitPromise = deps.getYomitanParserInitPromise(); + if (existingInitPromise) { + return existingInitPromise; + } + + const initPromise = (async () => { + const { BrowserWindow, session } = electron; + const parserWindow = new BrowserWindow({ + show: false, + width: 800, + height: 600, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + session: session.defaultSession, + }, + }); + deps.setYomitanParserWindow(parserWindow); + + deps.setYomitanParserReadyPromise( + new Promise((resolve, reject) => { + parserWindow.webContents.once('did-finish-load', () => resolve()); + parserWindow.webContents.once('did-fail-load', (_event, _errorCode, errorDescription) => { + reject(new Error(errorDescription)); + }); + }), + ); + + parserWindow.on('closed', () => { + if (deps.getYomitanParserWindow() === parserWindow) { + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + } + }); + + try { + await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`); + const readyPromise = deps.getYomitanParserReadyPromise(); + if (readyPromise) { + await readyPromise; + } + + return true; + } catch (err) { + logger.error('Failed to initialize Yomitan parser window:', (err as Error).message); + if (!parserWindow.isDestroyed()) { + parserWindow.destroy(); + } + if (deps.getYomitanParserWindow() === parserWindow) { + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + } + + return false; + } finally { + deps.setYomitanParserInitPromise(null); + } + })(); + + deps.setYomitanParserInitPromise(initPromise); + return initPromise; +} + +export async function requestYomitanParseResults( + text: string, + deps: YomitanParserRuntimeDeps, + logger: LoggerLike, +): Promise { + const yomitanExt = deps.getYomitanExt(); + if (!text || !yomitanExt) { + return null; + } + + const isReady = await ensureYomitanParserWindow(deps, logger); + const parserWindow = deps.getYomitanParserWindow(); + if (!isReady || !parserWindow || parserWindow.isDestroyed()) { + return null; + } + + const script = ` + (async () => { + const invoke = (action, params) => + new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action, params }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response || typeof response !== "object") { + reject(new Error("Invalid response from Yomitan backend")); + return; + } + if (response.error) { + reject(new Error(response.error.message || "Yomitan backend error")); + return; + } + resolve(response.result); + }); + }); + + const optionsFull = await invoke("optionsGetFull", undefined); + const profileIndex = optionsFull.profileCurrent; + const scanLength = + optionsFull.profiles?.[profileIndex]?.options?.scanning?.length ?? 40; + + return await invoke("parseText", { + text: ${JSON.stringify(text)}, + optionsContext: { index: profileIndex }, + scanLength, + useInternalParser: true, + useMecabParser: true + }); + })(); + `; + + try { + const parseResults = await parserWindow.webContents.executeJavaScript(script, true); + return Array.isArray(parseResults) ? parseResults : null; + } catch (err) { + logger.error('Yomitan parser request failed:', (err as Error).message); + return null; + } +} diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts new file mode 100644 index 0000000..59b6fd5 --- /dev/null +++ b/src/core/services/yomitan-extension-loader.ts @@ -0,0 +1,109 @@ +import { BrowserWindow, Extension, session } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:yomitan-extension-loader'); + +export interface YomitanExtensionLoaderDeps { + userDataPath: string; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + setYomitanParserInitPromise: (promise: Promise | null) => void; + setYomitanExtension: (extension: Extension | null) => void; +} + +function ensureExtensionCopy(sourceDir: string, userDataPath: string): string { + if (process.platform === 'win32') { + return sourceDir; + } + + const extensionsRoot = path.join(userDataPath, 'extensions'); + const targetDir = path.join(extensionsRoot, 'yomitan'); + + const sourceManifest = path.join(sourceDir, 'manifest.json'); + const targetManifest = path.join(targetDir, 'manifest.json'); + + let shouldCopy = !fs.existsSync(targetDir); + if (!shouldCopy && fs.existsSync(sourceManifest) && fs.existsSync(targetManifest)) { + try { + const sourceVersion = ( + JSON.parse(fs.readFileSync(sourceManifest, 'utf-8')) as { + version: string; + } + ).version; + const targetVersion = ( + JSON.parse(fs.readFileSync(targetManifest, 'utf-8')) as { + version: string; + } + ).version; + shouldCopy = sourceVersion !== targetVersion; + } catch { + shouldCopy = true; + } + } + + if (shouldCopy) { + fs.mkdirSync(extensionsRoot, { recursive: true }); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); + logger.info(`Copied yomitan extension to ${targetDir}`); + } + + return targetDir; +} + +export async function loadYomitanExtension( + deps: YomitanExtensionLoaderDeps, +): Promise { + const searchPaths = [ + path.join(__dirname, '..', '..', 'vendor', 'yomitan'), + path.join(__dirname, '..', '..', '..', 'vendor', 'yomitan'), + path.join(process.resourcesPath, 'yomitan'), + '/usr/share/SubMiner/yomitan', + path.join(deps.userDataPath, 'yomitan'), + ]; + + let extPath: string | null = null; + for (const p of searchPaths) { + if (fs.existsSync(p)) { + extPath = p; + break; + } + } + + if (!extPath) { + logger.error('Yomitan extension not found in any search path'); + logger.error('Install Yomitan to one of:', searchPaths); + return null; + } + + extPath = ensureExtensionCopy(extPath, deps.userDataPath); + + const parserWindow = deps.getYomitanParserWindow(); + if (parserWindow && !parserWindow.isDestroyed()) { + parserWindow.destroy(); + } + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + deps.setYomitanParserInitPromise(null); + + try { + const extensions = session.defaultSession.extensions; + const extension = extensions + ? await extensions.loadExtension(extPath, { + allowFileAccess: true, + }) + : await session.defaultSession.loadExtension(extPath, { + allowFileAccess: true, + }); + deps.setYomitanExtension(extension); + return extension; + } catch (err) { + logger.error('Failed to load Yomitan extension:', (err as Error).message); + logger.error('Full error:', err); + deps.setYomitanExtension(null); + return null; + } +} diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts new file mode 100644 index 0000000..ae86c1b --- /dev/null +++ b/src/core/services/yomitan-settings.ts @@ -0,0 +1,86 @@ +import { BrowserWindow, Extension, session } from 'electron'; +import { createLogger } from '../../logger'; + +const logger = createLogger('main:yomitan-settings'); + +export interface OpenYomitanSettingsWindowOptions { + yomitanExt: Extension | null; + getExistingWindow: () => BrowserWindow | null; + setWindow: (window: BrowserWindow | null) => void; +} + +export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void { + logger.info('openYomitanSettings called'); + + if (!options.yomitanExt) { + logger.error('Yomitan extension not loaded - yomitanExt is:', options.yomitanExt); + logger.error('This may be due to Manifest V3 service worker issues with Electron'); + return; + } + + const existingWindow = options.getExistingWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + logger.info('Settings window already exists, focusing'); + existingWindow.focus(); + return; + } + + logger.info('Creating new settings window for extension:', options.yomitanExt.id); + + const settingsWindow = new BrowserWindow({ + width: 1200, + height: 800, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + session: session.defaultSession, + }, + }); + options.setWindow(settingsWindow); + + const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`; + logger.info('Loading settings URL:', settingsUrl); + + let loadAttempts = 0; + const maxAttempts = 3; + + const attemptLoad = (): void => { + settingsWindow + .loadURL(settingsUrl) + .then(() => { + logger.info('Settings URL loaded successfully'); + }) + .catch((err: Error) => { + logger.error('Failed to load settings URL:', err); + loadAttempts++; + if (loadAttempts < maxAttempts && !settingsWindow.isDestroyed()) { + logger.info(`Retrying in 500ms (attempt ${loadAttempts + 1}/${maxAttempts})`); + setTimeout(attemptLoad, 500); + } + }); + }; + + attemptLoad(); + + settingsWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => { + logger.error('Settings page failed to load:', errorCode, errorDescription); + }); + + settingsWindow.webContents.on('did-finish-load', () => { + logger.info('Settings page loaded successfully'); + }); + + setTimeout(() => { + if (!settingsWindow.isDestroyed()) { + const [width = 0, height = 0] = settingsWindow.getSize(); + settingsWindow.setSize(width, height); + settingsWindow.webContents.invalidate(); + settingsWindow.show(); + } + }, 500); + + settingsWindow.on('closed', () => { + options.setWindow(null); + }); +} diff --git a/src/core/utils/coerce.ts b/src/core/utils/coerce.ts new file mode 100644 index 0000000..383a516 --- /dev/null +++ b/src/core/utils/coerce.ts @@ -0,0 +1,31 @@ +export function asFiniteNumber( + value: unknown, + fallback: number, + min?: number, + max?: number, +): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + if (min !== undefined && value < min) return min; + if (max !== undefined && value > max) return max; + return value; +} + +export function asString(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : fallback; +} + +export function asBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'yes' || normalized === 'true' || normalized === '1') { + return true; + } + if (normalized === 'no' || normalized === 'false' || normalized === '0') { + return false; + } + } + return fallback; +} diff --git a/src/core/utils/config-gen.ts b/src/core/utils/config-gen.ts new file mode 100644 index 0000000..7912b79 --- /dev/null +++ b/src/core/utils/config-gen.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import { CliArgs } from '../../cli/args'; +import { createLogger } from '../../logger'; + +const logger = createLogger('core:config-gen'); + +function formatBackupTimestamp(date = new Date()): string { + const pad = (v: number): string => String(v).padStart(2, '0'); + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +} + +function promptYesNo(question: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(question, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + resolve(normalized === 'y' || normalized === 'yes'); + }); + }); +} + +export async function generateDefaultConfigFile( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, +): Promise { + const targetPath = args.configPath + ? path.resolve(args.configPath) + : path.join(options.configDir, 'config.jsonc'); + const template = options.generateTemplate(options.defaultConfig); + + if (fs.existsSync(targetPath)) { + if (args.backupOverwrite) { + const backupPath = `${targetPath}.bak.${formatBackupTimestamp()}`; + fs.copyFileSync(targetPath, backupPath); + fs.writeFileSync(targetPath, template, 'utf-8'); + logger.info(`Backed up existing config to ${backupPath}`); + logger.info(`Generated config at ${targetPath}`); + return 0; + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + logger.error( + `Config exists at ${targetPath}. Re-run with --backup-overwrite to back up and overwrite.`, + ); + return 1; + } + + const confirmed = await promptYesNo( + `Config exists at ${targetPath}. Back up and overwrite? [y/N] `, + ); + if (!confirmed) { + logger.info('Config generation cancelled.'); + return 0; + } + + const backupPath = `${targetPath}.bak.${formatBackupTimestamp()}`; + fs.copyFileSync(targetPath, backupPath); + fs.writeFileSync(targetPath, template, 'utf-8'); + logger.info(`Backed up existing config to ${backupPath}`); + logger.info(`Generated config at ${targetPath}`); + return 0; + } + + const parentDir = path.dirname(targetPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + fs.writeFileSync(targetPath, template, 'utf-8'); + logger.info(`Generated config at ${targetPath}`); + return 0; +} diff --git a/src/core/utils/electron-backend.ts b/src/core/utils/electron-backend.ts new file mode 100644 index 0000000..0e0a17e --- /dev/null +++ b/src/core/utils/electron-backend.ts @@ -0,0 +1,40 @@ +import { CliArgs, shouldStartApp } from '../../cli/args'; +import { createLogger } from '../../logger'; + +const logger = createLogger('core:electron-backend'); + +function getElectronOzonePlatformHint(): string | null { + const hint = process.env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase(); + if (hint) return hint; + const ozone = process.env.OZONE_PLATFORM?.trim().toLowerCase(); + if (ozone) return ozone; + return null; +} + +function shouldPreferWaylandBackend(): boolean { + return Boolean(process.env.HYPRLAND_INSTANCE_SIGNATURE || process.env.SWAYSOCK); +} + +export function forceX11Backend(args: CliArgs): void { + if (process.platform !== 'linux') return; + if (!shouldStartApp(args)) return; + if (shouldPreferWaylandBackend()) return; + + const hint = getElectronOzonePlatformHint(); + if (hint === 'x11') return; + + process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11'; + process.env.OZONE_PLATFORM = 'x11'; +} + +export function enforceUnsupportedWaylandMode(args: CliArgs): void { + if (process.platform !== 'linux') return; + if (!shouldStartApp(args)) return; + const hint = getElectronOzonePlatformHint(); + if (hint !== 'wayland') return; + + const message = + 'Unsupported Electron backend: Wayland. Set ELECTRON_OZONE_PLATFORM_HINT=x11 and restart SubMiner.'; + logger.error(message); + throw new Error(message); +} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts new file mode 100644 index 0000000..32202b8 --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,5 @@ +export { generateDefaultConfigFile } from './config-gen'; +export { enforceUnsupportedWaylandMode, forceX11Backend } from './electron-backend'; +export { resolveKeybindings } from './keybindings'; +export { resolveConfiguredShortcuts } from './shortcut-config'; +export { showDesktopNotification } from './notification'; diff --git a/src/core/utils/keybindings.ts b/src/core/utils/keybindings.ts new file mode 100644 index 0000000..208844f --- /dev/null +++ b/src/core/utils/keybindings.ts @@ -0,0 +1,27 @@ +import { Config, Keybinding } from '../../types'; + +export function resolveKeybindings(config: Config, defaultKeybindings: Keybinding[]): Keybinding[] { + const userBindings = config.keybindings || []; + const bindingMap = new Map(); + + for (const binding of defaultKeybindings) { + bindingMap.set(binding.key, binding.command); + } + + for (const binding of userBindings) { + if (binding.command === null) { + bindingMap.delete(binding.key); + } else { + bindingMap.set(binding.key, binding.command); + } + } + + const keybindings: Keybinding[] = []; + for (const [key, command] of bindingMap) { + if (command !== null) { + keybindings.push({ key, command }); + } + } + + return keybindings; +} diff --git a/src/core/utils/notification.ts b/src/core/utils/notification.ts new file mode 100644 index 0000000..0b15091 --- /dev/null +++ b/src/core/utils/notification.ts @@ -0,0 +1,53 @@ +import { Notification, nativeImage } from 'electron'; +import * as fs from 'fs'; +import { createLogger } from '../../logger'; + +const logger = createLogger('core:notification'); + +export function showDesktopNotification( + title: string, + options: { body?: string; icon?: string }, +): void { + const notificationOptions: { + title: string; + body?: string; + icon?: Electron.NativeImage | string; + } = { title }; + + if (options.body) { + notificationOptions.body = options.body; + } + + if (options.icon) { + const isFilePath = + typeof options.icon === 'string' && + (options.icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(options.icon)); + + if (isFilePath) { + if (fs.existsSync(options.icon)) { + notificationOptions.icon = options.icon; + } else { + logger.warn('Notification icon file not found', options.icon); + } + } else if (typeof options.icon === 'string' && options.icon.startsWith('data:image/')) { + const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, ''); + try { + const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64')); + if (image.isEmpty()) { + logger.warn( + 'Notification icon created from base64 is empty - image format may not be supported by Electron', + ); + } else { + notificationOptions.icon = image; + } + } catch (err) { + logger.error('Failed to create notification icon from base64', err); + } + } else { + notificationOptions.icon = options.icon; + } + } + + const notification = new Notification(notificationOptions); + notification.show(); +} diff --git a/src/core/utils/shortcut-config.test.ts b/src/core/utils/shortcut-config.test.ts new file mode 100644 index 0000000..9ac47a7 --- /dev/null +++ b/src/core/utils/shortcut-config.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Config } from '../../types'; +import { resolveConfiguredShortcuts } from './shortcut-config'; + +test('forces Anki-dependent shortcuts to null when AnkiConnect is explicitly disabled', () => { + const config: Config = { + ankiConnect: { enabled: false }, + shortcuts: { + copySubtitle: 'Ctrl+KeyC', + updateLastCardFromClipboard: 'Ctrl+KeyU', + triggerFieldGrouping: 'Alt+KeyG', + mineSentence: 'Ctrl+Digit1', + mineSentenceMultiple: 'Ctrl+Digit2', + markAudioCard: 'Alt+KeyM', + }, + }; + const defaults: Config = { + shortcuts: { + updateLastCardFromClipboard: 'Alt+KeyL', + triggerFieldGrouping: 'Alt+KeyF', + mineSentence: 'KeyQ', + mineSentenceMultiple: 'KeyW', + markAudioCard: 'KeyE', + }, + }; + + const resolved = resolveConfiguredShortcuts(config, defaults); + + assert.equal(resolved.updateLastCardFromClipboard, null); + assert.equal(resolved.triggerFieldGrouping, null); + assert.equal(resolved.mineSentence, null); + assert.equal(resolved.mineSentenceMultiple, null); + assert.equal(resolved.markAudioCard, null); + assert.equal(resolved.copySubtitle, 'Ctrl+C'); +}); + +test('keeps Anki-dependent shortcuts enabled and normalized when AnkiConnect is enabled', () => { + const config: Config = { + ankiConnect: { enabled: true }, + shortcuts: { + updateLastCardFromClipboard: 'Ctrl+KeyU', + mineSentence: 'Ctrl+Digit1', + mineSentenceMultiple: 'Ctrl+Digit2', + }, + }; + const defaults: Config = { + shortcuts: { + triggerFieldGrouping: 'Alt+KeyG', + markAudioCard: 'Alt+KeyM', + }, + }; + + const resolved = resolveConfiguredShortcuts(config, defaults); + + assert.equal(resolved.updateLastCardFromClipboard, 'Ctrl+U'); + assert.equal(resolved.triggerFieldGrouping, 'Alt+G'); + assert.equal(resolved.mineSentence, 'Ctrl+1'); + assert.equal(resolved.mineSentenceMultiple, 'Ctrl+2'); + assert.equal(resolved.markAudioCard, 'Alt+M'); +}); + +test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => { + const config: Config = {}; + const defaults: Config = { + shortcuts: { + mineSentence: 'KeyQ', + openRuntimeOptions: 'Digit9', + }, + }; + + const resolved = resolveConfiguredShortcuts(config, defaults); + + assert.equal(resolved.mineSentence, 'Q'); + assert.equal(resolved.openRuntimeOptions, '9'); +}); diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts new file mode 100644 index 0000000..f7e7c5b --- /dev/null +++ b/src/core/utils/shortcut-config.ts @@ -0,0 +1,87 @@ +import { Config } from '../../types'; + +export interface ConfiguredShortcuts { + toggleVisibleOverlayGlobal: string | null | undefined; + toggleInvisibleOverlayGlobal: string | null | undefined; + copySubtitle: string | null | undefined; + copySubtitleMultiple: string | null | undefined; + updateLastCardFromClipboard: string | null | undefined; + triggerFieldGrouping: string | null | undefined; + triggerSubsync: string | null | undefined; + mineSentence: string | null | undefined; + mineSentenceMultiple: string | null | undefined; + multiCopyTimeoutMs: number; + toggleSecondarySub: string | null | undefined; + markAudioCard: string | null | undefined; + openRuntimeOptions: string | null | undefined; + openJimaku: string | null | undefined; +} + +export function resolveConfiguredShortcuts( + config: Config, + defaultConfig: Config, +): ConfiguredShortcuts { + const isAnkiConnectDisabled = config.ankiConnect?.enabled === false; + + const normalizeShortcut = (value: string | null | undefined): string | null | undefined => { + if (typeof value !== 'string') return value; + return value.replace(/\bKey([A-Z])\b/g, '$1').replace(/\bDigit([0-9])\b/g, '$1'); + }; + + return { + toggleVisibleOverlayGlobal: normalizeShortcut( + config.shortcuts?.toggleVisibleOverlayGlobal ?? + defaultConfig.shortcuts?.toggleVisibleOverlayGlobal, + ), + toggleInvisibleOverlayGlobal: normalizeShortcut( + config.shortcuts?.toggleInvisibleOverlayGlobal ?? + defaultConfig.shortcuts?.toggleInvisibleOverlayGlobal, + ), + copySubtitle: normalizeShortcut( + config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle, + ), + copySubtitleMultiple: normalizeShortcut( + config.shortcuts?.copySubtitleMultiple ?? defaultConfig.shortcuts?.copySubtitleMultiple, + ), + updateLastCardFromClipboard: normalizeShortcut( + isAnkiConnectDisabled + ? null + : (config.shortcuts?.updateLastCardFromClipboard ?? + defaultConfig.shortcuts?.updateLastCardFromClipboard), + ), + triggerFieldGrouping: normalizeShortcut( + isAnkiConnectDisabled + ? null + : (config.shortcuts?.triggerFieldGrouping ?? defaultConfig.shortcuts?.triggerFieldGrouping), + ), + triggerSubsync: normalizeShortcut( + config.shortcuts?.triggerSubsync ?? defaultConfig.shortcuts?.triggerSubsync, + ), + mineSentence: normalizeShortcut( + isAnkiConnectDisabled + ? null + : (config.shortcuts?.mineSentence ?? defaultConfig.shortcuts?.mineSentence), + ), + mineSentenceMultiple: normalizeShortcut( + isAnkiConnectDisabled + ? null + : (config.shortcuts?.mineSentenceMultiple ?? defaultConfig.shortcuts?.mineSentenceMultiple), + ), + multiCopyTimeoutMs: + config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000, + toggleSecondarySub: normalizeShortcut( + config.shortcuts?.toggleSecondarySub ?? defaultConfig.shortcuts?.toggleSecondarySub, + ), + markAudioCard: normalizeShortcut( + isAnkiConnectDisabled + ? null + : (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard), + ), + openRuntimeOptions: normalizeShortcut( + config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions, + ), + openJimaku: normalizeShortcut( + config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku, + ), + }; +} diff --git a/src/generate-config-example.ts b/src/generate-config-example.ts new file mode 100644 index 0000000..3eaa1f4 --- /dev/null +++ b/src/generate-config-example.ts @@ -0,0 +1,19 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { DEFAULT_CONFIG, generateConfigTemplate } from './config'; + +function main(): void { + const template = generateConfigTemplate(DEFAULT_CONFIG); + const outputPaths = [ + path.join(process.cwd(), 'config.example.jsonc'), + path.join(process.cwd(), 'docs', 'public', 'config.example.jsonc'), + ]; + + for (const outputPath of outputPaths) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, template, 'utf-8'); + console.log(`Generated ${outputPath}`); + } +} + +main(); diff --git a/src/jimaku/utils.ts b/src/jimaku/utils.ts new file mode 100644 index 0000000..ac1b382 --- /dev/null +++ b/src/jimaku/utils.ts @@ -0,0 +1,371 @@ +import * as http from 'http'; +import * as https from 'https'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as childProcess from 'child_process'; +import { createLogger } from '../logger'; +import { + JimakuApiResponse, + JimakuConfig, + JimakuDownloadResult, + JimakuFileEntry, + JimakuLanguagePreference, + JimakuMediaInfo, +} from '../types'; + +const logger = createLogger('main:jimaku'); + +function execCommand(command: string): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + childProcess.exec(command, { timeout: 10000 }, (err, stdout, stderr) => { + if (err) { + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +export async function resolveJimakuApiKey(config: JimakuConfig): Promise { + if (config.apiKey && config.apiKey.trim()) { + logger.debug('API key found in config'); + return config.apiKey.trim(); + } + if (config.apiKeyCommand && config.apiKeyCommand.trim()) { + try { + const { stdout } = await execCommand(config.apiKeyCommand); + const key = stdout.trim(); + logger.debug(`apiKeyCommand result: ${key.length > 0 ? 'key obtained' : 'empty output'}`); + return key.length > 0 ? key : null; + } catch (err) { + logger.error('Failed to run jimaku.apiKeyCommand', (err as Error).message); + return null; + } + } + logger.debug('No API key configured (neither apiKey nor apiKeyCommand set)'); + return null; +} + +function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined { + const value = headers['x-ratelimit-reset-after']; + if (!value) return undefined; + const raw = Array.isArray(value) ? value[0] : value; + const parsed = Number.parseFloat(raw!); + if (!Number.isFinite(parsed)) return undefined; + return parsed; +} + +export async function jimakuFetchJson( + endpoint: string, + query: Record, + options: { baseUrl: string; apiKey: string }, +): Promise> { + const url = new URL(endpoint, options.baseUrl); + for (const [key, value] of Object.entries(query)) { + if (value === null || value === undefined) continue; + url.searchParams.set(key, String(value)); + } + + logger.debug(`GET ${url.toString()}`); + const transport = url.protocol === 'https:' ? https : http; + + return new Promise((resolve) => { + const req = transport.request( + url, + { + method: 'GET', + headers: { + Authorization: options.apiKey, + 'User-Agent': 'SubMiner', + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk.toString(); + }); + res.on('end', () => { + const status = res.statusCode || 0; + logger.debug(`Response HTTP ${status} for ${endpoint}`); + if (status >= 200 && status < 300) { + try { + const parsed = JSON.parse(data) as T; + resolve({ ok: true, data: parsed }); + } catch { + logger.error(`JSON parse error: ${data.slice(0, 200)}`); + resolve({ + ok: false, + error: { error: 'Failed to parse Jimaku response JSON.' }, + }); + } + return; + } + + let errorMessage = `Jimaku API error (HTTP ${status})`; + try { + const parsed = JSON.parse(data) as { error?: string }; + if (parsed && parsed.error) { + errorMessage = parsed.error; + } + } catch { + // Ignore parse errors. + } + logger.error(`API error: ${errorMessage}`); + + resolve({ + ok: false, + error: { + error: errorMessage, + code: status || undefined, + retryAfter: status === 429 ? getRetryAfter(res.headers) : undefined, + }, + }); + }); + }, + ); + + req.on('error', (err) => { + logger.error(`Network error: ${(err as Error).message}`); + resolve({ + ok: false, + error: { error: `Jimaku request failed: ${(err as Error).message}` }, + }); + }); + + req.end(); + }); +} + +function matchEpisodeFromName(name: string): { + season: number | null; + episode: number | null; + index: number | null; + confidence: 'high' | 'medium' | 'low'; +} { + const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i); + if (seasonEpisode && seasonEpisode.index !== undefined) { + return { + season: Number.parseInt(seasonEpisode[1]!, 10), + episode: Number.parseInt(seasonEpisode[2]!, 10), + index: seasonEpisode.index, + confidence: 'high', + }; + } + + const alt = name.match(/(\d{1,2})x(\d{1,3})/i); + if (alt && alt.index !== undefined) { + return { + season: Number.parseInt(alt[1]!, 10), + episode: Number.parseInt(alt[2]!, 10), + index: alt.index, + confidence: 'high', + }; + } + + const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i); + if (epOnly && epOnly.index !== undefined) { + return { + season: null, + episode: Number.parseInt(epOnly[1]!, 10), + index: epOnly.index, + confidence: 'medium', + }; + } + + const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/); + if (numeric && numeric.index !== undefined) { + return { + season: null, + episode: Number.parseInt(numeric[1]!, 10), + index: numeric.index, + confidence: 'medium', + }; + } + + return { season: null, episode: null, index: null, confidence: 'low' }; +} + +function detectSeasonFromDir(mediaPath: string): number | null { + const parent = path.basename(path.dirname(mediaPath)); + const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i); + if (!match) return null; + const parsed = Number.parseInt(match[1]!, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function cleanupTitle(value: string): string { + return value + .replace(/^[\s-–—]+/, '') + .replace(/[\s-–—]+$/, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { + if (!mediaPath) { + return { + title: '', + season: null, + episode: null, + confidence: 'low', + filename: '', + rawTitle: '', + }; + } + + const normalizedMediaPath = normalizeMediaPathForJimaku(mediaPath); + const filename = path.basename(normalizedMediaPath); + let name = filename.replace(/\.[^/.]+$/, ''); + name = name.replace(/\[[^\]]*]/g, ' '); + name = name.replace(/\(\d{4}\)/g, ' '); + name = name.replace(/[._]/g, ' '); + name = name.replace(/[–—]/g, '-'); + name = name.replace(/\s+/g, ' ').trim(); + + const parsed = matchEpisodeFromName(name); + let titlePart = name; + if (parsed.index !== null) { + titlePart = name.slice(0, parsed.index); + } + + const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath); + const title = cleanupTitle(titlePart || name); + + return { + title, + season: seasonFromDir, + episode: parsed.episode, + confidence: parsed.confidence, + filename, + rawTitle: name, + }; +} + +function normalizeMediaPathForJimaku(mediaPath: string): string { + const trimmed = mediaPath.trim(); + if (!trimmed || !/^https?:\/\/.*/.test(trimmed)) { + return trimmed; + } + + try { + const parsedUrl = new URL(trimmed); + const titleParam = + parsedUrl.searchParams.get('title') || + parsedUrl.searchParams.get('name') || + parsedUrl.searchParams.get('q'); + if (titleParam && titleParam.trim()) return titleParam.trim(); + + const pathParts = parsedUrl.pathname.split('/').filter(Boolean).reverse(); + const candidate = pathParts.find((part) => { + const decoded = decodeURIComponent(part || '').replace(/\.[^/.]+$/, ''); + const lowered = decoded.toLowerCase(); + return lowered.length > 2 && !/^[0-9.]+$/.test(lowered) && !/^[a-f0-9]{16,}$/i.test(lowered); + }); + + return decodeURIComponent(candidate || parsedUrl.hostname.replace(/^www\./, '')); + } catch { + return trimmed; + } +} + +function formatLangScore(name: string, pref: JimakuLanguagePreference): number { + if (pref === 'none') return 0; + const upper = name.toUpperCase(); + const hasJa = + /(^|[\W_])JA([\W_]|$)/.test(upper) || + /(^|[\W_])JPN([\W_]|$)/.test(upper) || + upper.includes('.JA.'); + const hasEn = + /(^|[\W_])EN([\W_]|$)/.test(upper) || + /(^|[\W_])ENG([\W_]|$)/.test(upper) || + upper.includes('.EN.'); + if (pref === 'ja') { + if (hasJa) return 2; + if (hasEn) return 1; + } else if (pref === 'en') { + if (hasEn) return 2; + if (hasJa) return 1; + } + return 0; +} + +export function sortJimakuFiles( + files: JimakuFileEntry[], + pref: JimakuLanguagePreference, +): JimakuFileEntry[] { + if (pref === 'none') return files; + return [...files].sort((a, b) => { + const scoreDiff = formatLangScore(b.name, pref) - formatLangScore(a.name, pref); + if (scoreDiff !== 0) return scoreDiff; + return a.name.localeCompare(b.name); + }); +} + +export function isRemoteMediaPath(mediaPath: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath); +} + +export async function downloadToFile( + url: string, + destPath: string, + headers: Record, + redirectCount = 0, +): Promise { + if (redirectCount > 3) { + return { + ok: false, + error: { error: 'Too many redirects while downloading subtitle.' }, + }; + } + + return new Promise((resolve) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'https:' ? https : http; + + const req = transport.get(parsedUrl, { headers }, (res) => { + const status = res.statusCode || 0; + if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { + const redirectUrl = new URL(res.headers.location, parsedUrl).toString(); + res.resume(); + downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve); + return; + } + + if (status < 200 || status >= 300) { + res.resume(); + resolve({ + ok: false, + error: { + error: `Failed to download subtitle (HTTP ${status}).`, + code: status, + }, + }); + return; + } + + const fileStream = fs.createWriteStream(destPath); + res.pipe(fileStream); + fileStream.on('finish', () => { + fileStream.close(() => { + resolve({ ok: true, path: destPath }); + }); + }); + fileStream.on('error', (err: Error) => { + resolve({ + ok: false, + error: { + error: `Failed to save subtitle: ${(err as Error).message}`, + }, + }); + }); + }); + + req.on('error', (err) => { + resolve({ + ok: false, + error: { error: `Download request failed: ${(err as Error).message}` }, + }); + }); + }); +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..3092095 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,197 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +export type LogLevelSource = 'cli' | 'config'; + +type LogMethod = (message: string, ...meta: unknown[]) => void; + +type Logger = { + debug: LogMethod; + info: LogMethod; + warn: LogMethod; + error: LogMethod; + child: (childScope: string) => Logger; +}; + +const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; +const LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +const DEFAULT_LOG_LEVEL: LogLevel = 'info'; + +let cliLogLevel: LogLevel | undefined; +let configLogLevel: LogLevel | undefined; + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +function normalizeLogLevel(level: string | undefined): LogLevel | undefined { + const normalized = (level || '').toLowerCase() as LogLevel; + return LOG_LEVELS.includes(normalized) ? normalized : undefined; +} + +function getEnvLogLevel(): LogLevel | undefined { + if (!process || !process.env) return undefined; + return normalizeLogLevel(process.env.SUBMINER_LOG_LEVEL); +} + +function formatTimestamp(date: Date): string { + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hour = pad(date.getHours()); + const minute = pad(date.getMinutes()); + const second = pad(date.getSeconds()); + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} + +function resolveMinLevel(): LogLevel { + const envLevel = getEnvLogLevel(); + if (cliLogLevel) { + return cliLogLevel; + } + if (envLevel) { + return envLevel; + } + if (configLogLevel) { + return configLogLevel; + } + return DEFAULT_LOG_LEVEL; +} + +export function setLogLevel(level: string | undefined, source: LogLevelSource = 'cli'): void { + const normalized = normalizeLogLevel(level); + if (source === 'cli') { + cliLogLevel = normalized; + } else { + configLogLevel = normalized; + } +} + +function normalizeError(error: Error): { message: string; stack?: string } { + return { + message: error.message, + ...(error.stack ? { stack: error.stack } : {}), + }; +} + +function sanitizeMeta(value: unknown): unknown { + if (value instanceof Error) { + return normalizeError(value); + } + if (typeof value === 'bigint') { + return value.toString(); + } + return value; +} + +function safeStringify(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'undefined' || + value === null + ) { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function resolveLogFilePath(): string { + const envPath = process.env.SUBMINER_MPV_LOG?.trim(); + if (envPath) { + return envPath; + } + const date = new Date().toISOString().slice(0, 10); + return path.join(os.homedir(), '.config', 'SubMiner', 'logs', `SubMiner-${date}.log`); +} + +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 + } +} + +function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void { + const minLevel = resolveMinLevel(); + if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[minLevel]) { + return; + } + + const timestamp = formatTimestamp(new Date()); + const prefix = `[subminer] - ${timestamp} - ${level.toUpperCase()} - [${scope}] ${message}`; + const normalizedMeta = meta.map(sanitizeMeta); + + if (normalizedMeta.length === 0) { + if (level === 'error') { + console.error(prefix); + } else if (level === 'warn') { + console.warn(prefix); + } else if (level === 'debug') { + console.debug(prefix); + } else { + console.info(prefix); + } + appendToLogFile(prefix); + return; + } + + const serialized = normalizedMeta.map(safeStringify).join(' '); + const finalMessage = `${prefix} ${serialized}`; + + if (level === 'error') { + console.error(finalMessage); + } else if (level === 'warn') { + console.warn(finalMessage); + } else if (level === 'debug') { + console.debug(finalMessage); + } else { + console.info(finalMessage); + } + appendToLogFile(finalMessage); +} + +export function createLogger(scope: string): Logger { + const baseScope = scope.trim(); + if (!baseScope) { + throw new Error('Logger scope is required'); + } + + const logAt = (level: LogLevel): LogMethod => { + return (message: string, ...meta: unknown[]) => { + emit(level, baseScope, message, meta); + }; + }; + + return { + debug: logAt('debug'), + info: logAt('info'), + warn: logAt('warn'), + error: logAt('error'), + child: (childScope: string): Logger => { + const normalizedChild = childScope.trim(); + if (!normalizedChild) { + throw new Error('Child logger scope is required'); + } + return createLogger(`${baseScope}:${normalizedChild}`); + }, + }; +} diff --git a/src/main-entry.ts b/src/main-entry.ts new file mode 100644 index 0000000..b0d5a49 --- /dev/null +++ b/src/main-entry.ts @@ -0,0 +1,35 @@ +import { spawn } from 'node:child_process'; + +const BACKGROUND_ARG = '--background'; +const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; + +function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean { + if (env.ELECTRON_RUN_AS_NODE === '1') return false; + if (!argv.includes(BACKGROUND_ARG)) return false; + if (env[BACKGROUND_CHILD_ENV] === '1') return false; + return true; +} + +function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...baseEnv }; + env[BACKGROUND_CHILD_ENV] = '1'; + if (!env.NODE_NO_WARNINGS) { + env.NODE_NO_WARNINGS = '1'; + } + if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) { + delete env.VK_INSTANCE_LAYERS; + } + return env; +} + +if (shouldDetachBackgroundLaunch(process.argv, process.env)) { + const child = spawn(process.execPath, process.argv.slice(1), { + detached: true, + stdio: 'ignore', + env: sanitizeBackgroundEnv(process.env), + }); + child.unref(); + process.exit(0); +} + +require('./main.js'); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..dbe3ef8 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,2994 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +import { + app, + BrowserWindow, + clipboard, + globalShortcut, + shell, + protocol, + Extension, + Menu, + nativeImage, + Tray, + dialog, + screen, +} from 'electron'; + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'chrome-extension', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + bypassCSP: true, + }, + }, +]); + +import * as fs from 'fs'; +import { spawn } from 'node:child_process'; +import * as os from 'os'; +import * as path from 'path'; +import { MecabTokenizer } from './mecab-tokenizer'; +import type { + JimakuApiResponse, + KikuFieldGroupingChoice, + MpvSubtitleRenderMetrics, + ResolvedConfig, + RuntimeOptionState, + SecondarySubMode, + SubtitleData, + SubtitlePosition, + SubsyncManualRunRequest, + SubsyncResult, + WindowGeometry, +} from './types'; +import { AnkiIntegration } from './anki-integration'; +import { SubtitleTimingTracker } from './subtitle-timing-tracker'; +import { RuntimeOptionsManager } from './runtime-options'; +import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils'; +import { createLogger, setLogLevel, type LogLevelSource } from './logger'; +import { commandNeedsOverlayRuntime, parseArgs, shouldStartApp } from './cli/args'; +import type { CliArgs, CliCommandSource } from './cli/args'; +import { printHelp } from './cli/help'; +import { + buildConfigParseErrorDetails, + buildConfigWarningNotificationBody, + failStartupFromConfig, +} from './main/config-validation'; +import { + buildAnilistAttemptKey, + buildAnilistSetupUrl, + consumeAnilistSetupCallbackUrl, + createAnilistStateRuntime, + createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, + createBuildGetCurrentAnilistMediaKeyMainDepsHandler, + createBuildMaybeProbeAnilistDurationMainDepsHandler, + createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, + createBuildOpenAnilistSetupWindowMainDepsHandler, + createBuildProcessNextAnilistRetryUpdateMainDepsHandler, + createBuildRefreshAnilistClientSecretStateMainDepsHandler, + createBuildResetAnilistMediaGuessStateMainDepsHandler, + createBuildResetAnilistMediaTrackingMainDepsHandler, + createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, + createEnsureAnilistMediaGuessHandler, + createGetAnilistMediaGuessRuntimeStateHandler, + createGetCurrentAnilistMediaKeyHandler, + createMaybeFocusExistingAnilistSetupWindowHandler, + createMaybeProbeAnilistDurationHandler, + createMaybeRunAnilistPostWatchUpdateHandler, + createOpenAnilistSetupWindowHandler, + createProcessNextAnilistRetryUpdateHandler, + createRefreshAnilistClientSecretStateHandler, + createResetAnilistMediaGuessStateHandler, + createResetAnilistMediaTrackingHandler, + createSetAnilistMediaGuessRuntimeStateHandler, + findAnilistSetupDeepLinkArgvUrl, + isAnilistTrackingEnabled, + loadAnilistManualTokenEntry, + loadAnilistSetupFallback, + openAnilistSetupInBrowser, + rememberAnilistAttemptedUpdateKey, +} from './main/runtime/domains/anilist'; +import { + createApplyJellyfinMpvDefaultsHandler, + createBuildApplyJellyfinMpvDefaultsMainDepsHandler, + createBuildGetDefaultSocketPathMainDepsHandler, + createEnsureMpvConnectedForJellyfinPlaybackHandler, + createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler, + createGetDefaultSocketPathHandler, + createGetJellyfinClientInfoHandler, + createBuildGetJellyfinClientInfoMainDepsHandler, + createHandleJellyfinAuthCommands, + createBuildHandleJellyfinAuthCommandsMainDepsHandler, + createHandleJellyfinListCommands, + createBuildHandleJellyfinListCommandsMainDepsHandler, + createHandleJellyfinPlayCommand, + createBuildHandleJellyfinPlayCommandMainDepsHandler, + createHandleJellyfinRemoteAnnounceCommand, + createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler, + createHandleJellyfinRemotePlay, + createBuildHandleJellyfinRemotePlayMainDepsHandler, + createHandleJellyfinRemotePlaystate, + createBuildHandleJellyfinRemotePlaystateMainDepsHandler, + createHandleJellyfinRemoteGeneralCommand, + createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler, + createLaunchMpvIdleForJellyfinPlaybackHandler, + createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler, + createPlayJellyfinItemInMpvHandler, + createBuildPlayJellyfinItemInMpvMainDepsHandler, + createPreloadJellyfinExternalSubtitlesHandler, + createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler, + createReportJellyfinRemoteProgressHandler, + createBuildReportJellyfinRemoteProgressMainDepsHandler, + createReportJellyfinRemoteStoppedHandler, + createBuildReportJellyfinRemoteStoppedMainDepsHandler, + createStartJellyfinRemoteSessionHandler, + createBuildStartJellyfinRemoteSessionMainDepsHandler, + createStopJellyfinRemoteSessionHandler, + createBuildStopJellyfinRemoteSessionMainDepsHandler, + createRunJellyfinCommandHandler, + createBuildRunJellyfinCommandMainDepsHandler, + createWaitForMpvConnectedHandler, + createBuildWaitForMpvConnectedMainDepsHandler, + createOpenJellyfinSetupWindowHandler, + createBuildOpenJellyfinSetupWindowMainDepsHandler, + createGetResolvedJellyfinConfigHandler, + createBuildGetResolvedJellyfinConfigMainDepsHandler, + parseJellyfinSetupSubmissionUrl, + buildJellyfinSetupFormHtml, + createMaybeFocusExistingJellyfinSetupWindowHandler, +} from './main/runtime/domains/jellyfin'; +import type { ActiveJellyfinRemotePlaybackState } from './main/runtime/domains/jellyfin'; +import { getConfiguredJellyfinSession } from './main/runtime/domains/jellyfin'; +import { + createBuildConfigHotReloadMessageMainDepsHandler, + createBuildConfigHotReloadAppliedMainDepsHandler, + createBuildConfigHotReloadRuntimeMainDepsHandler, + createBuildWatchConfigPathMainDepsHandler, + createWatchConfigPathHandler, + createBuildOverlayContentMeasurementStoreMainDepsHandler, + createBuildOverlayModalRuntimeMainDepsHandler, + createBuildAppendClipboardVideoToQueueMainDepsHandler, + createBuildHandleOverlayModalClosedMainDepsHandler, + createBuildLoadSubtitlePositionMainDepsHandler, + createBuildSaveSubtitlePositionMainDepsHandler, + createBuildFieldGroupingOverlayMainDepsHandler, + createBuildGetFieldGroupingResolverMainDepsHandler, + createBuildSetFieldGroupingResolverMainDepsHandler, + createBuildOverlayVisibilityRuntimeMainDepsHandler, + createBuildBroadcastRuntimeOptionsChangedMainDepsHandler, + createBuildGetRuntimeOptionsStateMainDepsHandler, + createBuildOpenRuntimeOptionsPaletteMainDepsHandler, + createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler, + createBuildSendToActiveOverlayWindowMainDepsHandler, + createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, + createBuildEnforceOverlayLayerOrderMainDepsHandler, + createBuildEnsureOverlayWindowLevelMainDepsHandler, + createBuildUpdateInvisibleOverlayBoundsMainDepsHandler, + createBuildUpdateVisibleOverlayBoundsMainDepsHandler, + createOverlayWindowRuntimeHandlers, + createOverlayRuntimeBootstrapHandlers, + createTrayRuntimeHandlers, + createOverlayVisibilityRuntime, + createBroadcastRuntimeOptionsChangedHandler, + createGetRuntimeOptionsStateHandler, + createGetFieldGroupingResolverHandler, + createSetFieldGroupingResolverHandler, + createOpenRuntimeOptionsPaletteHandler, + createRestorePreviousSecondarySubVisibilityHandler, + createSendToActiveOverlayWindowHandler, + createSetOverlayDebugVisualizationEnabledHandler, + createEnforceOverlayLayerOrderHandler, + createEnsureOverlayWindowLevelHandler, + createUpdateInvisibleOverlayBoundsHandler, + createUpdateVisibleOverlayBoundsHandler, + createLoadSubtitlePositionHandler, + createSaveSubtitlePositionHandler, + createAppendClipboardVideoToQueueHandler, + createHandleOverlayModalClosedHandler, + createConfigHotReloadMessageHandler, + createConfigHotReloadAppliedHandler, + buildTrayMenuTemplateRuntime, + resolveTrayIconPathRuntime, + createYomitanExtensionRuntime, + createYomitanSettingsRuntime, + buildRestartRequiredConfigMessage, + resolveSubtitleStyleForRenderer, +} from './main/runtime/domains/overlay'; +import { + createBuildAnilistStateRuntimeMainDepsHandler, + createBuildConfigDerivedRuntimeMainDepsHandler, + createBuildImmersionMediaRuntimeMainDepsHandler, + createBuildMainSubsyncRuntimeMainDepsHandler, + createBuildSubtitleProcessingControllerMainDepsHandler, + createBuildMediaRuntimeMainDepsHandler, + createBuildDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRuntimeMainDepsHandler, + createBuildJlptDictionaryRuntimeMainDepsHandler, + createImmersionMediaRuntime, + createConfigDerivedRuntime, + appendClipboardVideoToQueueRuntime, + createMainSubsyncRuntime, + createLaunchBackgroundWarmupTaskHandler, + createStartBackgroundWarmupsHandler, + createBuildLaunchBackgroundWarmupTaskMainDepsHandler, + createBuildStartBackgroundWarmupsMainDepsHandler, +} from './main/runtime/domains/startup'; +import { + createBuildBindMpvMainEventHandlersMainDepsHandler, + createBuildMpvClientRuntimeServiceFactoryDepsHandler, + createMpvClientRuntimeServiceFactory, + createBindMpvMainEventHandlersHandler, + createBuildTokenizerDepsMainHandler, + createCreateMecabTokenizerAndCheckMainHandler, + createPrewarmSubtitleDictionariesMainHandler, + createUpdateMpvSubtitleRenderMetricsHandler, + createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler, + createMpvOsdRuntimeHandlers, + createCycleSecondarySubModeRuntimeHandler, +} from './main/runtime/domains/mpv'; +import type { MpvClientRuntimeServiceOptions } from './main/runtime/domains/mpv'; +import { + createBuildCopyCurrentSubtitleMainDepsHandler, + createBuildHandleMineSentenceDigitMainDepsHandler, + createBuildHandleMultiCopyDigitMainDepsHandler, + createBuildMarkLastCardAsAudioCardMainDepsHandler, + createBuildMineSentenceCardMainDepsHandler, + createBuildRefreshKnownWordCacheMainDepsHandler, + createBuildTriggerFieldGroupingMainDepsHandler, + createBuildUpdateLastCardFromClipboardMainDepsHandler, + createMarkLastCardAsAudioCardHandler, + createMineSentenceCardHandler, + createRefreshKnownWordCacheHandler, + createTriggerFieldGroupingHandler, + createUpdateLastCardFromClipboardHandler, + createCopyCurrentSubtitleHandler, + createHandleMineSentenceDigitHandler, + createHandleMultiCopyDigitHandler, +} from './main/runtime/domains/mining'; +import { + createCliCommandContextFactory, + createInitialArgsRuntimeHandler, + createCliCommandRuntimeHandler, +} from './main/runtime/domains/ipc'; +import { + enforceUnsupportedWaylandMode, + forceX11Backend, + generateDefaultConfigFile, + resolveConfiguredShortcuts, + resolveKeybindings, + showDesktopNotification, +} from './core/utils'; +import { + ImmersionTrackerService, + JellyfinRemoteSessionService, + MpvIpcClient, + SubtitleWebSocket, + Texthooker, + DEFAULT_MPV_SUBTITLE_RENDER_METRICS, + applyMpvSubtitleRenderMetricsPatch, + authenticateWithPasswordRuntime, + broadcastRuntimeOptionsChangedRuntime, + copyCurrentSubtitle as copyCurrentSubtitleCore, + createConfigHotReloadRuntime, + createDiscordPresenceService, + createFieldGroupingOverlayRuntime, + createOverlayContentMeasurementStore, + createOverlayManager, + createOverlayWindow as createOverlayWindowCore, + createSubtitleProcessingController, + createTokenizerDepsRuntime, + cycleSecondarySubMode as cycleSecondarySubModeCore, + enforceOverlayLayerOrder as enforceOverlayLayerOrderCore, + ensureOverlayWindowLevel as ensureOverlayWindowLevelCore, + handleMineSentenceDigit as handleMineSentenceDigitCore, + handleMultiCopyDigit as handleMultiCopyDigitCore, + hasMpvWebsocketPlugin, + initializeOverlayRuntime as initializeOverlayRuntimeCore, + jellyfinTicksToSecondsRuntime, + listJellyfinItemsRuntime, + listJellyfinLibrariesRuntime, + listJellyfinSubtitleTracksRuntime, + loadSubtitlePosition as loadSubtitlePositionCore, + loadYomitanExtension as loadYomitanExtensionCore, + markLastCardAsAudioCard as markLastCardAsAudioCardCore, + mineSentenceCard as mineSentenceCardCore, + openYomitanSettingsWindow, + playNextSubtitleRuntime, + registerGlobalShortcuts as registerGlobalShortcutsCore, + replayCurrentSubtitleRuntime, + resolveJellyfinPlaybackPlanRuntime, + runStartupBootstrapRuntime, + saveSubtitlePosition as saveSubtitlePositionCore, + sendMpvCommandRuntime, + setInvisibleOverlayVisible as setInvisibleOverlayVisibleCore, + setMpvSubVisibilityRuntime, + setOverlayDebugVisualizationEnabledRuntime, + setVisibleOverlayVisible as setVisibleOverlayVisibleCore, + showMpvOsdRuntime, + tokenizeSubtitle as tokenizeSubtitleCore, + triggerFieldGrouping as triggerFieldGroupingCore, + updateLastCardFromClipboard as updateLastCardFromClipboardCore, +} from './core/services'; +import { splitOverlayGeometryForSecondaryBar } from './core/services/overlay-window-geometry'; +import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; +import { + guessAnilistMediaInfo, + updateAnilistPostWatchProgress, +} from './core/services/anilist/anilist-updater'; +import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; +import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; +import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; +import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; +import { createMainRuntimeRegistry } from './main/runtime/registry'; +import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight'; +import { + composeAnilistSetupHandlers, + composeAnilistTrackingHandlers, + composeAppReadyRuntime, + composeIpcRuntimeHandlers, + composeJellyfinRuntimeHandlers, + composeMpvRuntimeHandlers, + composeShortcutRuntimes, + composeStartupLifecycleHandlers, +} from './main/runtime/composers'; +import { createStartupBootstrapRuntimeDeps } from './main/startup'; +import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; +import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; +import { registerIpcRuntimeServices } from './main/ipc-runtime'; +import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; +import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; +import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime'; +import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; +import { + createFrequencyDictionaryRuntimeService, + getFrequencyDictionarySearchPaths, +} from './main/frequency-dictionary-runtime'; +import { + createJlptDictionaryRuntimeService, + getJlptDictionarySearchPaths, +} from './main/jlpt-runtime'; +import { createMediaRuntimeService } from './main/media-runtime'; +import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; +import { + type AnilistMediaGuessRuntimeState, + type AppState, + type StartupState, + applyStartupState, + createAppState, + createInitialAnilistMediaGuessRuntimeState, + createInitialAnilistUpdateInFlightState, + transitionAnilistClientSecretState, + transitionAnilistMediaGuessRuntimeState, + transitionAnilistRetryQueueLastAttemptAt, + transitionAnilistRetryQueueLastError, + transitionAnilistRetryQueueState, + transitionAnilistUpdateInFlightState, +} from './main/state'; +import { + isAllowedAnilistExternalUrl, + isAllowedAnilistSetupNavigationUrl, +} from './main/anilist-url-guard'; +import { + ConfigService, + ConfigStartupParseError, + DEFAULT_CONFIG, + DEFAULT_KEYBINDINGS, + generateConfigTemplate, +} from './config'; +import { resolveConfigDir } from './config/path-resolution'; + +if (process.platform === 'linux') { + app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); +} + +app.setName('SubMiner'); + +const DEFAULT_TEXTHOOKER_PORT = 5174; +const DEFAULT_MPV_LOG_FILE = path.join(os.homedir(), '.cache', 'SubMiner', 'mp.log'); +const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize'; +const ANILIST_SETUP_RESPONSE_TYPE = 'token'; +const ANILIST_DEFAULT_CLIENT_ID = '36084'; +const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/'; +const ANILIST_DEVELOPER_SETTINGS_URL = 'https://anilist.co/settings/developer'; +const ANILIST_UPDATE_MIN_WATCH_RATIO = 0.85; +const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60; +const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; +const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; +const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json'; +const JELLYFIN_TOKEN_STORE_FILE = 'jellyfin-token-store.json'; +const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json'; +const TRAY_TOOLTIP = 'SubMiner'; + +let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState = + createInitialAnilistMediaGuessRuntimeState(); +let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState(); +const anilistAttemptedUpdateKeys = new Set(); +let anilistCachedAccessToken: string | null = null; +let jellyfinPlayQuitOnDisconnectArmed = false; +const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; +const JELLYFIN_TICKS_PER_SECOND = 10_000_000; +const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; +const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; +const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; +const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; +const MPV_JELLYFIN_DEFAULT_ARGS = [ + '--sub-auto=fuzzy', + '--sub-file-paths=.;subs;subtitles', + '--sid=auto', + '--secondary-sid=auto', + '--secondary-sub-visibility=no', + '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', + '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', +] as const; + +let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; +let jellyfinRemoteLastProgressAtMs = 0; +let jellyfinMpvAutoLaunchInFlight: Promise | null = null; +let backgroundWarmupsStarted = false; +let yomitanLoadInFlight: Promise | null = null; + +const buildApplyJellyfinMpvDefaultsMainDepsHandler = + createBuildApplyJellyfinMpvDefaultsMainDepsHandler({ + sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client, command), + jellyfinLangPref: JELLYFIN_LANG_PREF, + }); +const applyJellyfinMpvDefaultsMainDeps = buildApplyJellyfinMpvDefaultsMainDepsHandler(); +const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler( + applyJellyfinMpvDefaultsMainDeps, +); + +function applyJellyfinMpvDefaults( + client: Parameters[0], +): void { + applyJellyfinMpvDefaultsHandler(client); +} + +const CONFIG_DIR = resolveConfigDir({ + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, +}); +const USER_DATA_PATH = CONFIG_DIR; +const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; +const DEFAULT_IMMERSION_DB_PATH = path.join(USER_DATA_PATH, 'immersion.sqlite'); +const configService = (() => { + try { + return new ConfigService(CONFIG_DIR); + } catch (error) { + if (error instanceof ConfigStartupParseError) { + failStartupFromConfig( + 'SubMiner config parse error', + buildConfigParseErrorDetails(error.path, error.parseError), + { + logError: (details) => console.error(details), + showErrorBox: (title, details) => dialog.showErrorBox(title, details), + quit: () => app.quit(), + }, + ); + } + throw error; + } +})(); +const anilistTokenStore = createAnilistTokenStore( + path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE), + { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }, +); +const jellyfinTokenStore = createJellyfinTokenStore( + path.join(USER_DATA_PATH, JELLYFIN_TOKEN_STORE_FILE), + { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }, +); +const anilistUpdateQueue = createAnilistUpdateQueue( + path.join(USER_DATA_PATH, ANILIST_RETRY_QUEUE_FILE), + { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }, +); +const isDev = process.argv.includes('--dev') || process.argv.includes('--debug'); +const texthookerService = new Texthooker(); +const subtitleWsService = new SubtitleWebSocket(); +const logger = createLogger('main'); +const appLogger = { + logInfo: (message: string) => { + logger.info(message); + }, + logWarning: (message: string) => { + logger.warn(message); + }, + logError: (message: string, details: unknown) => { + logger.error(message, details); + }, + logNoRunningInstance: () => { + logger.error('No running instance. Use --start to launch the app.'); + }, + logConfigWarning: (warning: { + path: string; + message: string; + value: unknown; + fallback: unknown; + }) => { + logger.warn( + `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`, + ); + }, +}; +const runtimeRegistry = createMainRuntimeRegistry(); + +const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({ + platform: process.platform, +}); +const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler(); +const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefaultSocketPathMainDeps); + +function getDefaultSocketPath(): string { + return getDefaultSocketPathHandler(); +} + +if (!fs.existsSync(USER_DATA_PATH)) { + fs.mkdirSync(USER_DATA_PATH, { recursive: true }); +} +app.setPath('userData', USER_DATA_PATH); + +process.on('SIGINT', () => { + app.quit(); +}); +process.on('SIGTERM', () => { + app.quit(); +}); + +const overlayManager = createOverlayManager(); +const buildOverlayContentMeasurementStoreMainDepsHandler = + createBuildOverlayContentMeasurementStoreMainDepsHandler({ + now: () => Date.now(), + warn: (message: string) => logger.warn(message), + }); +const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({ + getMainWindow: () => overlayManager.getMainWindow(), + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), +}); +const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler(); +const overlayContentMeasurementStore = createOverlayContentMeasurementStore( + overlayContentMeasurementStoreMainDeps, +); +const overlayModalRuntime = createOverlayModalRuntimeService( + buildOverlayModalRuntimeMainDepsHandler(), +); +const appState = createAppState({ + mpvSocketPath: getDefaultSocketPath(), + texthookerPort: DEFAULT_TEXTHOOKER_PORT, +}); +const discordPresenceSessionStartedAtMs = Date.now(); +let discordPresenceMediaDurationSec: number | null = null; + +function refreshDiscordPresenceMediaDuration(): void { + const client = appState.mpvClient; + if (!client || !client.connected) return; + void client + .requestProperty('duration') + .then((value) => { + const numeric = Number(value); + discordPresenceMediaDurationSec = Number.isFinite(numeric) && numeric > 0 ? numeric : null; + }) + .catch(() => { + discordPresenceMediaDurationSec = null; + }); +} + +function publishDiscordPresence(): void { + refreshDiscordPresenceMediaDuration(); + appState.discordPresenceService?.publish({ + mediaTitle: appState.currentMediaTitle, + mediaPath: appState.currentMediaPath, + subtitleText: appState.currentSubText, + currentTimeSec: appState.mpvClient?.currentTimePos ?? null, + mediaDurationSec: + discordPresenceMediaDurationSec ?? anilistMediaGuessRuntimeState.mediaDurationSec, + paused: appState.playbackPaused, + connected: Boolean(appState.mpvClient?.connected), + sessionStartedAtMs: discordPresenceSessionStartedAtMs, + }); +} + +function createDiscordRpcClient() { + const discordRpc = require('discord-rpc') as { + Client: new (opts: { transport: 'ipc' }) => { + login: (opts: { clientId: string }) => Promise; + setActivity: (activity: Record) => Promise; + clearActivity: () => Promise; + destroy: () => void; + }; + }; + const client = new discordRpc.Client({ transport: 'ipc' }); + + return { + login: () => client.login({ clientId: DISCORD_PRESENCE_APP_ID }), + setActivity: (activity: unknown) => + client.setActivity(activity as unknown as Record), + clearActivity: () => client.clearActivity(), + destroy: () => client.destroy(), + }; +} + +async function initializeDiscordPresenceService(): Promise { + appState.discordPresenceService = createDiscordPresenceService({ + config: getResolvedConfig().discordPresence, + createClient: () => createDiscordRpcClient(), + logDebug: (message, meta) => logger.debug(message, meta), + }); + await appState.discordPresenceService.start(); + publishDiscordPresence(); +} +const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({ + getMpvClient: () => appState.mpvClient, + getCurrentSubtitleData: () => appState.currentSubtitleData, + getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex, + getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision, + getHoverTokenColor: () => getResolvedConfig().subtitleStyle.hoverTokenColor ?? null, +}); +const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({ + getResolvedConfig: () => getResolvedConfig(), + defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, + getTracker: () => appState.immersionTracker, + getMpvClient: () => appState.mpvClient, + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentMediaTitle: () => appState.currentMediaTitle, + logDebug: (message) => logger.debug(message), + logInfo: (message) => logger.info(message), +}); +const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMainDepsHandler({ + getClientSecretState: () => appState.anilistClientSecretState, + setClientSecretState: (next) => { + appState.anilistClientSecretState = transitionAnilistClientSecretState( + appState.anilistClientSecretState, + next, + ); + }, + getRetryQueueState: () => appState.anilistRetryQueueState, + setRetryQueueState: (next) => { + appState.anilistRetryQueueState = transitionAnilistRetryQueueState( + appState.anilistRetryQueueState, + next, + ); + }, + getUpdateQueueSnapshot: () => anilistUpdateQueue.getSnapshot(), + clearStoredToken: () => anilistTokenStore.clearToken(), + clearCachedAccessToken: () => { + anilistCachedAccessToken = null; + }, +}); +const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({ + getResolvedConfig: () => getResolvedConfig(), + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + platform: process.platform, + defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference, + defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults, + defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl, +}); +const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMainDepsHandler({ + getMpvClient: () => appState.mpvClient, + getResolvedConfig: () => getResolvedConfig(), + getSubsyncInProgress: () => appState.subsyncInProgress, + setSubsyncInProgress: (inProgress) => { + appState.subsyncInProgress = inProgress; + }, + showMpvOsd: (text) => showMpvOsd(text), + openManualPicker: (payload) => { + sendToActiveOverlayWindow('subsync:open-manual', payload, { + restoreOnModalClose: 'subsync', + }); + }, +}); +const immersionMediaRuntime = createImmersionMediaRuntime( + buildImmersionMediaRuntimeMainDepsHandler(), +); +const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler()); +const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); +const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); +let appTray: Tray | null = null; +const buildSubtitleProcessingControllerMainDepsHandler = + createBuildSubtitleProcessingControllerMainDepsHandler({ + tokenizeSubtitle: async (text: string) => { + if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) { + return null; + } + return await tokenizeSubtitle(text); + }, + emitSubtitle: (payload) => { + const previousSubtitleText = appState.currentSubtitleData?.text ?? null; + const nextSubtitleText = payload?.text ?? null; + const subtitleChanged = previousSubtitleText !== nextSubtitleText; + appState.currentSubtitleData = payload; + if (subtitleChanged) { + appState.hoveredSubtitleTokenIndex = null; + appState.hoveredSubtitleRevision += 1; + applyHoveredTokenOverlay(); + } + broadcastToOverlayWindows('subtitle:set', payload); + subtitleWsService.broadcast(payload, { + enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, + mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, + }); + }, + logDebug: (message) => { + logger.debug(`[subtitle-processing] ${message}`); + }, + now: () => Date.now(), + }); +const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMainDepsHandler(); +const subtitleProcessingController = createSubtitleProcessingController( + subtitleProcessingControllerMainDeps, +); +const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( + createBuildOverlayShortcutsRuntimeMainDepsHandler({ + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getShortcutsRegistered: () => appState.shortcutsRegistered, + setShortcutsRegistered: (registered: boolean) => { + appState.shortcutsRegistered = registered; + }, + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + showMpvOsd: (text: string) => showMpvOsd(text), + openRuntimeOptionsPalette: () => { + openRuntimeOptionsPalette(); + }, + openJimaku: () => { + sendToActiveOverlayWindow('jimaku:open', undefined, { + restoreOnModalClose: 'jimaku', + }); + }, + markAudioCard: () => markLastCardAsAudioCard(), + copySubtitleMultiple: (timeoutMs: number) => { + startPendingMultiCopy(timeoutMs); + }, + copySubtitle: () => { + copyCurrentSubtitle(); + }, + toggleSecondarySubMode: () => cycleSecondarySubMode(), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + mineSentenceCard: () => mineSentenceCard(), + mineSentenceMultiple: (timeoutMs: number) => { + startPendingMineSentenceMultiple(timeoutMs); + }, + cancelPendingMultiCopy: () => { + cancelPendingMultiCopy(); + }, + cancelPendingMineSentenceMultiple: () => { + cancelPendingMineSentenceMultiple(); + }, + })(), +); + +const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( + { + showMpvOsd: (message) => showMpvOsd(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + }, +); +const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler(); +const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler( + configHotReloadMessageMainDeps, +); +const buildWatchConfigPathMainDepsHandler = createBuildWatchConfigPathMainDepsHandler({ + fileExists: (targetPath) => fs.existsSync(targetPath), + dirname: (targetPath) => path.dirname(targetPath), + watchPath: (targetPath, listener) => fs.watch(targetPath, listener), +}); +const watchConfigPathHandler = createWatchConfigPathHandler(buildWatchConfigPathMainDepsHandler()); +const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadAppliedMainDepsHandler( + { + setKeybindings: (keybindings) => { + appState.keybindings = keybindings; + }, + refreshGlobalAndOverlayShortcuts: () => { + refreshGlobalAndOverlayShortcuts(); + }, + setSecondarySubMode: (mode) => { + appState.secondarySubMode = mode; + syncSecondaryOverlayWindowVisibility(); + }, + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + applyAnkiRuntimeConfigPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + }, +); +const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler( + { + getCurrentConfig: () => getResolvedConfig(), + reloadConfigStrict: () => configService.reloadConfigStrict(), + watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timeout) => clearTimeout(timeout), + debounceMs: 250, + onHotReloadApplied: createConfigHotReloadAppliedHandler( + buildConfigHotReloadAppliedMainDepsHandler(), + ), + onRestartRequired: (fields) => + notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), + onInvalidConfig: notifyConfigHotReloadMessage, + onValidationWarnings: (configPath, warnings) => { + showDesktopNotification('SubMiner', { + body: buildConfigWarningNotificationBody(configPath, warnings), + }); + }, + }, +); +const configHotReloadRuntime = createConfigHotReloadRuntime( + buildConfigHotReloadRuntimeMainDepsHandler(), +); + +const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({ + dirname: __dirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + userDataPath: USER_DATA_PATH, + appUserDataPath: app.getPath('userData'), + homeDir: os.homedir(), + cwd: process.cwd(), + joinPath: (...parts) => path.join(...parts), +}); +const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRootsMainHandler({ + dirname: __dirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + userDataPath: USER_DATA_PATH, + appUserDataPath: app.getPath('userData'), + homeDir: os.homedir(), + cwd: process.cwd(), + joinPath: (...parts) => path.join(...parts), +}); + +const jlptDictionaryRuntime = createJlptDictionaryRuntimeService( + createBuildJlptDictionaryRuntimeMainDepsHandler({ + isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, + getDictionaryRoots: () => buildDictionaryRootsHandler(), + getJlptDictionarySearchPaths, + setJlptLevelLookup: (lookup) => { + appState.jlptLevelLookup = lookup; + }, + logInfo: (message) => logger.info(message), + })(), +); + +const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService( + createBuildFrequencyDictionaryRuntimeMainDepsHandler({ + isFrequencyDictionaryEnabled: () => + getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + getDictionaryRoots: () => buildFrequencyDictionaryRootsHandler(), + getFrequencyDictionarySearchPaths, + getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, + setFrequencyRankLookup: (lookup) => { + appState.frequencyRankLookup = lookup; + }, + logInfo: (message) => logger.info(message), + })(), +); + +const buildGetFieldGroupingResolverMainDepsHandler = + createBuildGetFieldGroupingResolverMainDepsHandler({ + getResolver: () => appState.fieldGroupingResolver, + }); +const getFieldGroupingResolverMainDeps = buildGetFieldGroupingResolverMainDepsHandler(); +const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler( + getFieldGroupingResolverMainDeps, +); + +function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { + return getFieldGroupingResolverHandler(); +} + +const buildSetFieldGroupingResolverMainDepsHandler = + createBuildSetFieldGroupingResolverMainDepsHandler({ + setResolver: (resolver) => { + appState.fieldGroupingResolver = resolver; + }, + nextSequence: () => { + appState.fieldGroupingResolverSequence += 1; + return appState.fieldGroupingResolverSequence; + }, + getSequence: () => appState.fieldGroupingResolverSequence, + }); +const setFieldGroupingResolverMainDeps = buildSetFieldGroupingResolverMainDepsHandler(); +const setFieldGroupingResolverHandler = createSetFieldGroupingResolverHandler( + setFieldGroupingResolverMainDeps, +); + +function setFieldGroupingResolver( + resolver: ((choice: KikuFieldGroupingChoice) => void) | null, +): void { + setFieldGroupingResolverHandler(resolver); +} + +const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime( + createBuildFieldGroupingOverlayMainDepsHandler({ + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), + getResolver: () => getFieldGroupingResolver(), + setResolver: (resolver) => setFieldGroupingResolver(resolver), + getRestoreVisibleOverlayOnModalClose: () => + overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + })(), +); +const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; + +const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); + +const mediaRuntime = createMediaRuntimeService( + createBuildMediaRuntimeMainDepsHandler({ + isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), + loadSubtitlePosition: () => loadSubtitlePosition(), + getCurrentMediaPath: () => appState.currentMediaPath, + getPendingSubtitlePosition: () => appState.pendingSubtitlePosition, + getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, + setCurrentMediaPath: (nextPath: string | null) => { + appState.currentMediaPath = nextPath; + }, + clearPendingSubtitlePosition: () => { + appState.pendingSubtitlePosition = null; + }, + setSubtitlePosition: (position: SubtitlePosition | null) => { + appState.subtitlePosition = position; + }, + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + getCurrentMediaTitle: () => appState.currentMediaTitle, + setCurrentMediaTitle: (title) => { + appState.currentMediaTitle = title; + }, + })(), +); + +const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( + createBuildOverlayVisibilityRuntimeMainDepsHandler({ + getMainWindow: () => overlayManager.getMainWindow(), + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + getWindowTracker: () => appState.windowTracker, + getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown: boolean) => { + appState.trackerNotReadyWarningShown = shown; + }, + updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => + updateInvisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => { + ensureOverlayWindowLevel(window); + }, + enforceOverlayLayerOrder: () => { + enforceOverlayLayerOrder(); + }, + syncOverlayShortcuts: () => { + overlayShortcutsRuntime.syncOverlayShortcuts(); + }, + })(), +); + +const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler( + { + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + }, +); +const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler(); +const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler( + getRuntimeOptionsStateMainDeps, +); + +function getRuntimeOptionsState(): RuntimeOptionState[] { + return getRuntimeOptionsStateHandler(); +} + +function getOverlayWindows(): BrowserWindow[] { + return overlayManager.getOverlayWindows(); +} + +const buildRestorePreviousSecondarySubVisibilityMainDepsHandler = + createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({ + getMpvClient: () => appState.mpvClient, + }); +const restorePreviousSecondarySubVisibilityMainDeps = + buildRestorePreviousSecondarySubVisibilityMainDepsHandler(); +const restorePreviousSecondarySubVisibilityHandler = + createRestorePreviousSecondarySubVisibilityHandler(restorePreviousSecondarySubVisibilityMainDeps); + +function restorePreviousSecondarySubVisibility(): void { + restorePreviousSecondarySubVisibilityHandler(); +} + +function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { + overlayManager.broadcastToOverlayWindows(channel, ...args); +} + +const buildBroadcastRuntimeOptionsChangedMainDepsHandler = + createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ + broadcastRuntimeOptionsChangedRuntime, + getRuntimeOptionsState: () => getRuntimeOptionsState(), + broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), + }); +const broadcastRuntimeOptionsChangedMainDeps = buildBroadcastRuntimeOptionsChangedMainDepsHandler(); +const broadcastRuntimeOptionsChangedHandler = createBroadcastRuntimeOptionsChangedHandler( + broadcastRuntimeOptionsChangedMainDeps, +); + +function broadcastRuntimeOptionsChanged(): void { + broadcastRuntimeOptionsChangedHandler(); +} + +const buildSendToActiveOverlayWindowMainDepsHandler = + createBuildSendToActiveOverlayWindowMainDepsHandler({ + sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) => + overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }); +const sendToActiveOverlayWindowMainDeps = buildSendToActiveOverlayWindowMainDepsHandler(); +const sendToActiveOverlayWindowHandler = createSendToActiveOverlayWindowHandler( + sendToActiveOverlayWindowMainDeps, +); + +function sendToActiveOverlayWindow( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, +): boolean { + return sendToActiveOverlayWindowHandler(channel, payload, runtimeOptions); +} + +const buildSetOverlayDebugVisualizationEnabledMainDepsHandler = + createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({ + setOverlayDebugVisualizationEnabledRuntime, + getCurrentEnabled: () => appState.overlayDebugVisualizationEnabled, + setCurrentEnabled: (next) => { + appState.overlayDebugVisualizationEnabled = next; + }, + broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), + }); +const setOverlayDebugVisualizationEnabledMainDeps = + buildSetOverlayDebugVisualizationEnabledMainDepsHandler(); +const setOverlayDebugVisualizationEnabledHandler = createSetOverlayDebugVisualizationEnabledHandler( + setOverlayDebugVisualizationEnabledMainDeps, +); + +function setOverlayDebugVisualizationEnabled(enabled: boolean): void { + setOverlayDebugVisualizationEnabledHandler(enabled); +} + +const buildOpenRuntimeOptionsPaletteMainDepsHandler = + createBuildOpenRuntimeOptionsPaletteMainDepsHandler({ + openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(), + }); +const openRuntimeOptionsPaletteMainDeps = buildOpenRuntimeOptionsPaletteMainDepsHandler(); +const openRuntimeOptionsPaletteHandler = createOpenRuntimeOptionsPaletteHandler( + openRuntimeOptionsPaletteMainDeps, +); + +function openRuntimeOptionsPalette(): void { + openRuntimeOptionsPaletteHandler(); +} + +function getResolvedConfig() { + return configService.getConfig(); +} + +const { + getResolvedJellyfinConfig, + getJellyfinClientInfo, + reportJellyfinRemoteProgress, + reportJellyfinRemoteStopped, + handleJellyfinRemotePlay, + handleJellyfinRemotePlaystate, + handleJellyfinRemoteGeneralCommand, + playJellyfinItemInMpv, + startJellyfinRemoteSession, + stopJellyfinRemoteSession, + runJellyfinCommand, + openJellyfinSetupWindow, +} = composeJellyfinRuntimeHandlers({ + getResolvedJellyfinConfigMainDeps: { + getResolvedConfig: () => getResolvedConfig(), + loadStoredSession: () => jellyfinTokenStore.loadSession(), + getEnv: (name) => process.env[name], + }, + getJellyfinClientInfoMainDeps: { + getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), + getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, + }, + waitForMpvConnectedMainDeps: { + getMpvClient: () => appState.mpvClient, + now: () => Date.now(), + sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + }, + launchMpvIdleForJellyfinPlaybackMainDeps: { + getSocketPath: () => appState.mpvSocketPath, + platform: process.platform, + execPath: process.execPath, + defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, + defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, + removeSocketPath: (socketPath) => { + fs.rmSync(socketPath, { force: true }); + }, + spawnMpv: (args) => + spawn('mpv', args, { + detached: true, + stdio: 'ignore', + }), + logWarn: (message, error) => logger.warn(message, error), + logInfo: (message) => logger.info(message), + }, + ensureMpvConnectedForJellyfinPlaybackMainDeps: { + getMpvClient: () => appState.mpvClient, + setMpvClient: (client) => { + appState.mpvClient = client as MpvIpcClient | null; + }, + createMpvClient: () => createMpvClientRuntimeService(), + getAutoLaunchInFlight: () => jellyfinMpvAutoLaunchInFlight, + setAutoLaunchInFlight: (promise) => { + jellyfinMpvAutoLaunchInFlight = promise; + }, + connectTimeoutMs: JELLYFIN_MPV_CONNECT_TIMEOUT_MS, + autoLaunchTimeoutMs: JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS, + }, + preloadJellyfinExternalSubtitlesMainDeps: { + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => + listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId), + getMpvClient: () => appState.mpvClient, + sendMpvCommand: (command) => { + sendMpvCommandRuntime(appState.mpvClient, command); + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + logDebug: (message, error) => { + logger.debug(message, error); + }, + }, + playJellyfinItemInMpvMainDeps: { + getMpvClient: () => appState.mpvClient, + resolvePlaybackPlan: (params) => + resolveJellyfinPlaybackPlanRuntime( + params.session, + params.clientInfo, + params.jellyfinConfig as ReturnType, + { + itemId: params.itemId, + audioStreamIndex: params.audioStreamIndex ?? undefined, + subtitleStreamIndex: params.subtitleStreamIndex ?? undefined, + }, + ), + applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), + sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), + armQuitOnDisconnect: () => { + jellyfinPlayQuitOnDisconnectArmed = false; + setTimeout(() => { + jellyfinPlayQuitOnDisconnectArmed = true; + }, 3000); + }, + schedule: (callback, delayMs) => { + setTimeout(callback, delayMs); + }, + convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), + setActivePlayback: (state) => { + activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState; + }, + setLastProgressAtMs: (value) => { + jellyfinRemoteLastProgressAtMs = value; + }, + reportPlaying: (payload) => { + void appState.jellyfinRemoteSession?.reportPlaying(payload); + }, + showMpvOsd: (text) => { + showMpvOsd(text); + }, + }, + remoteComposerOptions: { + getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()), + logWarn: (message) => logger.warn(message), + getMpvClient: () => appState.mpvClient, + sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command), + jellyfinTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), + getActivePlayback: () => activeJellyfinRemotePlayback, + clearActivePlayback: () => { + activeJellyfinRemotePlayback = null; + }, + getSession: () => appState.jellyfinRemoteSession, + getNow: () => Date.now(), + getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs, + setLastProgressAtMs: (value) => { + jellyfinRemoteLastProgressAtMs = value; + }, + progressIntervalMs: JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS, + ticksPerSecond: JELLYFIN_TICKS_PER_SECOND, + logDebug: (message, error) => logger.debug(message, error), + }, + handleJellyfinAuthCommandsMainDeps: { + patchRawConfig: (patch) => { + configService.patchRawConfig(patch); + }, + authenticateWithPassword: (serverUrl, username, password, clientInfo) => + authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo), + saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), + clearStoredSession: () => jellyfinTokenStore.clearSession(), + logInfo: (message) => logger.info(message), + }, + handleJellyfinListCommandsMainDeps: { + listJellyfinLibraries: (session, clientInfo) => + listJellyfinLibrariesRuntime(session, clientInfo), + listJellyfinItems: (session, clientInfo, params) => + listJellyfinItemsRuntime(session, clientInfo, params), + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => + listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId), + logInfo: (message) => logger.info(message), + }, + handleJellyfinPlayCommandMainDeps: { + logWarn: (message) => logger.warn(message), + }, + handleJellyfinRemoteAnnounceCommandMainDeps: { + getRemoteSession: () => appState.jellyfinRemoteSession, + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + }, + startJellyfinRemoteSessionMainDeps: { + getCurrentSession: () => appState.jellyfinRemoteSession, + setCurrentSession: (session) => { + appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; + }, + createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), + defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, + defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, + defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, + logInfo: (message) => logger.info(message), + logWarn: (message, details) => logger.warn(message, details), + }, + stopJellyfinRemoteSessionMainDeps: { + getCurrentSession: () => appState.jellyfinRemoteSession, + setCurrentSession: (session) => { + appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; + }, + clearActivePlayback: () => { + activeJellyfinRemotePlayback = null; + }, + }, + runJellyfinCommandMainDeps: { + defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, + }, + maybeFocusExistingJellyfinSetupWindowMainDeps: { + getSetupWindow: () => appState.jellyfinSetupWindow, + }, + openJellyfinSetupWindowMainDeps: { + createSetupWindow: () => + new BrowserWindow({ + width: 520, + height: 560, + title: 'Jellyfin Setup', + show: true, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }), + buildSetupFormHtml: (defaultServer, defaultUser) => + buildJellyfinSetupFormHtml(defaultServer, defaultUser), + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: (server, username, password, clientInfo) => + authenticateWithPasswordRuntime(server, username, password, clientInfo), + saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), + patchJellyfinConfig: (session) => { + configService.patchRawConfig({ + jellyfin: { + enabled: true, + serverUrl: session.serverUrl, + username: session.username, + }, + }); + }, + logInfo: (message) => logger.info(message), + logError: (message, error) => logger.error(message, error), + showMpvOsd: (message) => showMpvOsd(message), + clearSetupWindow: () => { + appState.jellyfinSetupWindow = null; + }, + setSetupWindow: (window) => { + appState.jellyfinSetupWindow = window as BrowserWindow; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + }, +}); + +const { + notifyAnilistSetup, + consumeAnilistSetupTokenFromUrl, + handleAnilistSetupProtocolUrl, + registerSubminerProtocolClient, +} = composeAnilistSetupHandlers({ + notifyDeps: { + hasMpvClient: () => Boolean(appState.mpvClient), + showMpvOsd: (message) => showMpvOsd(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + logInfo: (message) => logger.info(message), + }, + consumeTokenDeps: { + consumeAnilistSetupCallbackUrl, + saveToken: (token) => anilistTokenStore.saveToken(token), + setCachedToken: (token) => { + anilistCachedAccessToken = token; + }, + setResolvedState: (resolvedAt) => { + anilistStateRuntime.setClientSecretState({ + status: 'resolved', + source: 'stored', + message: 'saved token from AniList login', + resolvedAt, + errorAt: null, + }); + }, + setSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + onSuccess: () => { + notifyAnilistSetup('AniList login success'); + }, + closeWindow: () => { + if (appState.anilistSetupWindow && !appState.anilistSetupWindow.isDestroyed()) { + appState.anilistSetupWindow.close(); + } + }, + }, + handleProtocolDeps: { + consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), + logWarn: (message, details) => logger.warn(message, details), + }, + registerProtocolClientDeps: { + isDefaultApp: () => Boolean(process.defaultApp), + getArgv: () => process.argv, + execPath: process.execPath, + resolvePath: (value) => path.resolve(value), + setAsDefaultProtocolClient: (scheme, appPath, args) => + appPath + ? app.setAsDefaultProtocolClient(scheme, appPath, args) + : app.setAsDefaultProtocolClient(scheme), + logWarn: (message, details) => logger.warn(message, details), + }, +}); + +const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({ + getSetupWindow: () => appState.anilistSetupWindow, +}); +const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWindowMainDepsHandler( + { + maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow, + createSetupWindow: () => + new BrowserWindow({ + width: 1000, + height: 760, + title: 'Anilist Setup', + show: true, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }), + buildAuthorizeUrl: () => + buildAnilistSetupUrl({ + authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, + clientId: ANILIST_DEFAULT_CLIENT_ID, + responseType: ANILIST_SETUP_RESPONSE_TYPE, + }), + consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), + openSetupInBrowser: (authorizeUrl) => + openAnilistSetupInBrowser({ + authorizeUrl, + openExternal: (url) => shell.openExternal(url), + logError: (message, error) => logger.error(message, error), + }), + loadManualTokenEntry: (setupWindow, authorizeUrl) => + loadAnilistManualTokenEntry({ + setupWindow: setupWindow as BrowserWindow, + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + logWarn: (message, data) => logger.warn(message, data), + }), + redirectUri: ANILIST_REDIRECT_URI, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url), + isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url), + logWarn: (message, details) => logger.warn(message, details), + logError: (message, details) => logger.error(message, details), + clearSetupWindow: () => { + appState.anilistSetupWindow = null; + }, + setSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + setSetupWindow: (setupWindow) => { + appState.anilistSetupWindow = setupWindow as BrowserWindow; + }, + openExternal: (url) => { + void shell.openExternal(url); + }, + }, +); + +function openAnilistSetupWindow(): void { + createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(); +} + +const { + refreshAnilistClientSecretState, + getCurrentAnilistMediaKey, + resetAnilistMediaTracking, + getAnilistMediaGuessRuntimeState, + setAnilistMediaGuessRuntimeState, + resetAnilistMediaGuessState, + maybeProbeAnilistDuration, + ensureAnilistMediaGuess, + processNextAnilistRetryUpdate, + maybeRunAnilistPostWatchUpdate, +} = composeAnilistTrackingHandlers({ + refreshClientSecretMainDeps: { + getResolvedConfig: () => getResolvedConfig(), + isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), + getCachedAccessToken: () => anilistCachedAccessToken, + setCachedAccessToken: (token) => { + anilistCachedAccessToken = token; + }, + saveStoredToken: (token) => { + anilistTokenStore.saveToken(token); + }, + loadStoredToken: () => anilistTokenStore.loadToken(), + setClientSecretState: (state) => { + anilistStateRuntime.setClientSecretState(state); + }, + getAnilistSetupPageOpened: () => appState.anilistSetupPageOpened, + setAnilistSetupPageOpened: (opened) => { + appState.anilistSetupPageOpened = opened; + }, + openAnilistSetupWindow: () => { + openAnilistSetupWindow(); + }, + now: () => Date.now(), + }, + getCurrentMediaKeyMainDeps: { + getCurrentMediaPath: () => appState.currentMediaPath, + }, + resetMediaTrackingMainDeps: { + setMediaKey: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaKey: value }, + ); + }, + setMediaDurationSec: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaDurationSec: value }, + ); + }, + setMediaGuess: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuess: value }, + ); + }, + setMediaGuessPromise: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuessPromise: value }, + ); + }, + setLastDurationProbeAtMs: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { lastDurationProbeAtMs: value }, + ); + }, + }, + getMediaGuessRuntimeStateMainDeps: { + getMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, + getMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec, + getMediaGuess: () => anilistMediaGuessRuntimeState.mediaGuess, + getMediaGuessPromise: () => anilistMediaGuessRuntimeState.mediaGuessPromise, + getLastDurationProbeAtMs: () => anilistMediaGuessRuntimeState.lastDurationProbeAtMs, + }, + setMediaGuessRuntimeStateMainDeps: { + setMediaKey: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaKey: value }, + ); + }, + setMediaDurationSec: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaDurationSec: value }, + ); + }, + setMediaGuess: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuess: value }, + ); + }, + setMediaGuessPromise: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuessPromise: value }, + ); + }, + setLastDurationProbeAtMs: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { lastDurationProbeAtMs: value }, + ); + }, + }, + resetMediaGuessStateMainDeps: { + setMediaGuess: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuess: value }, + ); + }, + setMediaGuessPromise: (value) => { + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuessPromise: value }, + ); + }, + }, + maybeProbeDurationMainDeps: { + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + durationRetryIntervalMs: ANILIST_DURATION_RETRY_INTERVAL_MS, + now: () => Date.now(), + requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'), + logWarn: (message, error) => logger.warn(message, error), + }, + ensureMediaGuessMainDeps: { + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + resolveMediaPathForJimaku: (currentMediaPath) => + mediaRuntime.resolveMediaPathForJimaku(currentMediaPath), + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentMediaTitle: () => appState.currentMediaTitle, + guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), + }, + processNextRetryUpdateMainDeps: { + nextReady: () => anilistUpdateQueue.nextReady(), + refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), + setLastAttemptAt: (value) => { + appState.anilistRetryQueueState = transitionAnilistRetryQueueLastAttemptAt( + appState.anilistRetryQueueState, + value, + ); + }, + setLastError: (value) => { + appState.anilistRetryQueueState = transitionAnilistRetryQueueLastError( + appState.anilistRetryQueueState, + value, + ); + }, + refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), + updateAnilistPostWatchProgress: (accessToken, title, episode) => + updateAnilistPostWatchProgress(accessToken, title, episode), + markSuccess: (key) => { + anilistUpdateQueue.markSuccess(key); + }, + rememberAttemptedUpdateKey: (key) => { + rememberAnilistAttemptedUpdate(key); + }, + markFailure: (key, message) => { + anilistUpdateQueue.markFailure(key, message); + }, + logInfo: (message) => logger.info(message), + now: () => Date.now(), + }, + maybeRunPostWatchUpdateMainDeps: { + getInFlight: () => anilistUpdateInFlightState.inFlight, + setInFlight: (value) => { + anilistUpdateInFlightState = transitionAnilistUpdateInFlightState( + anilistUpdateInFlightState, + value, + ); + }, + getResolvedConfig: () => getResolvedConfig(), + isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), + getCurrentMediaKey: () => getCurrentAnilistMediaKey(), + hasMpvClient: () => Boolean(appState.mpvClient), + getTrackedMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, + resetTrackedMedia: (mediaKey) => { + resetAnilistMediaTracking(mediaKey); + }, + getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN, + maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey), + hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), + enqueueRetry: (key, title, episode) => { + anilistUpdateQueue.enqueue(key, title, episode); + }, + markRetryFailure: (key, message) => { + anilistUpdateQueue.markFailure(key, message); + }, + markRetrySuccess: (key) => { + anilistUpdateQueue.markSuccess(key); + }, + refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), + updateAnilistPostWatchProgress: (accessToken, title, episode) => + updateAnilistPostWatchProgress(accessToken, title, episode), + rememberAttemptedUpdateKey: (key) => { + rememberAnilistAttemptedUpdate(key); + }, + showMpvOsd: (message) => showMpvOsd(message), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, + minWatchRatio: ANILIST_UPDATE_MIN_WATCH_RATIO, + }, +}); + +const rememberAnilistAttemptedUpdate = (key: string): void => { + rememberAnilistAttemptedUpdateKey( + anilistAttemptedUpdateKeys, + key, + ANILIST_MAX_ATTEMPTED_UPDATE_KEYS, + ); +}; + +const buildLoadSubtitlePositionMainDepsHandler = createBuildLoadSubtitlePositionMainDepsHandler({ + loadSubtitlePositionCore: () => + loadSubtitlePositionCore({ + currentMediaPath: appState.currentMediaPath, + fallbackPosition: getResolvedConfig().subtitlePosition, + subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, + }), + setSubtitlePosition: (position) => { + appState.subtitlePosition = position; + }, +}); +const loadSubtitlePositionMainDeps = buildLoadSubtitlePositionMainDepsHandler(); +const loadSubtitlePosition = createLoadSubtitlePositionHandler(loadSubtitlePositionMainDeps); + +const buildSaveSubtitlePositionMainDepsHandler = createBuildSaveSubtitlePositionMainDepsHandler({ + saveSubtitlePositionCore: (position) => { + saveSubtitlePositionCore({ + position, + currentMediaPath: appState.currentMediaPath, + subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, + onQueuePending: (queued) => { + appState.pendingSubtitlePosition = queued; + }, + onPersisted: () => { + appState.pendingSubtitlePosition = null; + }, + }); + }, + setSubtitlePosition: (position) => { + appState.subtitlePosition = position; + }, +}); +const saveSubtitlePositionMainDeps = buildSaveSubtitlePositionMainDepsHandler(); +const saveSubtitlePosition = createSaveSubtitlePositionHandler(saveSubtitlePositionMainDeps); + +registerSubminerProtocolClient(); +let flushPendingMpvLogWrites = (): void => {}; +const { + registerProtocolUrlHandlers: registerProtocolUrlHandlersHandler, + onWillQuitCleanup: onWillQuitCleanupHandler, + shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler, + restoreWindowsOnActivate: restoreWindowsOnActivateHandler, +} = composeStartupLifecycleHandlers({ + registerProtocolUrlHandlersMainDeps: { + registerOpenUrl: (listener) => { + app.on('open-url', listener); + }, + registerSecondInstance: (listener) => { + app.on('second-instance', listener); + }, + handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl), + findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv), + logUnhandledOpenUrl: (rawUrl) => { + logger.warn('Unhandled app protocol URL', { rawUrl }); + }, + logUnhandledSecondInstanceUrl: (rawUrl) => { + logger.warn('Unhandled second-instance protocol URL', { rawUrl }); + }, + }, + onWillQuitCleanupMainDeps: { + destroyTray: () => destroyTray(), + stopConfigHotReload: () => configHotReloadRuntime.stop(), + restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), + unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), + stopSubtitleWebsocket: () => subtitleWsService.stop(), + stopTexthookerService: () => texthookerService.stop(), + getYomitanParserWindow: () => appState.yomitanParserWindow, + clearYomitanParserState: () => { + appState.yomitanParserWindow = null; + appState.yomitanParserReadyPromise = null; + appState.yomitanParserInitPromise = null; + }, + getWindowTracker: () => appState.windowTracker, + flushMpvLog: () => flushPendingMpvLogWrites(), + getMpvSocket: () => appState.mpvClient?.socket ?? null, + getReconnectTimer: () => appState.reconnectTimer, + clearReconnectTimerRef: () => { + appState.reconnectTimer = null; + }, + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getImmersionTracker: () => appState.immersionTracker, + clearImmersionTracker: () => { + appState.immersionTracker = null; + }, + getAnkiIntegration: () => appState.ankiIntegration, + getAnilistSetupWindow: () => appState.anilistSetupWindow, + clearAnilistSetupWindow: () => { + appState.anilistSetupWindow = null; + }, + getJellyfinSetupWindow: () => appState.jellyfinSetupWindow, + clearJellyfinSetupWindow: () => { + appState.jellyfinSetupWindow = null; + }, + stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), + stopDiscordPresenceService: () => { + void appState.discordPresenceService?.stop(); + appState.discordPresenceService = null; + }, + }, + shouldRestoreWindowsOnActivateMainDeps: { + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + getAllWindowCount: () => BrowserWindow.getAllWindows().length, + }, + restoreWindowsOnActivateMainDeps: { + createMainWindow: () => { + createMainWindow(); + }, + createInvisibleWindow: () => { + createInvisibleWindow(); + }, + updateVisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + updateInvisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); + }, + }, +}); +registerProtocolUrlHandlersHandler(); + +const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppReadyRuntime({ + reloadConfigMainDeps: { + reloadConfigStrict: () => configService.reloadConfigStrict(), + logInfo: (message) => appLogger.logInfo(message), + logWarning: (message) => appLogger.logWarning(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + startConfigHotReload: () => configHotReloadRuntime.start(), + refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options), + failHandlers: { + logError: (details) => logger.error(details), + showErrorBox: (title, details) => dialog.showErrorBox(title, details), + quit: () => app.quit(), + }, + }, + criticalConfigErrorMainDeps: { + getConfigPath: () => configService.getConfigPath(), + failHandlers: { + logError: (message) => logger.error(message), + showErrorBox: (title, message) => dialog.showErrorBox(title, message), + quit: () => app.quit(), + }, + }, + appReadyRuntimeMainDeps: { + loadSubtitlePosition: () => loadSubtitlePosition(), + resolveKeybindings: () => { + appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); + }, + createMpvClient: () => { + appState.mpvClient = createMpvClientRuntimeService(); + }, + getResolvedConfig: () => getResolvedConfig(), + getConfigWarnings: () => configService.getWarnings(), + logConfigWarning: (warning) => appLogger.logConfigWarning(warning), + setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source), + initRuntimeOptionsManager: () => { + appState.runtimeOptionsManager = new RuntimeOptionsManager( + () => configService.getConfig().ankiConnect, + { + applyAnkiPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + onOptionsChanged: () => { + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, + }, + ); + }, + setSecondarySubMode: (mode: SecondarySubMode) => { + appState.secondarySubMode = mode; + syncSecondaryOverlayWindowVisibility(); + }, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, + hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), + startSubtitleWebsocket: (port: number) => { + subtitleWsService.start(port, () => appState.currentSubText); + }, + log: (message) => appLogger.logInfo(message), + createMecabTokenizerAndCheck: async () => { + await createMecabTokenizerAndCheck(); + }, + createSubtitleTimingTracker: () => { + const tracker = new SubtitleTimingTracker(); + appState.subtitleTimingTracker = tracker; + }, + loadYomitanExtension: async () => { + await loadYomitanExtension(); + }, + startJellyfinRemoteSession: async () => { + await startJellyfinRemoteSession(); + }, + prewarmSubtitleDictionaries: async () => { + await prewarmSubtitleDictionaries(); + }, + startBackgroundWarmups: () => { + startBackgroundWarmups(); + }, + texthookerOnlyMode: appState.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + appState.backgroundMode + ? false + : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + handleInitialArgs: () => handleInitialArgs(), + logDebug: (message: string) => { + logger.debug(message); + }, + now: () => Date.now(), + }, + immersionTrackerStartupMainDeps: { + getResolvedConfig: () => getResolvedConfig(), + getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), + createTrackerService: (params) => new ImmersionTrackerService(params), + setTracker: (tracker) => { + appState.immersionTracker = tracker as ImmersionTrackerService | null; + }, + getMpvClient: () => appState.mpvClient, + seedTrackerFromCurrentMedia: () => { + void immersionMediaRuntime.seedFromCurrentMedia(); + }, + logInfo: (message) => logger.info(message), + logDebug: (message) => logger.debug(message), + logWarn: (message, details) => logger.warn(message, details), + }, +}); + +const { appLifecycleRuntimeRunner, runAndApplyStartupState } = + runtimeRegistry.startup.createStartupRuntimeHandlers< + CliArgs, + StartupState, + ReturnType + >({ + appLifecycleRuntimeRunnerMainDeps: { + app, + platform: process.platform, + shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), + parseArgs: (argv: string[]) => parseArgs(argv), + handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => + handleCliCommand(nextArgs, source), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + logNoRunningInstance: () => appLogger.logNoRunningInstance(), + onReady: appReadyRuntimeRunner, + onWillQuitCleanup: () => onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), + shouldQuitOnWindowAllClosed: () => !appState.backgroundMode, + }, + createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params), + buildStartupBootstrapMainDeps: (startAppLifecycle) => ({ + argv: process.argv, + parseArgs: (argv: string[]) => parseArgs(argv), + setLogLevel: (level: string, source: LogLevelSource) => { + setLogLevel(level, source); + }, + forceX11Backend: (args: CliArgs) => { + forceX11Backend(args); + }, + enforceUnsupportedWaylandMode: (args: CliArgs) => { + enforceUnsupportedWaylandMode(args); + }, + shouldStartApp: (args: CliArgs) => shouldStartApp(args), + getDefaultSocketPath: () => getDefaultSocketPath(), + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, + configDir: CONFIG_DIR, + defaultConfig: DEFAULT_CONFIG, + generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config), + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => generateDefaultConfigFile(args, options), + setExitCode: (code) => { + process.exitCode = code; + }, + quitApp: () => app.quit(), + logGenerateConfigError: (message) => logger.error(message), + startAppLifecycle, + }), + createStartupBootstrapRuntimeDeps: (deps) => createStartupBootstrapRuntimeDeps(deps), + runStartupBootstrapRuntime, + applyStartupState: (startupState) => applyStartupState(appState, startupState), + }); + +runAndApplyStartupState(); +void refreshAnilistClientSecretState({ force: true }); +anilistStateRuntime.refreshRetryQueueState(); +void initializeDiscordPresenceService(); + +const handleCliCommand = createCliCommandRuntimeHandler({ + handleTexthookerOnlyModeTransitionMainDeps: { + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + setTexthookerOnlyMode: (enabled) => { + appState.texthookerOnlyMode = enabled; + }, + commandNeedsOverlayRuntime: (inputArgs) => commandNeedsOverlayRuntime(inputArgs), + startBackgroundWarmups: () => startBackgroundWarmups(), + logInfo: (message: string) => logger.info(message), + }, + createCliCommandContext: () => createCliCommandContextHandler(), + handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => + handleCliCommandRuntimeServiceWithContext(args, source, cliContext), +}); + +const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({ + getInitialArgs: () => appState.initialArgs, + isBackgroundMode: () => appState.backgroundMode, + ensureTray: () => ensureTray(), + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + hasImmersionTracker: () => Boolean(appState.immersionTracker), + getMpvClient: () => appState.mpvClient, + logInfo: (message) => logger.info(message), + handleCliCommand: (args, source) => handleCliCommand(args, source), +}); + +function handleInitialArgs(): void { + handleInitialArgsRuntimeHandler(); +} + +const { + bindMpvClientEventHandlers, + createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, + updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, + tokenizeSubtitle, + createMecabTokenizerAndCheck, + prewarmSubtitleDictionaries, + startBackgroundWarmups, +} = composeMpvRuntimeHandlers< + MpvIpcClient, + ReturnType, + SubtitleData +>({ + bindMpvMainEventHandlersMainDeps: { + appState, + getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed, + scheduleQuitCheck: (callback) => { + setTimeout(callback, 500); + }, + quitApp: () => app.quit(), + reportJellyfinRemoteStopped: () => { + void reportJellyfinRemoteStopped(); + }, + maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(), + logSubtitleTimingError: (message, error) => logger.error(message, error), + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); + }, + onSubtitleChange: (text) => { + subtitleProcessingController.onSubtitleChange(text); + }, + refreshDiscordPresence: () => { + publishDiscordPresence(); + }, + updateCurrentMediaPath: (path) => { + mediaRuntime.updateCurrentMediaPath(path); + }, + getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => { + resetAnilistMediaTracking(mediaKey); + }, + maybeProbeAnilistDuration: (mediaKey) => { + void maybeProbeAnilistDuration(mediaKey); + }, + ensureAnilistMediaGuess: (mediaKey) => { + void ensureAnilistMediaGuess(mediaKey); + }, + syncImmersionMediaState: () => { + immersionMediaRuntime.syncFromCurrentMediaState(); + }, + updateCurrentMediaTitle: (title) => { + mediaRuntime.updateCurrentMediaTitle(title); + }, + resetAnilistMediaGuessState: () => { + resetAnilistMediaGuessState(); + }, + reportJellyfinRemoteProgress: (forceImmediate) => { + void reportJellyfinRemoteProgress(forceImmediate); + }, + updateSubtitleRenderMetrics: (patch) => { + updateMpvSubtitleRenderMetrics(patch as Partial); + }, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: MpvIpcClient, + getSocketPath: () => appState.mpvSocketPath, + getResolvedConfig: () => getResolvedConfig(), + isAutoStartOverlayEnabled: () => appState.autoStartOverlay, + setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), + shouldBindVisibleOverlayToMpvSubVisibility: () => + configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getReconnectTimer: () => appState.reconnectTimer, + setReconnectTimer: (timer: ReturnType | null) => { + appState.reconnectTimer = timer; + }, + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, + setCurrentMetrics: (metrics) => { + appState.mpvSubtitleRenderMetrics = metrics; + }, + applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), + broadcastMetrics: (metrics) => { + broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics); + }, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => appState.yomitanExt, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window as BrowserWindow | null; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)), + recordLookup: (hit) => { + appState.immersionTracker?.recordLookup(hit); + }, + getKnownWordMatchMode: () => + appState.ankiIntegration?.getKnownWordMatchMode() ?? + getResolvedConfig().ankiConnect.nPlusOne.matchMode, + getMinSentenceWordsForNPlusOne: () => + getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, + getJlptLevel: (text) => appState.jlptLevelLookup(text), + getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, + getFrequencyDictionaryEnabled: () => + getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + getFrequencyRank: (text) => appState.frequencyRankLookup(text), + getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, + getMecabTokenizer: () => appState.mecabTokenizer, + }, + createTokenizerRuntimeDeps: (deps) => + createTokenizerDepsRuntime(deps as Parameters[0]), + tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps), + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => appState.mecabTokenizer, + setMecabTokenizer: (tokenizer) => { + appState.mecabTokenizer = tokenizer as MecabTokenizer | null; + }, + createMecabTokenizer: () => new MecabTokenizer(), + checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(), + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => + frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => Date.now(), + logDebug: (message) => logger.debug(message), + logWarn: (message) => logger.warn(message), + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => backgroundWarmupsStarted, + setStarted: (started) => { + backgroundWarmupsStarted = started; + }, + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), + shouldAutoConnectJellyfinRemote: () => { + const jellyfin = getResolvedConfig().jellyfin; + return ( + jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect + ); + }, + startJellyfinRemoteSession: () => startJellyfinRemoteSession(), + }, + }, +}); + +function createMpvClientRuntimeService(): MpvIpcClient { + return createMpvClientRuntimeServiceHandler() as MpvIpcClient; +} + +function updateMpvSubtitleRenderMetrics(patch: Partial): void { + updateMpvSubtitleRenderMetricsHandler(patch); +} + +let lastOverlayWindowGeometry: WindowGeometry | null = null; + +function getOverlayGeometryFallback(): WindowGeometry { + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const bounds = display.workArea; + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; +} + +function getCurrentOverlayGeometry(): WindowGeometry { + if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry; + const trackerGeometry = appState.windowTracker?.getGeometry(); + if (trackerGeometry) return trackerGeometry; + return getOverlayGeometryFallback(); +} + +function syncSecondaryOverlayWindowVisibility(): void { + const secondaryWindow = overlayManager.getSecondaryWindow(); + if (!secondaryWindow || secondaryWindow.isDestroyed()) return; + + if (appState.secondarySubMode === 'hidden') { + secondaryWindow.setIgnoreMouseEvents(true, { forward: true }); + secondaryWindow.hide(); + return; + } + + secondaryWindow.setIgnoreMouseEvents(false); + ensureOverlayWindowLevel(secondaryWindow); + if (typeof secondaryWindow.showInactive === 'function') { + secondaryWindow.showInactive(); + } else { + secondaryWindow.show(); + } +} + +function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeometry): void { + lastOverlayWindowGeometry = geometry; + const regions = splitOverlayGeometryForSecondaryBar(geometry); + overlayManager.setOverlayWindowBounds(layer, regions.primary); + overlayManager.setSecondaryWindowBounds(regions.secondary); + syncSecondaryOverlayWindowVisibility(); +} + +const buildUpdateVisibleOverlayBoundsMainDepsHandler = + createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ + setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), + }); +const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); +const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( + updateVisibleOverlayBoundsMainDeps, +); + +const buildUpdateInvisibleOverlayBoundsMainDepsHandler = + createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({ + setOverlayWindowBounds: (layer, geometry) => applyOverlayRegions(layer, geometry), + }); +const updateInvisibleOverlayBoundsMainDeps = buildUpdateInvisibleOverlayBoundsMainDepsHandler(); +const updateInvisibleOverlayBounds = createUpdateInvisibleOverlayBoundsHandler( + updateInvisibleOverlayBoundsMainDeps, +); + +const buildEnsureOverlayWindowLevelMainDepsHandler = + createBuildEnsureOverlayWindowLevelMainDepsHandler({ + ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), + }); +const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); +const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( + ensureOverlayWindowLevelMainDeps, +); + +const buildEnforceOverlayLayerOrderMainDepsHandler = + createBuildEnforceOverlayLayerOrderMainDepsHandler({ + enforceOverlayLayerOrderCore: (params) => + enforceOverlayLayerOrderCore({ + visibleOverlayVisible: params.visibleOverlayVisible, + invisibleOverlayVisible: params.invisibleOverlayVisible, + mainWindow: params.mainWindow as BrowserWindow | null, + invisibleWindow: params.invisibleWindow as BrowserWindow | null, + ensureOverlayWindowLevel: (window) => + params.ensureOverlayWindowLevel(window as BrowserWindow), + }), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), + }); +const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); +const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( + enforceOverlayLayerOrderMainDeps, +); + +async function loadYomitanExtension(): Promise { + return yomitanExtensionRuntime.loadYomitanExtension(); +} + +async function ensureYomitanExtensionLoaded(): Promise { + return yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); +} + +function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary'): BrowserWindow { + return createOverlayWindowHandler(kind); +} + +function createSecondaryWindow(): BrowserWindow { + const existingWindow = overlayManager.getSecondaryWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + return existingWindow; + } + const window = createSecondaryWindowHandler(); + applyOverlayRegions('visible', getCurrentOverlayGeometry()); + return window; +} + +function createMainWindow(): BrowserWindow { + const window = createMainWindowHandler(); + createSecondaryWindow(); + return window; +} +function createInvisibleWindow(): BrowserWindow { + return createInvisibleWindowHandler(); +} + +function resolveTrayIconPath(): string | null { + return resolveTrayIconPathHandler(); +} + +function buildTrayMenu(): Menu { + return buildTrayMenuHandler(); +} + +function ensureTray(): void { + ensureTrayHandler(); +} + +function destroyTray(): void { + destroyTrayHandler(); +} + +function initializeOverlayRuntime(): void { + initializeOverlayRuntimeHandler(); +} + +function openYomitanSettings(): void { + openYomitanSettingsHandler(); +} + +const { + getConfiguredShortcuts, + registerGlobalShortcuts, + refreshGlobalAndOverlayShortcuts, + cancelPendingMultiCopy, + startPendingMultiCopy, + cancelPendingMineSentenceMultiple, + startPendingMineSentenceMultiple, + registerOverlayShortcuts, + unregisterOverlayShortcuts, + syncOverlayShortcuts, + refreshOverlayShortcuts, +} = composeShortcutRuntimes({ + globalShortcuts: { + getConfiguredShortcutsMainDeps: { + getResolvedConfig: () => getResolvedConfig(), + defaultConfig: DEFAULT_CONFIG, + resolveConfiguredShortcuts, + }, + buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({ + getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), + registerGlobalShortcutsCore, + toggleVisibleOverlay: () => toggleVisibleOverlay(), + toggleInvisibleOverlay: () => toggleInvisibleOverlay(), + openYomitanSettings: () => openYomitanSettings(), + isDev, + getMainWindow: () => overlayManager.getMainWindow(), + }), + buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({ + unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), + registerGlobalShortcuts: () => registerGlobalShortcutsHandler(), + syncOverlayShortcuts: () => syncOverlayShortcuts(), + }), + }, + numericShortcutRuntimeMainDeps: { + globalShortcut, + showMpvOsd: (text) => showMpvOsd(text), + setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), + clearTimer: (timer) => clearTimeout(timer), + }, + numericSessions: { + onMultiCopyDigit: (count) => handleMultiCopyDigit(count), + onMineSentenceDigit: (count) => handleMineSentenceDigit(count), + }, + overlayShortcutsRuntimeMainDeps: { + overlayShortcutsRuntime, + }, +}); + +const { appendToMpvLog, flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ + appendToMpvLogMainDeps: { + logPath: DEFAULT_MPV_LOG_PATH, + dirname: (targetPath) => path.dirname(targetPath), + mkdir: async (targetPath, options) => { + await fs.promises.mkdir(targetPath, options); + }, + appendFile: async (targetPath, data, options) => { + await fs.promises.appendFile(targetPath, data, options); + }, + now: () => new Date(), + }, + buildShowMpvOsdMainDeps: (appendToMpvLogHandler) => ({ + appendToMpvLog: (message) => appendToMpvLogHandler(message), + showMpvOsdRuntime: (mpvClient, text, fallbackLog) => + showMpvOsdRuntime(mpvClient, text, fallbackLog), + getMpvClient: () => appState.mpvClient, + logInfo: (line) => logger.info(line), + }), +}); +flushPendingMpvLogWrites = () => { + void flushMpvLog(); +}; + +const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ + cycleSecondarySubModeMainDeps: { + getSecondarySubMode: () => appState.secondarySubMode, + setSecondarySubMode: (mode: SecondarySubMode) => { + appState.secondarySubMode = mode; + syncSecondaryOverlayWindowVisibility(); + }, + getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, + setLastSecondarySubToggleAtMs: (timestampMs: number) => { + appState.lastSecondarySubToggleAtMs = timestampMs; + }, + broadcastToOverlayWindows: (channel, mode) => { + broadcastToOverlayWindows(channel, mode); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + }, + cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), +}); + +async function triggerSubsyncFromConfig(): Promise { + await subsyncRuntime.triggerFromConfig(); +} + +function handleMultiCopyDigit(count: number): void { + handleMultiCopyDigitHandler(count); +} + +function copyCurrentSubtitle(): void { + copyCurrentSubtitleHandler(); +} + +const buildUpdateLastCardFromClipboardMainDepsHandler = + createBuildUpdateLastCardFromClipboardMainDepsHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + readClipboardText: () => clipboard.readText(), + showMpvOsd: (text) => showMpvOsd(text), + updateLastCardFromClipboardCore, + }); +const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler(); +const updateLastCardFromClipboardHandler = createUpdateLastCardFromClipboardHandler( + updateLastCardFromClipboardMainDeps, +); + +const buildRefreshKnownWordCacheMainDepsHandler = createBuildRefreshKnownWordCacheMainDepsHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + missingIntegrationMessage: 'AnkiConnect integration not enabled', +}); +const refreshKnownWordCacheMainDeps = buildRefreshKnownWordCacheMainDepsHandler(); +const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler( + refreshKnownWordCacheMainDeps, +); + +const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + showMpvOsd: (text) => showMpvOsd(text), + triggerFieldGroupingCore, +}); +const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler(); +const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFieldGroupingMainDeps); + +const buildMarkLastCardAsAudioCardMainDepsHandler = + createBuildMarkLastCardAsAudioCardMainDepsHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + showMpvOsd: (text) => showMpvOsd(text), + markLastCardAsAudioCardCore, + }); +const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler(); +const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler( + markLastCardAsAudioCardMainDeps, +); + +const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + getMpvClient: () => appState.mpvClient, + showMpvOsd: (text) => showMpvOsd(text), + mineSentenceCardCore, + recordCardsMined: (count) => { + appState.immersionTracker?.recordCardsMined(count); + }, +}); +const mineSentenceCardHandler = createMineSentenceCardHandler( + buildMineSentenceCardMainDepsHandler(), +); + +const buildHandleMultiCopyDigitMainDepsHandler = createBuildHandleMultiCopyDigitMainDepsHandler({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + writeClipboardText: (text) => clipboard.writeText(text), + showMpvOsd: (text) => showMpvOsd(text), + handleMultiCopyDigitCore, +}); +const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler(); +const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler(handleMultiCopyDigitMainDeps); + +const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + writeClipboardText: (text) => clipboard.writeText(text), + showMpvOsd: (text) => showMpvOsd(text), + copyCurrentSubtitleCore, +}); +const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler(); +const copyCurrentSubtitleHandler = createCopyCurrentSubtitleHandler(copyCurrentSubtitleMainDeps); + +const buildHandleMineSentenceDigitMainDepsHandler = + createBuildHandleMineSentenceDigitMainDepsHandler({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getAnkiIntegration: () => appState.ankiIntegration, + getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, + showMpvOsd: (text) => showMpvOsd(text), + logError: (message, err) => { + logger.error(message, err); + }, + onCardsMined: (cards) => { + appState.immersionTracker?.recordCardsMined(cards); + }, + handleMineSentenceDigitCore, + }); +const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler(); +const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler( + handleMineSentenceDigitMainDeps, +); +const { + setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, + setInvisibleOverlayVisible: setInvisibleOverlayVisibleHandler, + toggleVisibleOverlay: toggleVisibleOverlayHandler, + toggleInvisibleOverlay: toggleInvisibleOverlayHandler, + setOverlayVisible: setOverlayVisibleHandler, + toggleOverlay: toggleOverlayHandler, +} = createOverlayVisibilityRuntime({ + setVisibleOverlayVisibleDeps: { + setVisibleOverlayVisibleCore, + setVisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setVisibleOverlayVisible(nextVisible); + }, + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), + isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), + setMpvSubVisibility: (mpvSubVisible) => { + setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible); + }, + }, + setInvisibleOverlayVisibleDeps: { + setInvisibleOverlayVisibleCore, + setInvisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setInvisibleOverlayVisible(nextVisible); + }, + updateInvisibleOverlayVisibility: () => + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), + }, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), +}); + +const buildHandleOverlayModalClosedMainDepsHandler = + createBuildHandleOverlayModalClosedMainDepsHandler({ + handleOverlayModalClosedRuntime: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), + }); +const handleOverlayModalClosedMainDeps = buildHandleOverlayModalClosedMainDepsHandler(); +const handleOverlayModalClosedHandler = createHandleOverlayModalClosedHandler( + handleOverlayModalClosedMainDeps, +); + +const buildAppendClipboardVideoToQueueMainDepsHandler = + createBuildAppendClipboardVideoToQueueMainDepsHandler({ + appendClipboardVideoToQueueRuntime, + getMpvClient: () => appState.mpvClient, + readClipboardText: () => clipboard.readText(), + showMpvOsd: (text) => showMpvOsd(text), + sendMpvCommand: (command) => { + sendMpvCommandRuntime(appState.mpvClient, command); + }, + }); +const appendClipboardVideoToQueueMainDeps = buildAppendClipboardVideoToQueueMainDepsHandler(); +const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHandler( + appendClipboardVideoToQueueMainDeps, +); + +const { + handleMpvCommandFromIpc: handleMpvCommandFromIpcHandler, + runSubsyncManualFromIpc: runSubsyncManualFromIpcHandler, + registerIpcRuntimeHandlers, +} = composeIpcRuntimeHandlers({ + mpvCommandMainDeps: { + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + cycleRuntimeOption: (id, direction) => { + if (!appState.runtimeOptionsManager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + return applyRuntimeOptionResultRuntime( + appState.runtimeOptionsManager.cycleOption(id, direction), + (text) => showMpvOsd(text), + ); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), + playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), + sendMpvCommand: (rawCommand: (string | number)[]) => + sendMpvCommandRuntime(appState.mpvClient, rawCommand), + isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), + hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, + }, + handleMpvCommandFromIpcRuntime, + runSubsyncManualFromIpc: (request) => subsyncRuntime.runManualFromIpc(request), + registration: { + runtimeOptions: { + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + showMpvOsd: (text: string) => showMpvOsd(text), + }, + mainDeps: { + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), + focusMainWindow: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + }, + onOverlayModalClosed: (modal) => { + handleOverlayModalClosed(modal); + }, + openYomitanSettings: () => openYomitanSettings(), + quitApp: () => app.quit(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), + getCurrentSubtitleRaw: () => appState.currentSubText, + getCurrentSubtitleAss: () => appState.currentSubAssText, + getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, + getSubtitlePosition: () => loadSubtitlePosition(), + getSubtitleStyle: () => { + const resolvedConfig = getResolvedConfig(); + return resolveSubtitleStyleForRenderer(resolvedConfig); + }, + saveSubtitlePosition: (position) => saveSubtitlePosition(position), + getMecabTokenizer: () => appState.mecabTokenizer, + getKeybindings: () => appState.keybindings, + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getSecondarySubMode: () => appState.secondarySubMode, + getMpvClient: () => appState.mpvClient, + getAnkiConnectStatus: () => appState.ankiIntegration !== null, + getRuntimeOptions: () => getRuntimeOptionsState(), + reportOverlayContentBounds: (payload: unknown) => { + overlayContentMeasurementStore.report(payload); + }, + reportHoveredSubtitleToken: (tokenIndex: number | null) => { + reportHoveredSubtitleToken(tokenIndex); + }, + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetup: () => openAnilistSetupWindow(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), + appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), + }, + ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ + patchAnkiConnectEnabled: (enabled: boolean) => { + configService.patchRawConfig({ ankiConnect: { enabled } }); + }, + getResolvedConfig: () => getResolvedConfig(), + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getMpvClient: () => appState.mpvClient, + getAnkiIntegration: () => appState.ankiIntegration, + setAnkiIntegration: (integration: AnkiIntegration | null) => { + appState.ankiIntegration = integration; + }, + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + getFieldGroupingResolver: () => getFieldGroupingResolver(), + setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => + setFieldGroupingResolver(resolver), + parseMediaInfo: (mediaPath: string | null) => + parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)), + getCurrentMediaPath: () => appState.currentMediaPath, + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ): Promise> => configDerivedRuntime.jimakuFetchJson(endpoint, query), + getJimakuMaxEntryResults: () => configDerivedRuntime.getJimakuMaxEntryResults(), + getJimakuLanguagePreference: () => configDerivedRuntime.getJimakuLanguagePreference(), + resolveJimakuApiKey: () => configDerivedRuntime.resolveJimakuApiKey(), + isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), + downloadToFile: (url: string, destPath: string, headers: Record) => + downloadToFile(url, destPath, headers), + }), + registerIpcRuntimeServices, + }, +}); +const createCliCommandContextHandler = createCliCommandContextFactory({ + appState, + texthookerService, + getResolvedConfig: () => getResolvedConfig(), + openExternal: (url: string) => shell.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => + logger.error(`Failed to open browser for texthooker URL: ${url}`, error), + showMpvOsd: (text: string) => showMpvOsd(text), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + toggleInvisibleOverlay: () => toggleInvisibleOverlay(), + setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), + setInvisibleOverlayVisible: (visible: boolean) => setInvisibleOverlayVisible(visible), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + refreshKnownWordCache: () => refreshKnownWordCache(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), + openYomitanSettings: () => openYomitanSettings(), + cycleSecondarySubMode: () => cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + stopApp: () => app.quit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), + logInfo: (message: string) => logger.info(message), + logWarn: (message: string) => logger.warn(message), + logError: (message: string, err: unknown) => logger.error(message, err), +}); +const { + createOverlayWindow: createOverlayWindowHandler, + createMainWindow: createMainWindowHandler, + createInvisibleWindow: createInvisibleWindowHandler, + createSecondaryWindow: createSecondaryWindowHandler, +} = createOverlayWindowRuntimeHandlers({ + createOverlayWindowDeps: { + createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), + isDev, + getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), + isOverlayVisible: (windowKind) => + windowKind === 'visible' + ? overlayManager.getVisibleOverlayVisible() + : windowKind === 'invisible' + ? overlayManager.getInvisibleOverlayVisible() + : false, + tryHandleOverlayShortcutLocalFallback: (input) => + overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), + onWindowClosed: (windowKind) => { + if (windowKind === 'visible') { + overlayManager.setMainWindow(null); + } else if (windowKind === 'invisible') { + overlayManager.setInvisibleWindow(null); + } else { + overlayManager.setSecondaryWindow(null); + } + }, + }, + setMainWindow: (window) => overlayManager.setMainWindow(window), + setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), + setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window), +}); +const { + resolveTrayIconPath: resolveTrayIconPathHandler, + buildTrayMenu: buildTrayMenuHandler, + ensureTray: ensureTrayHandler, + destroyTray: destroyTrayHandler, +} = createTrayRuntimeHandlers({ + resolveTrayIconPathDeps: { + resolveTrayIconPathRuntime, + platform: process.platform, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + dirname: __dirname, + joinPath: (...parts) => path.join(...parts), + fileExists: (candidate) => fs.existsSync(candidate), + }, + buildTrayMenuTemplateDeps: { + buildTrayMenuTemplateRuntime, + initializeOverlayRuntime: () => initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + openYomitanSettings: () => openYomitanSettings(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + quitApp: () => app.quit(), + }, + ensureTrayDeps: { + getTray: () => appTray, + setTray: (tray) => { + appTray = tray as Tray | null; + }, + createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath), + createEmptyImage: () => nativeImage.createEmpty(), + createTray: (icon) => new Tray(icon as ConstructorParameters[0]), + trayTooltip: TRAY_TOOLTIP, + platform: process.platform, + logWarn: (message) => logger.warn(message), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + }, + destroyTrayDeps: { + getTray: () => appTray, + setTray: (tray) => { + appTray = tray as Tray | null; + }, + }, + buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), +}); +const yomitanExtensionRuntime = createYomitanExtensionRuntime({ + loadYomitanExtensionCore, + userDataPath: USER_DATA_PATH, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window as BrowserWindow | null; + }, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extension) => { + appState.yomitanExt = extension; + }, + getYomitanExtension: () => appState.yomitanExt, + getLoadInFlight: () => yomitanLoadInFlight, + setLoadInFlight: (promise) => { + yomitanLoadInFlight = promise; + }, +}); +const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = + runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({ + initializeOverlayRuntimeMainDeps: { + appState, + overlayManager: { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => + overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + }, + getInitialInvisibleOverlayVisibility: () => + configDerivedRuntime.getInitialInvisibleOverlayVisibility(), + createMainWindow: () => createMainWindow(), + createInvisibleWindow: () => createInvisibleWindow(), + registerGlobalShortcuts: () => registerGlobalShortcuts(), + updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry), + getOverlayWindows: () => getOverlayWindows(), + getResolvedConfig: () => getResolvedConfig(), + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlayRuntimeCore, + setInvisibleOverlayVisible: (visible) => { + overlayManager.setInvisibleOverlayVisible(visible); + }, + setOverlayRuntimeInitialized: (initialized) => { + appState.overlayRuntimeInitialized = initialized; + }, + startBackgroundWarmups: () => startBackgroundWarmups(), + }, + }); +const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), + openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => { + openYomitanSettingsWindow({ + yomitanExt: yomitanExt as Extension, + getExistingWindow: () => getExistingWindow() as BrowserWindow | null, + setWindow: (window) => setWindow(window as BrowserWindow | null), + }); + }, + getExistingWindow: () => appState.yomitanSettingsWindow, + setWindow: (window) => { + appState.yomitanSettingsWindow = window as BrowserWindow | null; + }, + logWarn: (message) => logger.warn(message), + logError: (message, error) => logger.error(message, error), +}); + +async function updateLastCardFromClipboard(): Promise { + await updateLastCardFromClipboardHandler(); +} + +async function refreshKnownWordCache(): Promise { + await refreshKnownWordCacheHandler(); +} + +async function triggerFieldGrouping(): Promise { + await triggerFieldGroupingHandler(); +} + +async function markLastCardAsAudioCard(): Promise { + await markLastCardAsAudioCardHandler(); +} + +async function mineSentenceCard(): Promise { + await mineSentenceCardHandler(); +} + +function handleMineSentenceDigit(count: number): void { + handleMineSentenceDigitHandler(count); +} + +function ensureOverlayWindowsReadyForVisibilityActions(): void { + if (!appState.overlayRuntimeInitialized) { + initializeOverlayRuntime(); + return; + } + + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + createMainWindow(); + } + + const invisibleWindow = overlayManager.getInvisibleWindow(); + if (!invisibleWindow || invisibleWindow.isDestroyed()) { + createInvisibleWindow(); + } +} + +function setVisibleOverlayVisible(visible: boolean): void { + ensureOverlayWindowsReadyForVisibilityActions(); + setVisibleOverlayVisibleHandler(visible); +} + +function setInvisibleOverlayVisible(visible: boolean): void { + ensureOverlayWindowsReadyForVisibilityActions(); + setInvisibleOverlayVisibleHandler(visible); + if (visible) { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + } +} + +function toggleVisibleOverlay(): void { + ensureOverlayWindowsReadyForVisibilityActions(); + toggleVisibleOverlayHandler(); +} +function toggleInvisibleOverlay(): void { + ensureOverlayWindowsReadyForVisibilityActions(); + toggleInvisibleOverlayHandler(); +} +function setOverlayVisible(visible: boolean): void { + setOverlayVisibleHandler(visible); +} +function toggleOverlay(): void { + toggleOverlayHandler(); +} +function handleOverlayModalClosed(modal: OverlayHostedModal): void { + handleOverlayModalClosedHandler(modal); +} + +function handleMpvCommandFromIpc(command: (string | number)[]): void { + handleMpvCommandFromIpcHandler(command); +} + +function reportHoveredSubtitleToken(tokenIndex: number | null): void { + appState.hoveredSubtitleTokenIndex = tokenIndex; + applyHoveredTokenOverlay(); +} + +async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise { + return runSubsyncManualFromIpcHandler(request) as Promise; +} + +function appendClipboardVideoToQueue(): { ok: boolean; message: string } { + return appendClipboardVideoToQueueHandler(); +} + +registerIpcRuntimeHandlers(); diff --git a/src/main/anilist-url-guard.test.ts b/src/main/anilist-url-guard.test.ts new file mode 100644 index 0000000..cfaf599 --- /dev/null +++ b/src/main/anilist-url-guard.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + isAllowedAnilistExternalUrl, + isAllowedAnilistSetupNavigationUrl, +} from './anilist-url-guard'; + +test('allows only AniList https URLs for external opens', () => { + assert.equal(isAllowedAnilistExternalUrl('https://anilist.co'), true); + assert.equal(isAllowedAnilistExternalUrl('https://www.anilist.co/settings/developer'), true); + assert.equal(isAllowedAnilistExternalUrl('http://anilist.co'), false); + assert.equal(isAllowedAnilistExternalUrl('https://example.com'), false); + assert.equal(isAllowedAnilistExternalUrl('file:///tmp/test'), false); + assert.equal(isAllowedAnilistExternalUrl('not a url'), false); +}); + +test('allows only AniList https or data URLs for setup navigation', () => { + assert.equal( + isAllowedAnilistSetupNavigationUrl('https://anilist.co/api/v2/oauth/authorize'), + true, + ); + assert.equal( + isAllowedAnilistSetupNavigationUrl('data:text/html;charset=utf-8,%3Chtml%3E%3C%2Fhtml%3E'), + true, + ); + assert.equal(isAllowedAnilistSetupNavigationUrl('https://example.com/redirect'), false); + assert.equal(isAllowedAnilistSetupNavigationUrl('javascript:alert(1)'), false); +}); diff --git a/src/main/anilist-url-guard.ts b/src/main/anilist-url-guard.ts new file mode 100644 index 0000000..f9a80a6 --- /dev/null +++ b/src/main/anilist-url-guard.ts @@ -0,0 +1,24 @@ +const ANILIST_ALLOWED_HOSTS = new Set(['anilist.co', 'www.anilist.co']); + +export function isAllowedAnilistExternalUrl(rawUrl: string): boolean { + try { + const parsedUrl = new URL(rawUrl); + return ( + parsedUrl.protocol === 'https:' && ANILIST_ALLOWED_HOSTS.has(parsedUrl.hostname.toLowerCase()) + ); + } catch { + return false; + } +} + +export function isAllowedAnilistSetupNavigationUrl(rawUrl: string): boolean { + if (isAllowedAnilistExternalUrl(rawUrl)) { + return true; + } + try { + const parsedUrl = new URL(rawUrl); + return parsedUrl.protocol === 'data:'; + } catch { + return false; + } +} diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts new file mode 100644 index 0000000..4829220 --- /dev/null +++ b/src/main/app-lifecycle.ts @@ -0,0 +1,115 @@ +import type { CliArgs, CliCommandSource } from '../cli/args'; +import { runAppReadyRuntime } from '../core/services/startup'; +import type { AppReadyRuntimeDeps } from '../core/services/startup'; +import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle'; + +export interface AppLifecycleRuntimeDepsFactoryInput { + app: AppLifecycleDepsRuntimeOptions['app']; + platform: NodeJS.Platform; + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; +} + +export interface AppReadyRuntimeDepsFactoryInput { + loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition']; + resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings']; + createMpvClient: AppReadyRuntimeDeps['createMpvClient']; + reloadConfig: AppReadyRuntimeDeps['reloadConfig']; + getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig']; + getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings']; + logConfigWarning: AppReadyRuntimeDeps['logConfigWarning']; + initRuntimeOptionsManager: AppReadyRuntimeDeps['initRuntimeOptionsManager']; + setSecondarySubMode: AppReadyRuntimeDeps['setSecondarySubMode']; + defaultSecondarySubMode: AppReadyRuntimeDeps['defaultSecondarySubMode']; + defaultWebsocketPort: AppReadyRuntimeDeps['defaultWebsocketPort']; + hasMpvWebsocketPlugin: AppReadyRuntimeDeps['hasMpvWebsocketPlugin']; + startSubtitleWebsocket: AppReadyRuntimeDeps['startSubtitleWebsocket']; + log: AppReadyRuntimeDeps['log']; + setLogLevel: AppReadyRuntimeDeps['setLogLevel']; + createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck']; + createSubtitleTimingTracker: AppReadyRuntimeDeps['createSubtitleTimingTracker']; + createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker']; + startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession']; + loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension']; + prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries']; + startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; + texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode']; + shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig']; + initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime']; + handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs']; + onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; + logDebug?: AppReadyRuntimeDeps['logDebug']; + now?: AppReadyRuntimeDeps['now']; +} + +export function createAppLifecycleRuntimeDeps( + params: AppLifecycleRuntimeDepsFactoryInput, +): AppLifecycleDepsRuntimeOptions { + return { + app: params.app, + platform: params.platform, + shouldStartApp: params.shouldStartApp, + parseArgs: params.parseArgs, + handleCliCommand: params.handleCliCommand, + printHelp: params.printHelp, + logNoRunningInstance: params.logNoRunningInstance, + onReady: params.onReady, + onWillQuitCleanup: params.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: params.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed, + }; +} + +export function createAppReadyRuntimeDeps( + params: AppReadyRuntimeDepsFactoryInput, +): AppReadyRuntimeDeps { + return { + loadSubtitlePosition: params.loadSubtitlePosition, + resolveKeybindings: params.resolveKeybindings, + createMpvClient: params.createMpvClient, + reloadConfig: params.reloadConfig, + getResolvedConfig: params.getResolvedConfig, + getConfigWarnings: params.getConfigWarnings, + logConfigWarning: params.logConfigWarning, + initRuntimeOptionsManager: params.initRuntimeOptionsManager, + setSecondarySubMode: params.setSecondarySubMode, + defaultSecondarySubMode: params.defaultSecondarySubMode, + defaultWebsocketPort: params.defaultWebsocketPort, + hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin, + startSubtitleWebsocket: params.startSubtitleWebsocket, + log: params.log, + setLogLevel: params.setLogLevel, + createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, + createSubtitleTimingTracker: params.createSubtitleTimingTracker, + createImmersionTracker: params.createImmersionTracker, + startJellyfinRemoteSession: params.startJellyfinRemoteSession, + loadYomitanExtension: params.loadYomitanExtension, + prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries, + startBackgroundWarmups: params.startBackgroundWarmups, + texthookerOnlyMode: params.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: + params.shouldAutoInitializeOverlayRuntimeFromConfig, + initializeOverlayRuntime: params.initializeOverlayRuntime, + handleInitialArgs: params.handleInitialArgs, + onCriticalConfigErrors: params.onCriticalConfigErrors, + logDebug: params.logDebug, + now: params.now, + }; +} + +export function createAppReadyRuntimeRunner( + params: AppReadyRuntimeDepsFactoryInput, +): () => Promise { + return async () => { + await runAppReadyRuntime(createAppReadyRuntimeDeps(params)); + }; +} diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts new file mode 100644 index 0000000..29c1cc9 --- /dev/null +++ b/src/main/cli-runtime.ts @@ -0,0 +1,136 @@ +import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services'; +import type { CliArgs, CliCommandSource } from '../cli/args'; +import { + createCliCommandRuntimeServiceDeps, + CliCommandRuntimeServiceDepsParams, +} from './dependencies'; + +export interface CliCommandRuntimeServiceContext { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getClient: CliCommandRuntimeServiceDepsParams['mpv']['getClient']; + showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd']; + getTexthookerPort: () => number; + setTexthookerPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openInBrowser: (url: string) => void; + isOverlayInitialized: () => boolean; + initializeOverlay: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + setVisibleOverlay: (visible: boolean) => void; + setInvisibleOverlay: (visible: boolean) => void; + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus']; + clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken']; + openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup']; + getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus']; + retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow']; + openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; + runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string, err: unknown) => void; +} + +export interface CliCommandRuntimeServiceContextHandlers { + texthookerService: CliCommandRuntimeServiceDepsParams['texthooker']['service']; +} + +function createCliCommandDepsFromContext( + context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, +): CliCommandRuntimeServiceDepsParams { + return { + mpv: { + getSocketPath: context.getSocketPath, + setSocketPath: context.setSocketPath, + getClient: context.getClient, + showOsd: context.showOsd, + }, + texthooker: { + service: context.texthookerService, + getPort: context.getTexthookerPort, + setPort: context.setTexthookerPort, + shouldOpenBrowser: context.shouldOpenBrowser, + openInBrowser: context.openInBrowser, + }, + overlay: { + isInitialized: context.isOverlayInitialized, + initialize: context.initializeOverlay, + toggleVisible: context.toggleVisibleOverlay, + toggleInvisible: context.toggleInvisibleOverlay, + setVisible: context.setVisibleOverlay, + setInvisible: context.setInvisibleOverlay, + }, + mining: { + copyCurrentSubtitle: context.copyCurrentSubtitle, + startPendingMultiCopy: context.startPendingMultiCopy, + mineSentenceCard: context.mineSentenceCard, + startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: context.updateLastCardFromClipboard, + refreshKnownWords: context.refreshKnownWordCache, + triggerFieldGrouping: context.triggerFieldGrouping, + triggerSubsyncFromConfig: context.triggerSubsyncFromConfig, + markLastCardAsAudioCard: context.markLastCardAsAudioCard, + }, + anilist: { + getStatus: context.getAnilistStatus, + clearToken: context.clearAnilistToken, + openSetup: context.openAnilistSetup, + getQueueStatus: context.getAnilistQueueStatus, + retryQueueNow: context.retryAnilistQueueNow, + }, + jellyfin: { + openSetup: context.openJellyfinSetup, + runCommand: context.runJellyfinCommand, + }, + ui: { + openYomitanSettings: context.openYomitanSettings, + cycleSecondarySubMode: context.cycleSecondarySubMode, + openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, + printHelp: context.printHelp, + }, + app: { + stop: context.stopApp, + hasMainWindow: context.hasMainWindow, + }, + getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs, + schedule: context.schedule, + log: context.log, + warn: context.warn, + error: context.error, + }; +} + +export function handleCliCommandRuntimeService( + args: CliArgs, + source: CliCommandSource, + params: CliCommandRuntimeServiceDepsParams, +): void { + const deps = createCliCommandDepsRuntime(createCliCommandRuntimeServiceDeps(params)); + handleCliCommand(args, source, deps); +} + +export function handleCliCommandRuntimeServiceWithContext( + args: CliArgs, + source: CliCommandSource, + context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, +): void { + handleCliCommandRuntimeService(args, source, createCliCommandDepsFromContext(context)); +} diff --git a/src/main/config-validation.test.ts b/src/main/config-validation.test.ts new file mode 100644 index 0000000..f9aa870 --- /dev/null +++ b/src/main/config-validation.test.ts @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildConfigParseErrorDetails, + buildConfigWarningNotificationBody, + buildConfigWarningSummary, + failStartupFromConfig, + formatConfigValue, +} from './config-validation'; + +test('formatConfigValue handles undefined and JSON values', () => { + assert.equal(formatConfigValue(undefined), 'undefined'); + assert.equal(formatConfigValue({ x: 1 }), '{"x":1}'); + assert.equal(formatConfigValue(['a', 2]), '["a",2]'); +}); + +test('buildConfigWarningSummary includes warnings with formatted values', () => { + const summary = buildConfigWarningSummary('/tmp/config.jsonc', [ + { + path: 'ankiConnect.pollingRate', + message: 'must be >= 50', + value: 20, + fallback: 250, + }, + ]); + + assert.match(summary, /Validation found 1 issue\(s\)\. File: \/tmp\/config\.jsonc/); + assert.match(summary, /ankiConnect\.pollingRate: must be >= 50 actual=20 fallback=250/); +}); + +test('buildConfigWarningNotificationBody includes concise warning details', () => { + const body = buildConfigWarningNotificationBody('/tmp/config.jsonc', [ + { + path: 'ankiConnect.openRouter', + message: 'Deprecated key; use ankiConnect.ai instead.', + value: { enabled: true }, + fallback: {}, + }, + { + path: 'ankiConnect.isLapis.sentenceCardSentenceField', + message: 'Deprecated key; sentence-card sentence field is fixed to Sentence.', + value: 'Sentence', + fallback: 'Sentence', + }, + ]); + + assert.match(body, /2 config validation issue\(s\) detected\./); + assert.match(body, /File: \/tmp\/config\.jsonc/); + assert.match(body, /1\. ankiConnect\.openRouter: Deprecated key; use ankiConnect\.ai instead\./); + assert.match( + body, + /2\. ankiConnect\.isLapis\.sentenceCardSentenceField: Deprecated key; sentence-card sentence field is fixed to Sentence\./, + ); +}); + +test('buildConfigParseErrorDetails includes path error and restart guidance', () => { + const details = buildConfigParseErrorDetails('/tmp/config.jsonc', 'unexpected token at line 1'); + + assert.match(details, /Failed to parse config file at:/); + assert.match(details, /\/tmp\/config\.jsonc/); + assert.match(details, /Error: unexpected token at line 1/); + assert.match(details, /Fix the config file and restart SubMiner\./); +}); + +test('failStartupFromConfig invokes handlers and throws', () => { + const calls: string[] = []; + const previousExitCode = process.exitCode; + process.exitCode = 0; + + assert.throws( + () => + failStartupFromConfig('Config Error', 'bad value', { + logError: (details) => { + calls.push(`log:${details}`); + }, + showErrorBox: (title, details) => { + calls.push(`dialog:${title}:${details}`); + }, + quit: () => { + calls.push('quit'); + }, + }), + /bad value/, + ); + + assert.equal(process.exitCode, 1); + assert.deepEqual(calls, ['log:bad value', 'dialog:Config Error:bad value', 'quit']); + + process.exitCode = previousExitCode; +}); diff --git a/src/main/config-validation.ts b/src/main/config-validation.ts new file mode 100644 index 0000000..a9c326f --- /dev/null +++ b/src/main/config-validation.ts @@ -0,0 +1,85 @@ +import type { ConfigValidationWarning } from '../types'; + +export type StartupFailureHandlers = { + logError: (details: string) => void; + showErrorBox: (title: string, details: string) => void; + quit: () => void; +}; + +export function formatConfigValue(value: unknown): string { + if (value === undefined) { + return 'undefined'; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function buildConfigWarningSummary( + configPath: string, + warnings: ConfigValidationWarning[], +): string { + const lines = [ + `[config] Validation found ${warnings.length} issue(s). File: ${configPath}`, + ...warnings.map( + (warning, index) => + `[config] ${index + 1}. ${warning.path}: ${warning.message} actual=${formatConfigValue(warning.value)} fallback=${formatConfigValue(warning.fallback)}`, + ), + ]; + return lines.join('\n'); +} + +export function buildConfigWarningNotificationBody( + configPath: string, + warnings: ConfigValidationWarning[], +): string { + const maxLines = 3; + const maxPathLength = 48; + + const trimPath = (value: string): string => + value.length > maxPathLength ? `...${value.slice(-(maxPathLength - 3))}` : value; + const clippedPath = trimPath(configPath); + + const lines = warnings.slice(0, maxLines).map((warning, index) => { + const message = `${warning.path}: ${warning.message}`; + return `${index + 1}. ${message}`; + }); + + const overflow = warnings.length - lines.length; + if (overflow > 0) { + lines.push(`+${overflow} more issue(s)`); + } + + return [ + `${warnings.length} config validation issue(s) detected.`, + 'Defaults were applied where possible.', + `File: ${clippedPath}`, + ...lines, + ].join('\n'); +} + +export function buildConfigParseErrorDetails(configPath: string, parseError: string): string { + return [ + 'Failed to parse config file at:', + configPath, + '', + `Error: ${parseError}`, + '', + 'Fix the config file and restart SubMiner.', + ].join('\n'); +} + +export function failStartupFromConfig( + title: string, + details: string, + handlers: StartupFailureHandlers, +): never { + handlers.logError(details); + handlers.showErrorBox(title, details); + process.exitCode = 1; + handlers.quit(); + throw new Error(details); +} diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts new file mode 100644 index 0000000..2ab12f3 --- /dev/null +++ b/src/main/dependencies.ts @@ -0,0 +1,341 @@ +import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types'; +import { SubsyncResolvedConfig } from '../subsync/utils'; +import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner'; +import type { IpcDepsRuntimeOptions } from '../core/services/ipc'; +import type { AnkiJimakuIpcRuntimeOptions } from '../core/services/anki-jimaku'; +import type { CliCommandDepsRuntimeOptions } from '../core/services/cli-command'; +import type { HandleMpvCommandFromIpcOptions } from '../core/services/ipc-command'; +import { + cycleRuntimeOptionFromIpcRuntime, + setRuntimeOptionFromIpcRuntime, +} from '../core/services/runtime-options-ipc'; +import { RuntimeOptionsManager } from '../runtime-options'; + +export interface RuntimeOptionsIpcDepsParams { + getRuntimeOptionsManager: () => RuntimeOptionsManager | null; + showMpvOsd: (text: string) => void; +} + +export interface SubsyncRuntimeDepsParams { + getMpvClient: () => ReturnType; + getResolvedSubsyncConfig: () => SubsyncResolvedConfig; + isSubsyncInProgress: () => boolean; + setSubsyncInProgress: (inProgress: boolean) => void; + showMpvOsd: (text: string) => void; + openManualPicker: (payload: SubsyncManualPayload) => void; +} + +export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): { + setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; +} { + return { + setRuntimeOption: (id, value) => + setRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, value, (text) => + params.showMpvOsd(text), + ), + cycleRuntimeOption: (id, direction) => + cycleRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, direction, (text) => + params.showMpvOsd(text), + ), + }; +} + +export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): SubsyncRuntimeDeps { + return { + getMpvClient: params.getMpvClient, + getResolvedSubsyncConfig: params.getResolvedSubsyncConfig, + isSubsyncInProgress: params.isSubsyncInProgress, + setSubsyncInProgress: params.setSubsyncInProgress, + showMpvOsd: params.showMpvOsd, + openManualPicker: params.openManualPicker, + }; +} + +export interface MainIpcRuntimeServiceDepsParams { + getInvisibleWindow: IpcDepsRuntimeOptions['getInvisibleWindow']; + getMainWindow: IpcDepsRuntimeOptions['getMainWindow']; + getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; + getInvisibleOverlayVisibility: IpcDepsRuntimeOptions['getInvisibleOverlayVisibility']; + onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; + openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; + quitApp: IpcDepsRuntimeOptions['quitApp']; + toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay']; + tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle']; + getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; + getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; + focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; + getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics']; + getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition']; + getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle']; + saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition']; + getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; + handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; + getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; + getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; + getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; + getMpvClient: IpcDepsRuntimeOptions['getMpvClient']; + runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual']; + getAnkiConnectStatus: IpcDepsRuntimeOptions['getAnkiConnectStatus']; + getRuntimeOptions: IpcDepsRuntimeOptions['getRuntimeOptions']; + setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption']; + cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption']; + reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds']; + reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken']; + getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus']; + clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken']; + openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup']; + getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; + retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; + appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; +} + +export interface AnkiJimakuIpcRuntimeServiceDepsParams { + patchAnkiConnectEnabled: AnkiJimakuIpcRuntimeOptions['patchAnkiConnectEnabled']; + getResolvedConfig: AnkiJimakuIpcRuntimeOptions['getResolvedConfig']; + getRuntimeOptionsManager: AnkiJimakuIpcRuntimeOptions['getRuntimeOptionsManager']; + getSubtitleTimingTracker: AnkiJimakuIpcRuntimeOptions['getSubtitleTimingTracker']; + getMpvClient: AnkiJimakuIpcRuntimeOptions['getMpvClient']; + getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration']; + setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; + getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; + showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; + createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; + broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged']; + getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver']; + setFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['setFieldGroupingResolver']; + parseMediaInfo: AnkiJimakuIpcRuntimeOptions['parseMediaInfo']; + getCurrentMediaPath: AnkiJimakuIpcRuntimeOptions['getCurrentMediaPath']; + jimakuFetchJson: AnkiJimakuIpcRuntimeOptions['jimakuFetchJson']; + getJimakuMaxEntryResults: AnkiJimakuIpcRuntimeOptions['getJimakuMaxEntryResults']; + getJimakuLanguagePreference: AnkiJimakuIpcRuntimeOptions['getJimakuLanguagePreference']; + resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions['resolveJimakuApiKey']; + isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions['isRemoteMediaPath']; + downloadToFile: AnkiJimakuIpcRuntimeOptions['downloadToFile']; +} + +export interface CliCommandRuntimeServiceDepsParams { + mpv: { + getSocketPath: CliCommandDepsRuntimeOptions['mpv']['getSocketPath']; + setSocketPath: CliCommandDepsRuntimeOptions['mpv']['setSocketPath']; + getClient: CliCommandDepsRuntimeOptions['mpv']['getClient']; + showOsd: CliCommandDepsRuntimeOptions['mpv']['showOsd']; + }; + texthooker: { + service: CliCommandDepsRuntimeOptions['texthooker']['service']; + getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort']; + setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort']; + shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser']; + openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser']; + }; + overlay: { + isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized']; + initialize: CliCommandDepsRuntimeOptions['overlay']['initialize']; + toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible']; + toggleInvisible: CliCommandDepsRuntimeOptions['overlay']['toggleInvisible']; + setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible']; + setInvisible: CliCommandDepsRuntimeOptions['overlay']['setInvisible']; + }; + mining: { + copyCurrentSubtitle: CliCommandDepsRuntimeOptions['mining']['copyCurrentSubtitle']; + startPendingMultiCopy: CliCommandDepsRuntimeOptions['mining']['startPendingMultiCopy']; + mineSentenceCard: CliCommandDepsRuntimeOptions['mining']['mineSentenceCard']; + startPendingMineSentenceMultiple: CliCommandDepsRuntimeOptions['mining']['startPendingMineSentenceMultiple']; + updateLastCardFromClipboard: CliCommandDepsRuntimeOptions['mining']['updateLastCardFromClipboard']; + refreshKnownWords: CliCommandDepsRuntimeOptions['mining']['refreshKnownWords']; + triggerFieldGrouping: CliCommandDepsRuntimeOptions['mining']['triggerFieldGrouping']; + triggerSubsyncFromConfig: CliCommandDepsRuntimeOptions['mining']['triggerSubsyncFromConfig']; + markLastCardAsAudioCard: CliCommandDepsRuntimeOptions['mining']['markLastCardAsAudioCard']; + }; + anilist: { + getStatus: CliCommandDepsRuntimeOptions['anilist']['getStatus']; + clearToken: CliCommandDepsRuntimeOptions['anilist']['clearToken']; + openSetup: CliCommandDepsRuntimeOptions['anilist']['openSetup']; + getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus']; + retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow']; + }; + jellyfin: { + openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup']; + runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand']; + }; + ui: { + openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; + cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode']; + 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']; + warn: CliCommandDepsRuntimeOptions['warn']; + error: CliCommandDepsRuntimeOptions['error']; +} + +export interface MpvCommandRuntimeServiceDepsParams { + specialCommands: HandleMpvCommandFromIpcOptions['specialCommands']; + runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle']; + triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; + openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; + showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; + mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; + mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; + mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand']; + isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected']; + hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager']; +} + +export function createMainIpcRuntimeServiceDeps( + params: MainIpcRuntimeServiceDepsParams, +): IpcDepsRuntimeOptions { + return { + getInvisibleWindow: params.getInvisibleWindow, + getMainWindow: params.getMainWindow, + getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, + getInvisibleOverlayVisibility: params.getInvisibleOverlayVisibility, + onOverlayModalClosed: params.onOverlayModalClosed, + openYomitanSettings: params.openYomitanSettings, + quitApp: params.quitApp, + toggleVisibleOverlay: params.toggleVisibleOverlay, + tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, + getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, + getCurrentSubtitleAss: params.getCurrentSubtitleAss, + getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics, + getSubtitlePosition: params.getSubtitlePosition, + getSubtitleStyle: params.getSubtitleStyle, + saveSubtitlePosition: params.saveSubtitlePosition, + getMecabTokenizer: params.getMecabTokenizer, + handleMpvCommand: params.handleMpvCommand, + getKeybindings: params.getKeybindings, + getConfiguredShortcuts: params.getConfiguredShortcuts, + focusMainWindow: params.focusMainWindow ?? (() => {}), + getSecondarySubMode: params.getSecondarySubMode, + getMpvClient: params.getMpvClient, + runSubsyncManual: params.runSubsyncManual, + getAnkiConnectStatus: params.getAnkiConnectStatus, + getRuntimeOptions: params.getRuntimeOptions, + setRuntimeOption: params.setRuntimeOption, + cycleRuntimeOption: params.cycleRuntimeOption, + reportOverlayContentBounds: params.reportOverlayContentBounds, + reportHoveredSubtitleToken: params.reportHoveredSubtitleToken, + getAnilistStatus: params.getAnilistStatus, + clearAnilistToken: params.clearAnilistToken, + openAnilistSetup: params.openAnilistSetup, + getAnilistQueueStatus: params.getAnilistQueueStatus, + retryAnilistQueueNow: params.retryAnilistQueueNow, + appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, + }; +} + +export function createAnkiJimakuIpcRuntimeServiceDeps( + params: AnkiJimakuIpcRuntimeServiceDepsParams, +): AnkiJimakuIpcRuntimeOptions { + return { + patchAnkiConnectEnabled: params.patchAnkiConnectEnabled, + getResolvedConfig: params.getResolvedConfig, + getRuntimeOptionsManager: params.getRuntimeOptionsManager, + getSubtitleTimingTracker: params.getSubtitleTimingTracker, + getMpvClient: params.getMpvClient, + getAnkiIntegration: params.getAnkiIntegration, + setAnkiIntegration: params.setAnkiIntegration, + getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, + showDesktopNotification: params.showDesktopNotification, + createFieldGroupingCallback: params.createFieldGroupingCallback, + broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, + getFieldGroupingResolver: params.getFieldGroupingResolver, + setFieldGroupingResolver: params.setFieldGroupingResolver, + parseMediaInfo: params.parseMediaInfo, + getCurrentMediaPath: params.getCurrentMediaPath, + jimakuFetchJson: params.jimakuFetchJson, + getJimakuMaxEntryResults: params.getJimakuMaxEntryResults, + getJimakuLanguagePreference: params.getJimakuLanguagePreference, + resolveJimakuApiKey: params.resolveJimakuApiKey, + isRemoteMediaPath: params.isRemoteMediaPath, + downloadToFile: params.downloadToFile, + }; +} + +export function createCliCommandRuntimeServiceDeps( + params: CliCommandRuntimeServiceDepsParams, +): CliCommandDepsRuntimeOptions { + return { + mpv: { + getSocketPath: params.mpv.getSocketPath, + setSocketPath: params.mpv.setSocketPath, + getClient: params.mpv.getClient, + showOsd: params.mpv.showOsd, + }, + texthooker: { + service: params.texthooker.service, + getPort: params.texthooker.getPort, + setPort: params.texthooker.setPort, + shouldOpenBrowser: params.texthooker.shouldOpenBrowser, + openInBrowser: params.texthooker.openInBrowser, + }, + overlay: { + isInitialized: params.overlay.isInitialized, + initialize: params.overlay.initialize, + toggleVisible: params.overlay.toggleVisible, + toggleInvisible: params.overlay.toggleInvisible, + setVisible: params.overlay.setVisible, + setInvisible: params.overlay.setInvisible, + }, + mining: { + copyCurrentSubtitle: params.mining.copyCurrentSubtitle, + startPendingMultiCopy: params.mining.startPendingMultiCopy, + mineSentenceCard: params.mining.mineSentenceCard, + startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard, + refreshKnownWords: params.mining.refreshKnownWords, + triggerFieldGrouping: params.mining.triggerFieldGrouping, + triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig, + markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard, + }, + anilist: { + getStatus: params.anilist.getStatus, + clearToken: params.anilist.clearToken, + openSetup: params.anilist.openSetup, + getQueueStatus: params.anilist.getQueueStatus, + retryQueueNow: params.anilist.retryQueueNow, + }, + jellyfin: { + openSetup: params.jellyfin.openSetup, + runCommand: params.jellyfin.runCommand, + }, + ui: { + openYomitanSettings: params.ui.openYomitanSettings, + cycleSecondarySubMode: params.ui.cycleSecondarySubMode, + 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, + warn: params.warn, + error: params.error, + }; +} + +export function createMpvCommandRuntimeServiceDeps( + params: MpvCommandRuntimeServiceDepsParams, +): HandleMpvCommandFromIpcOptions { + return { + specialCommands: params.specialCommands, + triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, + openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, + runtimeOptionsCycle: params.runtimeOptionsCycle, + showMpvOsd: params.showMpvOsd, + mpvReplaySubtitle: params.mpvReplaySubtitle, + mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, + mpvSendCommand: params.mpvSendCommand, + isMpvConnected: params.isMpvConnected, + hasRuntimeOptionsManager: params.hasRuntimeOptionsManager, + }; +} diff --git a/src/main/frequency-dictionary-runtime.ts b/src/main/frequency-dictionary-runtime.ts new file mode 100644 index 0000000..5bc992b --- /dev/null +++ b/src/main/frequency-dictionary-runtime.ts @@ -0,0 +1,86 @@ +import * as path from 'path'; +import type { FrequencyDictionaryLookup } from '../types'; +import { createFrequencyDictionaryLookup } from '../core/services'; + +export interface FrequencyDictionarySearchPathDeps { + getDictionaryRoots: () => string[]; + getSourcePath?: () => string | undefined; +} + +export interface FrequencyDictionaryRuntimeDeps { + isFrequencyDictionaryEnabled: () => boolean; + getSearchPaths: () => string[]; + setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void; + log: (message: string) => void; +} + +let frequencyDictionaryLookupInitialized = false; +let frequencyDictionaryLookupInitialization: Promise | null = null; + +// Frequency dictionary services are initialized lazily as a process-wide singleton. +// Initialization is idempotent and intentionally shared across callers. + +export function getFrequencyDictionarySearchPaths( + deps: FrequencyDictionarySearchPathDeps, +): string[] { + const dictionaryRoots = deps.getDictionaryRoots(); + const sourcePath = deps.getSourcePath?.(); + + const rawSearchPaths: string[] = []; + // User-provided path takes precedence over bundled/default roots. + // Root list should include `vendor/jiten_freq_global` in callers. + if (sourcePath && sourcePath.trim()) { + rawSearchPaths.push(sourcePath.trim()); + rawSearchPaths.push(path.join(sourcePath.trim(), 'frequency-dictionary')); + rawSearchPaths.push(path.join(sourcePath.trim(), 'vendor', 'frequency-dictionary')); + } + + for (const dictionaryRoot of dictionaryRoots) { + rawSearchPaths.push(dictionaryRoot); + rawSearchPaths.push(path.join(dictionaryRoot, 'frequency-dictionary')); + rawSearchPaths.push(path.join(dictionaryRoot, 'vendor', 'frequency-dictionary')); + } + + return [...new Set(rawSearchPaths)]; +} + +export async function initializeFrequencyDictionaryLookup( + deps: FrequencyDictionaryRuntimeDeps, +): Promise { + const lookup = await createFrequencyDictionaryLookup({ + searchPaths: deps.getSearchPaths(), + log: deps.log, + }); + deps.setFrequencyRankLookup(lookup); +} + +export async function ensureFrequencyDictionaryLookup( + deps: FrequencyDictionaryRuntimeDeps, +): Promise { + if (!deps.isFrequencyDictionaryEnabled()) { + return; + } + if (frequencyDictionaryLookupInitialized) { + return; + } + if (!frequencyDictionaryLookupInitialization) { + frequencyDictionaryLookupInitialization = initializeFrequencyDictionaryLookup(deps) + .then(() => { + frequencyDictionaryLookupInitialized = true; + }) + .catch((error) => { + frequencyDictionaryLookupInitialized = true; + deps.log(`Failed to initialize frequency dictionary: ${String(error)}`); + deps.setFrequencyRankLookup(() => null); + }); + } + await frequencyDictionaryLookupInitialization; +} + +export function createFrequencyDictionaryRuntimeService(deps: FrequencyDictionaryRuntimeDeps): { + ensureFrequencyDictionaryLookup: () => Promise; +} { + return { + ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup(deps), + }; +} diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts new file mode 100644 index 0000000..2829567 --- /dev/null +++ b/src/main/ipc-mpv-command.ts @@ -0,0 +1,37 @@ +import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../types'; +import { handleMpvCommandFromIpc } from '../core/services'; +import { createMpvCommandRuntimeServiceDeps } from './dependencies'; +import { SPECIAL_COMMANDS } from '../config'; + +export interface MpvCommandFromIpcRuntimeDeps { + triggerSubsyncFromConfig: () => void; + openRuntimeOptionsPalette: () => void; + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; + showMpvOsd: (text: string) => void; + replayCurrentSubtitle: () => void; + playNextSubtitle: () => void; + sendMpvCommand: (command: (string | number)[]) => void; + isMpvConnected: () => boolean; + hasRuntimeOptionsManager: () => boolean; +} + +export function handleMpvCommandFromIpcRuntime( + command: (string | number)[], + deps: MpvCommandFromIpcRuntimeDeps, +): void { + handleMpvCommandFromIpc( + command, + createMpvCommandRuntimeServiceDeps({ + specialCommands: SPECIAL_COMMANDS, + triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, + openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + runtimeOptionsCycle: deps.cycleRuntimeOption, + showMpvOsd: deps.showMpvOsd, + mpvReplaySubtitle: deps.replayCurrentSubtitle, + mpvPlayNextSubtitle: deps.playNextSubtitle, + mpvSendCommand: deps.sendMpvCommand, + isMpvConnected: deps.isMpvConnected, + hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager, + }), + ); +} diff --git a/src/main/ipc-runtime.ts b/src/main/ipc-runtime.ts new file mode 100644 index 0000000..4e5f7f6 --- /dev/null +++ b/src/main/ipc-runtime.ts @@ -0,0 +1,46 @@ +import { + createIpcDepsRuntime, + registerAnkiJimakuIpcRuntime, + registerIpcHandlers, +} from '../core/services'; +import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc'; +import { + createAnkiJimakuIpcRuntimeServiceDeps, + AnkiJimakuIpcRuntimeServiceDepsParams, + createMainIpcRuntimeServiceDeps, + MainIpcRuntimeServiceDepsParams, + createRuntimeOptionsIpcDeps, + RuntimeOptionsIpcDepsParams, +} from './dependencies'; + +export interface RegisterIpcRuntimeServicesParams { + runtimeOptions: RuntimeOptionsIpcDepsParams; + mainDeps: Omit; + ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams; +} + +export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void { + registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params))); +} + +export function registerAnkiJimakuIpcRuntimeServices( + params: AnkiJimakuIpcRuntimeServiceDepsParams, +): void { + registerAnkiJimakuIpcRuntime( + createAnkiJimakuIpcRuntimeServiceDeps(params), + registerAnkiJimakuIpcHandlers, + ); +} + +export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void { + const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({ + getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager, + showMpvOsd: params.runtimeOptions.showMpvOsd, + }); + registerMainIpcRuntimeServices({ + ...params.mainDeps, + setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, + cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, + }); + registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps); +} diff --git a/src/main/jlpt-runtime.ts b/src/main/jlpt-runtime.ts new file mode 100644 index 0000000..f2af355 --- /dev/null +++ b/src/main/jlpt-runtime.ts @@ -0,0 +1,73 @@ +import * as path from 'path'; +import type { JlptLevel } from '../types'; + +import { createJlptVocabularyLookup } from '../core/services'; + +export interface JlptDictionarySearchPathDeps { + getDictionaryRoots: () => string[]; +} + +export type JlptLookup = (term: string) => JlptLevel | null; + +export interface JlptDictionaryRuntimeDeps { + isJlptEnabled: () => boolean; + getSearchPaths: () => string[]; + setJlptLevelLookup: (lookup: JlptLookup) => void; + log: (message: string) => void; +} + +let jlptDictionaryLookupInitialized = false; +let jlptDictionaryLookupInitialization: Promise | null = null; + +export function getJlptDictionarySearchPaths(deps: JlptDictionarySearchPathDeps): string[] { + const dictionaryRoots = deps.getDictionaryRoots(); + + const searchPaths: string[] = []; + for (const dictionaryRoot of dictionaryRoots) { + searchPaths.push(dictionaryRoot); + searchPaths.push(path.join(dictionaryRoot, 'vendor', 'yomitan-jlpt-vocab')); + searchPaths.push(path.join(dictionaryRoot, 'yomitan-jlpt-vocab')); + } + + const uniquePaths = new Set(searchPaths); + return [...uniquePaths]; +} + +export async function initializeJlptDictionaryLookup( + deps: JlptDictionaryRuntimeDeps, +): Promise { + deps.setJlptLevelLookup( + await createJlptVocabularyLookup({ + searchPaths: deps.getSearchPaths(), + log: deps.log, + }), + ); +} + +export async function ensureJlptDictionaryLookup(deps: JlptDictionaryRuntimeDeps): Promise { + if (!deps.isJlptEnabled()) { + return; + } + if (jlptDictionaryLookupInitialized) { + return; + } + if (!jlptDictionaryLookupInitialization) { + jlptDictionaryLookupInitialization = initializeJlptDictionaryLookup(deps) + .then(() => { + jlptDictionaryLookupInitialized = true; + }) + .catch((error) => { + jlptDictionaryLookupInitialization = null; + throw error; + }); + } + await jlptDictionaryLookupInitialization; +} + +export function createJlptDictionaryRuntimeService(deps: JlptDictionaryRuntimeDeps): { + ensureJlptDictionaryLookup: () => Promise; +} { + return { + ensureJlptDictionaryLookup: () => ensureJlptDictionaryLookup(deps), + }; +} diff --git a/src/main/media-runtime.ts b/src/main/media-runtime.ts new file mode 100644 index 0000000..a994c93 --- /dev/null +++ b/src/main/media-runtime.ts @@ -0,0 +1,68 @@ +import { updateCurrentMediaPath } from '../core/services'; + +import type { SubtitlePosition } from '../types'; + +export interface MediaRuntimeDeps { + isRemoteMediaPath: (mediaPath: string) => boolean; + loadSubtitlePosition: () => SubtitlePosition | null; + getCurrentMediaPath: () => string | null; + getPendingSubtitlePosition: () => SubtitlePosition | null; + getSubtitlePositionsDir: () => string; + setCurrentMediaPath: (mediaPath: string | null) => void; + clearPendingSubtitlePosition: () => void; + setSubtitlePosition: (position: SubtitlePosition | null) => void; + broadcastSubtitlePosition: (position: SubtitlePosition | null) => void; + getCurrentMediaTitle: () => string | null; + setCurrentMediaTitle: (title: string | null) => void; +} + +export interface MediaRuntimeService { + updateCurrentMediaPath: (mediaPath: unknown) => void; + updateCurrentMediaTitle: (mediaTitle: unknown) => void; + resolveMediaPathForJimaku: (mediaPath: string | null) => string | null; +} + +export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeService { + return { + updateCurrentMediaPath(mediaPath: unknown): void { + if (typeof mediaPath !== 'string' || !deps.isRemoteMediaPath(mediaPath)) { + deps.setCurrentMediaTitle(null); + } + + updateCurrentMediaPath({ + mediaPath, + currentMediaPath: deps.getCurrentMediaPath(), + pendingSubtitlePosition: deps.getPendingSubtitlePosition(), + subtitlePositionsDir: deps.getSubtitlePositionsDir(), + loadSubtitlePosition: () => deps.loadSubtitlePosition(), + setCurrentMediaPath: (nextPath: string | null) => { + deps.setCurrentMediaPath(nextPath); + }, + clearPendingSubtitlePosition: () => { + deps.clearPendingSubtitlePosition(); + }, + setSubtitlePosition: (position: SubtitlePosition | null) => { + deps.setSubtitlePosition(position); + }, + broadcastSubtitlePosition: (position: SubtitlePosition | null) => { + deps.broadcastSubtitlePosition(position); + }, + }); + }, + + updateCurrentMediaTitle(mediaTitle: unknown): void { + if (typeof mediaTitle === 'string') { + const sanitized = mediaTitle.trim(); + deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null); + return; + } + deps.setCurrentMediaTitle(null); + }, + + resolveMediaPathForJimaku(mediaPath: string | null): string | null { + return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle() + ? deps.getCurrentMediaTitle() + : mediaPath; + }, + }; +} diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts new file mode 100644 index 0000000..6df4523 --- /dev/null +++ b/src/main/overlay-runtime.ts @@ -0,0 +1,134 @@ +import type { BrowserWindow } from 'electron'; + +type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku'; +type OverlayHostLayer = 'visible' | 'invisible'; + +export interface OverlayWindowResolver { + getMainWindow: () => BrowserWindow | null; + getInvisibleWindow: () => BrowserWindow | null; +} + +export interface OverlayModalRuntime { + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + ) => boolean; + openRuntimeOptionsPalette: () => void; + handleOverlayModalClosed: (modal: OverlayHostedModal) => void; + getRestoreVisibleOverlayOnModalClose: () => Set; +} + +export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): OverlayModalRuntime { + const restoreVisibleOverlayOnModalClose = new Set(); + const overlayModalAutoShownLayer = new Map(); + + const getTargetOverlayWindow = (): { + window: BrowserWindow; + layer: OverlayHostLayer; + } | null => { + const visibleMainWindow = deps.getMainWindow(); + const invisibleWindow = deps.getInvisibleWindow(); + + if (visibleMainWindow && !visibleMainWindow.isDestroyed()) { + return { window: visibleMainWindow, layer: 'visible' }; + } + + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + return { window: invisibleWindow, layer: 'invisible' }; + } + + return null; + }; + + const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => { + if (layer === 'invisible' && typeof window.showInactive === 'function') { + window.showInactive(); + } else { + window.show(); + } + if (!window.isFocused()) { + window.focus(); + } + }; + + const sendToActiveOverlayWindow = ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + ): boolean => { + const target = getTargetOverlayWindow(); + if (!target) return false; + + const { window: targetWindow, layer } = target; + const wasVisible = targetWindow.isVisible(); + const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; + + const sendNow = (): void => { + if (payload === undefined) { + targetWindow.webContents.send(channel); + } else { + targetWindow.webContents.send(channel, payload); + } + }; + + if (!wasVisible) { + showOverlayWindowForModal(targetWindow, layer); + } + if (!wasVisible && restoreOnModalClose) { + restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); + overlayModalAutoShownLayer.set(restoreOnModalClose, layer); + } + + if (targetWindow.webContents.isLoading()) { + targetWindow.webContents.once('did-finish-load', () => { + if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) { + sendNow(); + } + }); + return true; + } + + sendNow(); + return true; + }; + + const openRuntimeOptionsPalette = (): void => { + sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + }; + + const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { + if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + restoreVisibleOverlayOnModalClose.delete(modal); + const layer = overlayModalAutoShownLayer.get(modal); + overlayModalAutoShownLayer.delete(modal); + if (!layer) return; + const shouldKeepLayerVisible = [...restoreVisibleOverlayOnModalClose].some( + (pendingModal) => overlayModalAutoShownLayer.get(pendingModal) === layer, + ); + if (shouldKeepLayerVisible) return; + + if (layer === 'visible') { + const mainWindow = deps.getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide(); + } + return; + } + const invisibleWindow = deps.getInvisibleWindow(); + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + invisibleWindow.hide(); + } + }; + + return { + sendToActiveOverlayWindow, + openRuntimeOptionsPalette, + handleOverlayModalClosed, + getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, + }; +} + +export type { OverlayHostedModal }; diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts new file mode 100644 index 0000000..a041c60 --- /dev/null +++ b/src/main/overlay-shortcuts-runtime.ts @@ -0,0 +1,134 @@ +import type { ConfiguredShortcuts } from '../core/utils/shortcut-config'; +import { + createOverlayShortcutRuntimeHandlers, + shortcutMatchesInputForLocalFallback, +} from '../core/services'; +import { + refreshOverlayShortcutsRuntime, + registerOverlayShortcuts, + syncOverlayShortcutsRuntime, + unregisterOverlayShortcutsRuntime, +} from '../core/services'; +import { runOverlayShortcutLocalFallback } from '../core/services/overlay-shortcut-handler'; + +export interface OverlayShortcutRuntimeServiceInput { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getShortcutsRegistered: () => boolean; + setShortcutsRegistered: (registered: boolean) => void; + isOverlayRuntimeInitialized: () => boolean; + showMpvOsd: (text: string) => void; + openRuntimeOptionsPalette: () => void; + openJimaku: () => void; + markAudioCard: () => Promise; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySubMode: () => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + mineSentenceCard: () => Promise; + mineSentenceMultiple: (timeoutMs: number) => void; + cancelPendingMultiCopy: () => void; + cancelPendingMineSentenceMultiple: () => void; +} + +export interface OverlayShortcutsRuntimeService { + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + registerOverlayShortcuts: () => void; + unregisterOverlayShortcuts: () => void; + syncOverlayShortcuts: () => void; + refreshOverlayShortcuts: () => void; +} + +export function createOverlayShortcutsRuntimeService( + input: OverlayShortcutRuntimeServiceInput, +): OverlayShortcutsRuntimeService { + const handlers = createOverlayShortcutRuntimeHandlers({ + showMpvOsd: (text: string) => input.showMpvOsd(text), + openRuntimeOptions: () => { + input.openRuntimeOptionsPalette(); + }, + openJimaku: () => { + input.openJimaku(); + }, + markAudioCard: () => { + return input.markAudioCard(); + }, + copySubtitleMultiple: (timeoutMs: number) => { + input.copySubtitleMultiple(timeoutMs); + }, + copySubtitle: () => { + input.copySubtitle(); + }, + toggleSecondarySub: () => { + input.toggleSecondarySubMode(); + }, + updateLastCardFromClipboard: () => { + return input.updateLastCardFromClipboard(); + }, + triggerFieldGrouping: () => { + return input.triggerFieldGrouping(); + }, + triggerSubsync: () => { + return input.triggerSubsyncFromConfig(); + }, + mineSentence: () => { + return input.mineSentenceCard(); + }, + mineSentenceMultiple: (timeoutMs: number) => { + input.mineSentenceMultiple(timeoutMs); + }, + }); + + const getShortcutLifecycleDeps = () => { + return { + getConfiguredShortcuts: () => input.getConfiguredShortcuts(), + getOverlayHandlers: () => handlers.overlayHandlers, + cancelPendingMultiCopy: () => input.cancelPendingMultiCopy(), + cancelPendingMineSentenceMultiple: () => input.cancelPendingMineSentenceMultiple(), + }; + }; + + const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized(); + + return { + tryHandleOverlayShortcutLocalFallback: (inputEvent) => + runOverlayShortcutLocalFallback( + inputEvent, + input.getConfiguredShortcuts(), + shortcutMatchesInputForLocalFallback, + handlers.fallbackHandlers, + ), + registerOverlayShortcuts: () => { + input.setShortcutsRegistered( + registerOverlayShortcuts(input.getConfiguredShortcuts(), handlers.overlayHandlers), + ); + }, + unregisterOverlayShortcuts: () => { + input.setShortcutsRegistered( + unregisterOverlayShortcutsRuntime( + input.getShortcutsRegistered(), + getShortcutLifecycleDeps(), + ), + ); + }, + syncOverlayShortcuts: () => { + input.setShortcutsRegistered( + syncOverlayShortcutsRuntime( + shouldOverlayShortcutsBeActive(), + input.getShortcutsRegistered(), + getShortcutLifecycleDeps(), + ), + ); + }, + refreshOverlayShortcuts: () => { + input.setShortcutsRegistered( + refreshOverlayShortcutsRuntime( + shouldOverlayShortcutsBeActive(), + input.getShortcutsRegistered(), + getShortcutLifecycleDeps(), + ), + ); + }, + }; +} diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts new file mode 100644 index 0000000..032d444 --- /dev/null +++ b/src/main/overlay-visibility-runtime.ts @@ -0,0 +1,90 @@ +import type { BrowserWindow } from 'electron'; + +import type { BaseWindowTracker } from '../window-trackers'; +import type { WindowGeometry } from '../types'; +import { + syncInvisibleOverlayMousePassthrough, + updateInvisibleOverlayVisibility, + updateVisibleOverlayVisibility, +} from '../core/services'; + +export interface OverlayVisibilityRuntimeDeps { + getMainWindow: () => BrowserWindow | null; + getInvisibleWindow: () => BrowserWindow | null; + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + getWindowTracker: () => BaseWindowTracker | null; + getTrackerNotReadyWarningShown: () => boolean; + setTrackerNotReadyWarningShown: (shown: boolean) => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: BrowserWindow) => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; +} + +export interface OverlayVisibilityRuntimeService { + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; +} + +export function createOverlayVisibilityRuntimeService( + deps: OverlayVisibilityRuntimeDeps, +): OverlayVisibilityRuntimeService { + const hasInvisibleWindow = (): boolean => { + const invisibleWindow = deps.getInvisibleWindow(); + return Boolean(invisibleWindow && !invisibleWindow.isDestroyed()); + }; + + const setIgnoreMouseEvents = ( + ignore: boolean, + options?: Parameters[1], + ): void => { + const invisibleWindow = deps.getInvisibleWindow(); + if (!invisibleWindow || invisibleWindow.isDestroyed()) return; + invisibleWindow.setIgnoreMouseEvents(ignore, options); + }; + + return { + updateVisibleOverlayVisibility(): void { + updateVisibleOverlayVisibility({ + visibleOverlayVisible: deps.getVisibleOverlayVisible(), + mainWindow: deps.getMainWindow(), + windowTracker: deps.getWindowTracker(), + trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), + setTrackerNotReadyWarningShown: (shown: boolean) => { + deps.setTrackerNotReadyWarningShown(shown); + }, + updateVisibleOverlayBounds: (geometry: WindowGeometry) => + deps.updateVisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), + }); + }, + + updateInvisibleOverlayVisibility(): void { + updateInvisibleOverlayVisibility({ + invisibleWindow: deps.getInvisibleWindow(), + visibleOverlayVisible: deps.getVisibleOverlayVisible(), + invisibleOverlayVisible: deps.getInvisibleOverlayVisible(), + windowTracker: deps.getWindowTracker(), + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => + deps.updateInvisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), + }); + }, + + syncInvisibleOverlayMousePassthrough(): void { + syncInvisibleOverlayMousePassthrough({ + hasInvisibleWindow, + setIgnoreMouseEvents, + visibleOverlayVisible: deps.getVisibleOverlayVisible(), + invisibleOverlayVisible: deps.getInvisibleOverlayVisible(), + }); + }, + }; +} diff --git a/src/main/runtime/anilist-media-guess-main-deps.test.ts b/src/main/runtime/anilist-media-guess-main-deps.test.ts new file mode 100644 index 0000000..2c33486 --- /dev/null +++ b/src/main/runtime/anilist-media-guess-main-deps.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildEnsureAnilistMediaGuessMainDepsHandler, + createBuildMaybeProbeAnilistDurationMainDepsHandler, +} from './anilist-media-guess-main-deps'; + +test('maybe probe anilist duration main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildMaybeProbeAnilistDurationMainDepsHandler({ + getState: () => ({ + mediaKey: 'm', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }), + setState: () => calls.push('set-state'), + durationRetryIntervalMs: 1000, + now: () => 42, + requestMpvDuration: async () => 3600, + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + assert.equal(deps.durationRetryIntervalMs, 1000); + assert.equal(deps.now(), 42); + assert.equal(await deps.requestMpvDuration(), 3600); + deps.setState({ + mediaKey: 'm', + mediaDurationSec: 100, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }); + deps.logWarn('oops', null); + assert.deepEqual(calls, ['set-state', 'warn:oops']); +}); + +test('ensure anilist media guess main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildEnsureAnilistMediaGuessMainDepsHandler({ + getState: () => ({ + mediaKey: 'm', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }), + setState: () => calls.push('set-state'), + resolveMediaPathForJimaku: (path) => { + calls.push('resolve'); + return path; + }, + getCurrentMediaPath: () => '/tmp/video.mkv', + getCurrentMediaTitle: () => 'title', + guessAnilistMediaInfo: async () => { + calls.push('guess'); + return { title: 'title', episode: 1, source: 'fallback' }; + }, + })(); + + assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv'); + assert.equal(deps.getCurrentMediaTitle(), 'title'); + assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv'); + assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), { + title: 'title', + episode: 1, + source: 'fallback', + }); + deps.setState({ + mediaKey: 'm', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }); + assert.deepEqual(calls, ['resolve', 'guess', 'set-state']); +}); diff --git a/src/main/runtime/anilist-media-guess-main-deps.ts b/src/main/runtime/anilist-media-guess-main-deps.ts new file mode 100644 index 0000000..2a5bfeb --- /dev/null +++ b/src/main/runtime/anilist-media-guess-main-deps.ts @@ -0,0 +1,31 @@ +import type { + createEnsureAnilistMediaGuessHandler, + createMaybeProbeAnilistDurationHandler, +} from './anilist-media-guess'; + +type MaybeProbeAnilistDurationMainDeps = Parameters[0]; +type EnsureAnilistMediaGuessMainDeps = Parameters[0]; + +export function createBuildMaybeProbeAnilistDurationMainDepsHandler( + deps: MaybeProbeAnilistDurationMainDeps, +) { + return (): MaybeProbeAnilistDurationMainDeps => ({ + getState: () => deps.getState(), + setState: (state) => deps.setState(state), + durationRetryIntervalMs: deps.durationRetryIntervalMs, + now: () => deps.now(), + requestMpvDuration: () => deps.requestMpvDuration(), + logWarn: (message: string, error: unknown) => deps.logWarn(message, error), + }); +} + +export function createBuildEnsureAnilistMediaGuessMainDepsHandler(deps: EnsureAnilistMediaGuessMainDeps) { + return (): EnsureAnilistMediaGuessMainDeps => ({ + getState: () => deps.getState(), + setState: (state) => deps.setState(state), + resolveMediaPathForJimaku: (currentMediaPath) => deps.resolveMediaPathForJimaku(currentMediaPath), + getCurrentMediaPath: () => deps.getCurrentMediaPath(), + getCurrentMediaTitle: () => deps.getCurrentMediaTitle(), + guessAnilistMediaInfo: (mediaPath, mediaTitle) => deps.guessAnilistMediaInfo(mediaPath, mediaTitle), + }); +} diff --git a/src/main/runtime/anilist-media-guess.test.ts b/src/main/runtime/anilist-media-guess.test.ts new file mode 100644 index 0000000..6a862c6 --- /dev/null +++ b/src/main/runtime/anilist-media-guess.test.ts @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createEnsureAnilistMediaGuessHandler, + createMaybeProbeAnilistDurationHandler, + type AnilistMediaGuessRuntimeState, +} from './anilist-media-guess'; + +test('maybeProbeAnilistDuration updates state with probed duration', async () => { + let state: AnilistMediaGuessRuntimeState = { + mediaKey: '/tmp/video.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + const probe = createMaybeProbeAnilistDurationHandler({ + getState: () => state, + setState: (next) => { + state = next; + }, + durationRetryIntervalMs: 1000, + now: () => 2000, + requestMpvDuration: async () => 321, + logWarn: () => {}, + }); + + const duration = await probe('/tmp/video.mkv'); + assert.equal(duration, 321); + assert.equal(state.mediaDurationSec, 321); +}); + +test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => { + let state: AnilistMediaGuessRuntimeState = { + mediaKey: '/tmp/video.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + let calls = 0; + const ensureGuess = createEnsureAnilistMediaGuessHandler({ + getState: () => state, + setState: (next) => { + state = next; + }, + resolveMediaPathForJimaku: (value) => value, + getCurrentMediaPath: () => '/tmp/video.mkv', + getCurrentMediaTitle: () => 'Episode 1', + guessAnilistMediaInfo: async () => { + calls += 1; + return { title: 'Show', episode: 1, source: 'guessit' }; + }, + }); + + const [first, second] = await Promise.all([ + ensureGuess('/tmp/video.mkv'), + ensureGuess('/tmp/video.mkv'), + ]); + assert.deepEqual(first, { title: 'Show', episode: 1, source: 'guessit' }); + assert.deepEqual(second, { title: 'Show', episode: 1, source: 'guessit' }); + assert.equal(calls, 1); + assert.deepEqual(state.mediaGuess, { title: 'Show', episode: 1, source: 'guessit' }); + assert.equal(state.mediaGuessPromise, null); +}); diff --git a/src/main/runtime/anilist-media-guess.ts b/src/main/runtime/anilist-media-guess.ts new file mode 100644 index 0000000..7a0a799 --- /dev/null +++ b/src/main/runtime/anilist-media-guess.ts @@ -0,0 +1,112 @@ +import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; + +export type AnilistMediaGuessRuntimeState = { + mediaKey: string | null; + mediaDurationSec: number | null; + mediaGuess: AnilistMediaGuess | null; + mediaGuessPromise: Promise | null; + lastDurationProbeAtMs: number; +}; + +type GuessAnilistMediaInfo = ( + mediaPath: string | null, + mediaTitle: string | null, +) => Promise; + +export function createMaybeProbeAnilistDurationHandler(deps: { + getState: () => AnilistMediaGuessRuntimeState; + setState: (state: AnilistMediaGuessRuntimeState) => void; + durationRetryIntervalMs: number; + now: () => number; + requestMpvDuration: () => Promise; + logWarn: (message: string, error: unknown) => void; +}) { + return async (mediaKey: string): Promise => { + const state = deps.getState(); + if (state.mediaKey !== mediaKey) { + return null; + } + if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) { + return state.mediaDurationSec; + } + const now = deps.now(); + if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) { + return null; + } + + deps.setState({ + ...state, + lastDurationProbeAtMs: now, + }); + + try { + const durationCandidate = await deps.requestMpvDuration(); + const duration = + typeof durationCandidate === 'number' && Number.isFinite(durationCandidate) + ? durationCandidate + : null; + const latestState = deps.getState(); + if (duration && duration > 0 && latestState.mediaKey === mediaKey) { + deps.setState({ + ...latestState, + mediaDurationSec: duration, + }); + return duration; + } + } catch (error) { + deps.logWarn('AniList duration probe failed:', error); + } + return null; + }; +} + +export function createEnsureAnilistMediaGuessHandler(deps: { + getState: () => AnilistMediaGuessRuntimeState; + setState: (state: AnilistMediaGuessRuntimeState) => void; + resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null; + getCurrentMediaPath: () => string | null; + getCurrentMediaTitle: () => string | null; + guessAnilistMediaInfo: GuessAnilistMediaInfo; +}) { + return async (mediaKey: string): Promise => { + const state = deps.getState(); + if (state.mediaKey !== mediaKey) { + return null; + } + if (state.mediaGuess) { + return state.mediaGuess; + } + if (state.mediaGuessPromise) { + return state.mediaGuessPromise; + } + + const mediaPathForGuess = deps.resolveMediaPathForJimaku(deps.getCurrentMediaPath()); + const promise = deps + .guessAnilistMediaInfo(mediaPathForGuess, deps.getCurrentMediaTitle()) + .then((guess) => { + const latestState = deps.getState(); + if (latestState.mediaKey === mediaKey) { + deps.setState({ + ...latestState, + mediaGuess: guess, + }); + } + return guess; + }) + .finally(() => { + const latestState = deps.getState(); + if (latestState.mediaKey === mediaKey) { + deps.setState({ + ...latestState, + mediaGuessPromise: null, + }); + } + }); + + deps.setState({ + ...state, + mediaGuessPromise: promise, + }); + return promise; + }; +} diff --git a/src/main/runtime/anilist-media-state-main-deps.test.ts b/src/main/runtime/anilist-media-state-main-deps.test.ts new file mode 100644 index 0000000..f5515b7 --- /dev/null +++ b/src/main/runtime/anilist-media-state-main-deps.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, + createBuildGetCurrentAnilistMediaKeyMainDepsHandler, + createBuildResetAnilistMediaGuessStateMainDepsHandler, + createBuildResetAnilistMediaTrackingMainDepsHandler, + createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, +} from './anilist-media-state-main-deps'; + +test('get current anilist media key main deps builder maps callbacks', () => { + const deps = createBuildGetCurrentAnilistMediaKeyMainDepsHandler({ + getCurrentMediaPath: () => '/tmp/video.mkv', + })(); + assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv'); +}); + +test('reset anilist media tracking main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildResetAnilistMediaTrackingMainDepsHandler({ + setMediaKey: () => calls.push('key'), + setMediaDurationSec: () => calls.push('duration'), + setMediaGuess: () => calls.push('guess'), + setMediaGuessPromise: () => calls.push('promise'), + setLastDurationProbeAtMs: () => calls.push('probe'), + })(); + deps.setMediaKey(null); + deps.setMediaDurationSec(null); + deps.setMediaGuess(null); + deps.setMediaGuessPromise(null); + deps.setLastDurationProbeAtMs(0); + assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']); +}); + +test('get/set anilist media guess runtime state main deps builders map callbacks', () => { + const getter = createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({ + getMediaKey: () => '/tmp/video.mkv', + getMediaDurationSec: () => 24, + getMediaGuess: () => ({ title: 'X' }) as never, + getMediaGuessPromise: () => Promise.resolve(null) as never, + getLastDurationProbeAtMs: () => 123, + })(); + assert.equal(getter.getMediaKey(), '/tmp/video.mkv'); + assert.equal(getter.getMediaDurationSec(), 24); + assert.equal(getter.getLastDurationProbeAtMs(), 123); + + const calls: string[] = []; + const setter = createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({ + setMediaKey: () => calls.push('key'), + setMediaDurationSec: () => calls.push('duration'), + setMediaGuess: () => calls.push('guess'), + setMediaGuessPromise: () => calls.push('promise'), + setLastDurationProbeAtMs: () => calls.push('probe'), + })(); + setter.setMediaKey(null); + setter.setMediaDurationSec(null); + setter.setMediaGuess(null); + setter.setMediaGuessPromise(null); + setter.setLastDurationProbeAtMs(0); + assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']); +}); + +test('reset anilist media guess state main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildResetAnilistMediaGuessStateMainDepsHandler({ + setMediaGuess: () => calls.push('guess'), + setMediaGuessPromise: () => calls.push('promise'), + })(); + deps.setMediaGuess(null); + deps.setMediaGuessPromise(null); + assert.deepEqual(calls, ['guess', 'promise']); +}); diff --git a/src/main/runtime/anilist-media-state-main-deps.ts b/src/main/runtime/anilist-media-state-main-deps.ts new file mode 100644 index 0000000..7057cd2 --- /dev/null +++ b/src/main/runtime/anilist-media-state-main-deps.ts @@ -0,0 +1,72 @@ +import type { + createGetAnilistMediaGuessRuntimeStateHandler, + createGetCurrentAnilistMediaKeyHandler, + createResetAnilistMediaGuessStateHandler, + createResetAnilistMediaTrackingHandler, + createSetAnilistMediaGuessRuntimeStateHandler, +} from './anilist-media-state'; + +type GetCurrentAnilistMediaKeyMainDeps = Parameters[0]; +type ResetAnilistMediaTrackingMainDeps = Parameters[0]; +type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters< + typeof createGetAnilistMediaGuessRuntimeStateHandler +>[0]; +type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters< + typeof createSetAnilistMediaGuessRuntimeStateHandler +>[0]; +type ResetAnilistMediaGuessStateMainDeps = Parameters< + typeof createResetAnilistMediaGuessStateHandler +>[0]; + +export function createBuildGetCurrentAnilistMediaKeyMainDepsHandler( + deps: GetCurrentAnilistMediaKeyMainDeps, +) { + return (): GetCurrentAnilistMediaKeyMainDeps => ({ + getCurrentMediaPath: () => deps.getCurrentMediaPath(), + }); +} + +export function createBuildResetAnilistMediaTrackingMainDepsHandler( + deps: ResetAnilistMediaTrackingMainDeps, +) { + return (): ResetAnilistMediaTrackingMainDeps => ({ + setMediaKey: (value) => deps.setMediaKey(value), + setMediaDurationSec: (value) => deps.setMediaDurationSec(value), + setMediaGuess: (value) => deps.setMediaGuess(value), + setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value), + setLastDurationProbeAtMs: (value: number) => deps.setLastDurationProbeAtMs(value), + }); +} + +export function createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler( + deps: GetAnilistMediaGuessRuntimeStateMainDeps, +) { + return (): GetAnilistMediaGuessRuntimeStateMainDeps => ({ + getMediaKey: () => deps.getMediaKey(), + getMediaDurationSec: () => deps.getMediaDurationSec(), + getMediaGuess: () => deps.getMediaGuess(), + getMediaGuessPromise: () => deps.getMediaGuessPromise(), + getLastDurationProbeAtMs: () => deps.getLastDurationProbeAtMs(), + }); +} + +export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler( + deps: SetAnilistMediaGuessRuntimeStateMainDeps, +) { + return (): SetAnilistMediaGuessRuntimeStateMainDeps => ({ + setMediaKey: (value) => deps.setMediaKey(value), + setMediaDurationSec: (value) => deps.setMediaDurationSec(value), + setMediaGuess: (value) => deps.setMediaGuess(value), + setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value), + setLastDurationProbeAtMs: (value: number) => deps.setLastDurationProbeAtMs(value), + }); +} + +export function createBuildResetAnilistMediaGuessStateMainDepsHandler( + deps: ResetAnilistMediaGuessStateMainDeps, +) { + return (): ResetAnilistMediaGuessStateMainDeps => ({ + setMediaGuess: (value) => deps.setMediaGuess(value), + setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value), + }); +} diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts new file mode 100644 index 0000000..8720ccd --- /dev/null +++ b/src/main/runtime/anilist-media-state.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createGetAnilistMediaGuessRuntimeStateHandler, + createGetCurrentAnilistMediaKeyHandler, + createResetAnilistMediaGuessStateHandler, + createResetAnilistMediaTrackingHandler, + createSetAnilistMediaGuessRuntimeStateHandler, +} from './anilist-media-state'; + +test('get current anilist media key trims and normalizes empty path', () => { + const getKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => ' /tmp/video.mkv ', + }); + const getEmptyKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => ' ', + }); + + assert.equal(getKey(), '/tmp/video.mkv'); + assert.equal(getEmptyKey(), null); +}); + +test('reset anilist media tracking clears duration/guess/probe state', () => { + let mediaKey: string | null = 'old'; + let mediaDurationSec: number | null = 123; + let mediaGuess: { title: string } | null = { title: 'guess' }; + let mediaGuessPromise: Promise | null = Promise.resolve(null); + let lastDurationProbeAtMs = 999; + + const reset = createResetAnilistMediaTrackingHandler({ + setMediaKey: (value) => { + mediaKey = value; + }, + setMediaDurationSec: (value) => { + mediaDurationSec = value; + }, + setMediaGuess: (value) => { + mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + lastDurationProbeAtMs = value; + }, + }); + + reset('/new/media'); + assert.equal(mediaKey, '/new/media'); + assert.equal(mediaDurationSec, null); + assert.equal(mediaGuess, null); + assert.equal(mediaGuessPromise, null); + assert.equal(lastDurationProbeAtMs, 0); +}); + +test('reset anilist media tracking is idempotent', () => { + const state = { + mediaKey: 'old' as string | null, + mediaDurationSec: 123 as number | null, + mediaGuess: { title: 'guess' } as { title: string } | null, + mediaGuessPromise: Promise.resolve(null) as Promise | null, + lastDurationProbeAtMs: 999, + }; + + const reset = createResetAnilistMediaTrackingHandler({ + setMediaKey: (value) => { + state.mediaKey = value; + }, + setMediaDurationSec: (value) => { + state.mediaDurationSec = value; + }, + setMediaGuess: (value) => { + state.mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + state.mediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + state.lastDurationProbeAtMs = value; + }, + }); + + reset('/new/media'); + const afterFirstReset = { ...state }; + reset('/new/media'); + + assert.deepEqual(state, afterFirstReset); +}); + +test('get/set anilist media guess runtime state round-trips fields', () => { + let state = { + mediaKey: null as string | null, + mediaDurationSec: null as number | null, + mediaGuess: null as { title: string } | null, + mediaGuessPromise: null as Promise | null, + lastDurationProbeAtMs: 0, + }; + + const setState = createSetAnilistMediaGuessRuntimeStateHandler({ + setMediaKey: (value) => { + state.mediaKey = value; + }, + setMediaDurationSec: (value) => { + state.mediaDurationSec = value; + }, + setMediaGuess: (value) => { + state.mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + state.mediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + state.lastDurationProbeAtMs = value; + }, + }); + + const getState = createGetAnilistMediaGuessRuntimeStateHandler({ + getMediaKey: () => state.mediaKey, + getMediaDurationSec: () => state.mediaDurationSec, + getMediaGuess: () => state.mediaGuess as never, + getMediaGuessPromise: () => state.mediaGuessPromise as never, + getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs, + }); + + const nextPromise = Promise.resolve(null); + setState({ + mediaKey: '/tmp/video.mkv', + mediaDurationSec: 24, + mediaGuess: { title: 'Title' } as never, + mediaGuessPromise: nextPromise as never, + lastDurationProbeAtMs: 321, + }); + + const roundTrip = getState(); + assert.equal(roundTrip.mediaKey, '/tmp/video.mkv'); + assert.equal(roundTrip.mediaDurationSec, 24); + assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' }); + assert.equal(roundTrip.mediaGuessPromise, nextPromise); + assert.equal(roundTrip.lastDurationProbeAtMs, 321); +}); + +test('reset anilist media guess state clears guess and in-flight promise', () => { + const state = { + mediaKey: '/tmp/video.mkv' as string | null, + mediaDurationSec: 240 as number | null, + mediaGuess: { title: 'guess' } as { title: string } | null, + mediaGuessPromise: Promise.resolve(null) as Promise | null, + lastDurationProbeAtMs: 321, + }; + + const resetGuessState = createResetAnilistMediaGuessStateHandler({ + setMediaGuess: (value) => { + state.mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + state.mediaGuessPromise = value; + }, + }); + + resetGuessState(); + assert.equal(state.mediaGuess, null); + assert.equal(state.mediaGuessPromise, null); + assert.equal(state.mediaKey, '/tmp/video.mkv'); + assert.equal(state.mediaDurationSec, 240); + assert.equal(state.lastDurationProbeAtMs, 321); +}); diff --git a/src/main/runtime/anilist-media-state.ts b/src/main/runtime/anilist-media-state.ts new file mode 100644 index 0000000..433967d --- /dev/null +++ b/src/main/runtime/anilist-media-state.ts @@ -0,0 +1,68 @@ +import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess'; + +export function createGetCurrentAnilistMediaKeyHandler(deps: { + getCurrentMediaPath: () => string | null; +}) { + return (): string | null => { + const mediaPath = deps.getCurrentMediaPath()?.trim(); + return mediaPath && mediaPath.length > 0 ? mediaPath : null; + }; +} + +export function createResetAnilistMediaTrackingHandler(deps: { + setMediaKey: (value: string | null) => void; + setMediaDurationSec: (value: number | null) => void; + setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; + setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; + setLastDurationProbeAtMs: (value: number) => void; +}) { + return (mediaKey: string | null): void => { + deps.setMediaKey(mediaKey); + deps.setMediaDurationSec(null); + deps.setMediaGuess(null); + deps.setMediaGuessPromise(null); + deps.setLastDurationProbeAtMs(0); + }; +} + +export function createGetAnilistMediaGuessRuntimeStateHandler(deps: { + getMediaKey: () => string | null; + getMediaDurationSec: () => number | null; + getMediaGuess: () => AnilistMediaGuessRuntimeState['mediaGuess']; + getMediaGuessPromise: () => AnilistMediaGuessRuntimeState['mediaGuessPromise']; + getLastDurationProbeAtMs: () => number; +}) { + return (): AnilistMediaGuessRuntimeState => ({ + mediaKey: deps.getMediaKey(), + mediaDurationSec: deps.getMediaDurationSec(), + mediaGuess: deps.getMediaGuess(), + mediaGuessPromise: deps.getMediaGuessPromise(), + lastDurationProbeAtMs: deps.getLastDurationProbeAtMs(), + }); +} + +export function createSetAnilistMediaGuessRuntimeStateHandler(deps: { + setMediaKey: (value: string | null) => void; + setMediaDurationSec: (value: number | null) => void; + setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; + setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; + setLastDurationProbeAtMs: (value: number) => void; +}) { + return (state: AnilistMediaGuessRuntimeState): void => { + deps.setMediaKey(state.mediaKey); + deps.setMediaDurationSec(state.mediaDurationSec); + deps.setMediaGuess(state.mediaGuess); + deps.setMediaGuessPromise(state.mediaGuessPromise); + deps.setLastDurationProbeAtMs(state.lastDurationProbeAtMs); + }; +} + +export function createResetAnilistMediaGuessStateHandler(deps: { + setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; + setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; +}) { + return (): void => { + deps.setMediaGuess(null); + deps.setMediaGuessPromise(null); + }; +} diff --git a/src/main/runtime/anilist-post-watch-main-deps.test.ts b/src/main/runtime/anilist-post-watch-main-deps.test.ts new file mode 100644 index 0000000..bb88e69 --- /dev/null +++ b/src/main/runtime/anilist-post-watch-main-deps.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, + createBuildProcessNextAnilistRetryUpdateMainDepsHandler, +} from './anilist-post-watch-main-deps'; + +test('process next anilist retry update main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({ + nextReady: () => ({ key: 'k', title: 't', episode: 1 }), + refreshRetryQueueState: () => calls.push('refresh'), + setLastAttemptAt: () => calls.push('attempt'), + setLastError: () => calls.push('error'), + refreshAnilistClientSecretState: async () => 'token', + updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }), + markSuccess: () => calls.push('success'), + rememberAttemptedUpdateKey: () => calls.push('remember'), + markFailure: () => calls.push('failure'), + logInfo: (message) => calls.push(`info:${message}`), + now: () => 7, + })(); + + assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', episode: 1 }); + deps.refreshRetryQueueState(); + deps.setLastAttemptAt(1); + deps.setLastError('x'); + assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); + assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), { + status: 'updated', + message: 'ok', + }); + deps.markSuccess('k'); + deps.rememberAttemptedUpdateKey('k'); + deps.markFailure('k', 'bad'); + deps.logInfo('hello'); + assert.equal(deps.now(), 7); + assert.deepEqual(calls, [ + 'refresh', + 'attempt', + 'error', + 'success', + 'remember', + 'failure', + 'info:hello', + ]); +}); + +test('maybe run anilist post watch update main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({ + getInFlight: () => false, + setInFlight: () => calls.push('in-flight'), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => 'media', + hasMpvClient: () => true, + getTrackedMediaKey: () => 'media', + resetTrackedMedia: () => calls.push('reset'), + getWatchedSeconds: () => 100, + maybeProbeAnilistDuration: async () => 120, + ensureAnilistMediaGuess: async () => ({ title: 'x', episode: 1 }), + hasAttemptedUpdateKey: () => false, + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), + refreshAnilistClientSecretState: async () => 'token', + enqueueRetry: () => calls.push('enqueue'), + markRetryFailure: () => calls.push('retry-fail'), + markRetrySuccess: () => calls.push('retry-ok'), + refreshRetryQueueState: () => calls.push('refresh'), + updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }), + rememberAttemptedUpdateKey: () => calls.push('remember'), + showMpvOsd: () => calls.push('osd'), + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + minWatchSeconds: 5, + minWatchRatio: 0.5, + })(); + + assert.equal(deps.getInFlight(), false); + deps.setInFlight(true); + assert.equal(deps.isAnilistTrackingEnabled(deps.getResolvedConfig()), true); + assert.equal(deps.getCurrentMediaKey(), 'media'); + assert.equal(deps.hasMpvClient(), true); + assert.equal(deps.getTrackedMediaKey(), 'media'); + deps.resetTrackedMedia('media'); + assert.equal(deps.getWatchedSeconds(), 100); + assert.equal(await deps.maybeProbeAnilistDuration('media'), 120); + assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', episode: 1 }); + assert.equal(deps.hasAttemptedUpdateKey('k'), false); + assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' }); + assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); + deps.enqueueRetry('k', 't', 1); + deps.markRetryFailure('k', 'bad'); + deps.markRetrySuccess('k'); + deps.refreshRetryQueueState(); + assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), { + status: 'updated', + message: 'done', + }); + deps.rememberAttemptedUpdateKey('k'); + deps.showMpvOsd('ok'); + deps.logInfo('x'); + deps.logWarn('y'); + assert.equal(deps.minWatchSeconds, 5); + assert.equal(deps.minWatchRatio, 0.5); + assert.deepEqual(calls, [ + 'in-flight', + 'reset', + 'enqueue', + 'retry-fail', + 'retry-ok', + 'refresh', + 'remember', + 'osd', + 'info:x', + 'warn:y', + ]); +}); diff --git a/src/main/runtime/anilist-post-watch-main-deps.ts b/src/main/runtime/anilist-post-watch-main-deps.ts new file mode 100644 index 0000000..b5a1a45 --- /dev/null +++ b/src/main/runtime/anilist-post-watch-main-deps.ts @@ -0,0 +1,63 @@ +import type { + createMaybeRunAnilistPostWatchUpdateHandler, + createProcessNextAnilistRetryUpdateHandler, +} from './anilist-post-watch'; + +type ProcessNextAnilistRetryUpdateMainDeps = Parameters< + typeof createProcessNextAnilistRetryUpdateHandler +>[0]; +type MaybeRunAnilistPostWatchUpdateMainDeps = Parameters< + typeof createMaybeRunAnilistPostWatchUpdateHandler +>[0]; + +export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler( + deps: ProcessNextAnilistRetryUpdateMainDeps, +) { + return (): ProcessNextAnilistRetryUpdateMainDeps => ({ + nextReady: () => deps.nextReady(), + refreshRetryQueueState: () => deps.refreshRetryQueueState(), + setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value), + setLastError: (value: string | null) => deps.setLastError(value), + refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(), + updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) => + deps.updateAnilistPostWatchProgress(accessToken, title, episode), + markSuccess: (key: string) => deps.markSuccess(key), + rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key), + markFailure: (key: string, message: string) => deps.markFailure(key, message), + logInfo: (message: string) => deps.logInfo(message), + now: () => deps.now(), + }); +} + +export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler( + deps: MaybeRunAnilistPostWatchUpdateMainDeps, +) { + return (): MaybeRunAnilistPostWatchUpdateMainDeps => ({ + getInFlight: () => deps.getInFlight(), + setInFlight: (value: boolean) => deps.setInFlight(value), + getResolvedConfig: () => deps.getResolvedConfig(), + isAnilistTrackingEnabled: (config) => deps.isAnilistTrackingEnabled(config), + getCurrentMediaKey: () => deps.getCurrentMediaKey(), + hasMpvClient: () => deps.hasMpvClient(), + getTrackedMediaKey: () => deps.getTrackedMediaKey(), + resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey), + getWatchedSeconds: () => deps.getWatchedSeconds(), + maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), + hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key), + processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(), + refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(), + enqueueRetry: (key: string, title: string, episode: number) => deps.enqueueRetry(key, title, episode), + markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message), + markRetrySuccess: (key: string) => deps.markRetrySuccess(key), + refreshRetryQueueState: () => deps.refreshRetryQueueState(), + updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) => + deps.updateAnilistPostWatchProgress(accessToken, title, episode), + rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key), + showMpvOsd: (message: string) => deps.showMpvOsd(message), + logInfo: (message: string) => deps.logInfo(message), + logWarn: (message: string) => deps.logWarn(message), + minWatchSeconds: deps.minWatchSeconds, + minWatchRatio: deps.minWatchRatio, + }); +} diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts new file mode 100644 index 0000000..0b95dcf --- /dev/null +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAnilistAttemptKey, + createMaybeRunAnilistPostWatchUpdateHandler, + createProcessNextAnilistRetryUpdateHandler, + rememberAnilistAttemptedUpdateKey, +} from './anilist-post-watch'; + +test('buildAnilistAttemptKey formats media and episode', () => { + assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3'); +}); + +test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => { + const set = new Set(['a', 'b']); + rememberAnilistAttemptedUpdateKey(set, 'c', 2); + assert.deepEqual(Array.from(set), ['b', 'c']); +}); + +test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => { + const calls: string[] = []; + const handler = createProcessNextAnilistRetryUpdateHandler({ + nextReady: () => ({ key: 'k1', title: 'Show', episode: 1 }), + refreshRetryQueueState: () => calls.push('refresh'), + setLastAttemptAt: () => calls.push('attempt'), + setLastError: (value) => calls.push(`error:${value ?? 'null'}`), + refreshAnilistClientSecretState: async () => 'token', + updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }), + markSuccess: () => calls.push('success'), + rememberAttemptedUpdateKey: () => calls.push('remember'), + markFailure: () => calls.push('failure'), + logInfo: () => calls.push('info'), + now: () => 1, + }); + + const result = await handler(); + assert.deepEqual(result, { ok: true, message: 'updated ok' }); + assert.ok(calls.includes('success')); + assert.ok(calls.includes('remember')); +}); + +test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', async () => { + const calls: string[] = []; + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => '/tmp/video.mkv', + hasMpvClient: () => true, + getTrackedMediaKey: () => '/tmp/video.mkv', + resetTrackedMedia: () => {}, + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async () => 1000, + ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 1 }), + hasAttemptedUpdateKey: () => false, + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), + refreshAnilistClientSecretState: async () => null, + enqueueRetry: () => calls.push('enqueue'), + markRetryFailure: () => calls.push('mark-failure'), + markRetrySuccess: () => calls.push('mark-success'), + refreshRetryQueueState: () => calls.push('refresh'), + updateAnilistPostWatchProgress: async () => ({ 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.ok(calls.includes('enqueue')); + assert.ok(calls.includes('mark-failure')); + assert.ok(calls.includes('osd:AniList: access token not configured')); + assert.ok(calls.includes('inflight:true')); + assert.ok(calls.includes('inflight:false')); +}); diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts new file mode 100644 index 0000000..1deb055 --- /dev/null +++ b/src/main/runtime/anilist-post-watch.ts @@ -0,0 +1,195 @@ +type AnilistGuess = { + title: string; + episode: number | null; +}; + +type AnilistUpdateResult = { + status: 'updated' | 'skipped' | 'error'; + message: string; +}; + +type RetryQueueItem = { + key: string; + title: string; + episode: number; +}; + +export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { + return `${mediaKey}::${episode}`; +} + +export function rememberAnilistAttemptedUpdateKey( + attemptedKeys: Set, + key: string, + maxSize: number, +): void { + attemptedKeys.add(key); + if (attemptedKeys.size <= maxSize) { + return; + } + const oldestKey = attemptedKeys.values().next().value; + if (typeof oldestKey === 'string') { + attemptedKeys.delete(oldestKey); + } +} + +export function createProcessNextAnilistRetryUpdateHandler(deps: { + nextReady: () => RetryQueueItem | null; + refreshRetryQueueState: () => void; + setLastAttemptAt: (value: number) => void; + setLastError: (value: string | null) => void; + refreshAnilistClientSecretState: () => Promise; + updateAnilistPostWatchProgress: ( + accessToken: string, + title: string, + episode: number, + ) => Promise; + markSuccess: (key: string) => void; + rememberAttemptedUpdateKey: (key: string) => void; + markFailure: (key: string, message: string) => void; + logInfo: (message: string) => void; + now: () => number; +}) { + return async (): Promise<{ ok: boolean; message: string }> => { + const queued = deps.nextReady(); + deps.refreshRetryQueueState(); + if (!queued) { + return { ok: true, message: 'AniList queue has no ready items.' }; + } + + deps.setLastAttemptAt(deps.now()); + const accessToken = await deps.refreshAnilistClientSecretState(); + if (!accessToken) { + deps.setLastError('AniList token unavailable for queued retry.'); + return { ok: false, message: 'AniList token unavailable for queued retry.' }; + } + + const result = await deps.updateAnilistPostWatchProgress(accessToken, queued.title, queued.episode); + if (result.status === 'updated' || result.status === 'skipped') { + deps.markSuccess(queued.key); + deps.rememberAttemptedUpdateKey(queued.key); + deps.setLastError(null); + deps.refreshRetryQueueState(); + deps.logInfo(`[AniList queue] ${result.message}`); + return { ok: true, message: result.message }; + } + + deps.markFailure(queued.key, result.message); + deps.setLastError(result.message); + deps.refreshRetryQueueState(); + return { ok: false, message: result.message }; + }; +} + +export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { + getInFlight: () => boolean; + setInFlight: (value: boolean) => void; + getResolvedConfig: () => unknown; + isAnilistTrackingEnabled: (config: unknown) => boolean; + getCurrentMediaKey: () => string | null; + hasMpvClient: () => boolean; + getTrackedMediaKey: () => string | null; + resetTrackedMedia: (mediaKey: string | null) => void; + getWatchedSeconds: () => number; + maybeProbeAnilistDuration: (mediaKey: string) => Promise; + ensureAnilistMediaGuess: (mediaKey: string) => Promise; + hasAttemptedUpdateKey: (key: string) => boolean; + processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>; + refreshAnilistClientSecretState: () => Promise; + enqueueRetry: (key: string, title: string, episode: number) => void; + markRetryFailure: (key: string, message: string) => void; + markRetrySuccess: (key: string) => void; + refreshRetryQueueState: () => void; + updateAnilistPostWatchProgress: ( + accessToken: string, + title: string, + episode: number, + ) => Promise; + rememberAttemptedUpdateKey: (key: string) => void; + showMpvOsd: (message: string) => void; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + minWatchSeconds: number; + minWatchRatio: number; +}) { + return async (): Promise => { + if (deps.getInFlight()) { + return; + } + + const resolved = deps.getResolvedConfig(); + if (!deps.isAnilistTrackingEnabled(resolved)) { + return; + } + + const mediaKey = deps.getCurrentMediaKey(); + if (!mediaKey || !deps.hasMpvClient()) { + return; + } + if (deps.getTrackedMediaKey() !== mediaKey) { + deps.resetTrackedMedia(mediaKey); + } + + const watchedSeconds = deps.getWatchedSeconds(); + if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { + return; + } + + const duration = await deps.maybeProbeAnilistDuration(mediaKey); + if (!duration || duration <= 0) { + return; + } + if (watchedSeconds / duration < deps.minWatchRatio) { + return; + } + + const guess = await deps.ensureAnilistMediaGuess(mediaKey); + if (!guess?.title || !guess.episode || guess.episode <= 0) { + return; + } + + const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode); + if (deps.hasAttemptedUpdateKey(attemptKey)) { + return; + } + + deps.setInFlight(true); + try { + await deps.processNextAnilistRetryUpdate(); + + const accessToken = await deps.refreshAnilistClientSecretState(); + if (!accessToken) { + deps.enqueueRetry(attemptKey, guess.title, guess.episode); + deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken'); + deps.refreshRetryQueueState(); + deps.showMpvOsd('AniList: access token not configured'); + return; + } + + const result = await deps.updateAnilistPostWatchProgress(accessToken, guess.title, guess.episode); + if (result.status === 'updated') { + deps.rememberAttemptedUpdateKey(attemptKey); + deps.markRetrySuccess(attemptKey); + deps.refreshRetryQueueState(); + deps.showMpvOsd(result.message); + deps.logInfo(result.message); + return; + } + if (result.status === 'skipped') { + deps.rememberAttemptedUpdateKey(attemptKey); + deps.markRetrySuccess(attemptKey); + deps.refreshRetryQueueState(); + deps.logInfo(result.message); + return; + } + + deps.enqueueRetry(attemptKey, guess.title, guess.episode); + deps.markRetryFailure(attemptKey, result.message); + deps.refreshRetryQueueState(); + deps.showMpvOsd(`AniList: ${result.message}`); + deps.logWarn(result.message); + } finally { + deps.setInFlight(false); + } + }; +} diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.test.ts b/src/main/runtime/anilist-setup-protocol-main-deps.test.ts new file mode 100644 index 0000000..a6c4c06 --- /dev/null +++ b/src/main/runtime/anilist-setup-protocol-main-deps.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler, + createBuildHandleAnilistSetupProtocolUrlMainDepsHandler, + createBuildNotifyAnilistSetupMainDepsHandler, + createBuildRegisterSubminerProtocolClientMainDepsHandler, +} from './anilist-setup-protocol-main-deps'; + +test('notify anilist setup main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildNotifyAnilistSetupMainDepsHandler({ + hasMpvClient: () => true, + showMpvOsd: (message) => calls.push(`osd:${message}`), + showDesktopNotification: (title) => calls.push(`notify:${title}`), + logInfo: (message) => calls.push(`log:${message}`), + })(); + + assert.equal(deps.hasMpvClient(), true); + deps.showMpvOsd('ok'); + deps.showDesktopNotification('SubMiner', { body: 'x' }); + deps.logInfo('done'); + assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']); +}); + +test('consume anilist setup token main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({ + consumeAnilistSetupCallbackUrl: () => true, + saveToken: () => calls.push('save'), + setCachedToken: () => calls.push('cache'), + setResolvedState: () => calls.push('resolved'), + setSetupPageOpened: () => calls.push('opened'), + onSuccess: () => calls.push('success'), + closeWindow: () => calls.push('close'), + })(); + + assert.equal( + deps.consumeAnilistSetupCallbackUrl({ + rawUrl: 'subminer://anilist-setup', + saveToken: () => {}, + setCachedToken: () => {}, + setResolvedState: () => {}, + setSetupPageOpened: () => {}, + onSuccess: () => {}, + closeWindow: () => {}, + }), + true, + ); + deps.saveToken('token'); + deps.setCachedToken('token'); + deps.setResolvedState(Date.now()); + deps.setSetupPageOpened(true); + deps.onSuccess(); + deps.closeWindow(); + assert.deepEqual(calls, ['save', 'cache', 'resolved', 'opened', 'success', 'close']); +}); + +test('handle anilist setup protocol url main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildHandleAnilistSetupProtocolUrlMainDepsHandler({ + consumeAnilistSetupTokenFromUrl: () => true, + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + assert.equal(deps.consumeAnilistSetupTokenFromUrl('subminer://anilist-setup'), true); + deps.logWarn('missing', null); + assert.deepEqual(calls, ['warn:missing']); +}); + +test('register subminer protocol client main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildRegisterSubminerProtocolClientMainDepsHandler({ + isDefaultApp: () => true, + getArgv: () => ['electron', 'entry.js'], + execPath: '/tmp/electron', + resolvePath: (value) => `/abs/${value}`, + setAsDefaultProtocolClient: () => true, + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + assert.equal(deps.isDefaultApp(), true); + assert.deepEqual(deps.getArgv(), ['electron', 'entry.js']); + assert.equal(deps.execPath, '/tmp/electron'); + assert.equal(deps.resolvePath('entry.js'), '/abs/entry.js'); + assert.equal(deps.setAsDefaultProtocolClient('subminer'), true); +}); diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.ts b/src/main/runtime/anilist-setup-protocol-main-deps.ts new file mode 100644 index 0000000..b028a2a --- /dev/null +++ b/src/main/runtime/anilist-setup-protocol-main-deps.ts @@ -0,0 +1,64 @@ +import type { + createConsumeAnilistSetupTokenFromUrlHandler, + createHandleAnilistSetupProtocolUrlHandler, + createNotifyAnilistSetupHandler, + createRegisterSubminerProtocolClientHandler, +} from './anilist-setup-protocol'; + +type NotifyAnilistSetupMainDeps = Parameters[0]; +type ConsumeAnilistSetupTokenMainDeps = Parameters< + typeof createConsumeAnilistSetupTokenFromUrlHandler +>[0]; +type HandleAnilistSetupProtocolUrlMainDeps = Parameters< + typeof createHandleAnilistSetupProtocolUrlHandler +>[0]; +type RegisterSubminerProtocolClientMainDeps = Parameters< + typeof createRegisterSubminerProtocolClientHandler +>[0]; + +export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) { + return (): NotifyAnilistSetupMainDeps => ({ + hasMpvClient: () => deps.hasMpvClient(), + showMpvOsd: (message: string) => deps.showMpvOsd(message), + showDesktopNotification: (title: string, options: { body: string }) => + deps.showDesktopNotification(title, options), + logInfo: (message: string) => deps.logInfo(message), + }); +} + +export function createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler( + deps: ConsumeAnilistSetupTokenMainDeps, +) { + return (): ConsumeAnilistSetupTokenMainDeps => ({ + consumeAnilistSetupCallbackUrl: (input) => deps.consumeAnilistSetupCallbackUrl(input), + saveToken: (token: string) => deps.saveToken(token), + setCachedToken: (token: string) => deps.setCachedToken(token), + setResolvedState: (resolvedAt: number) => deps.setResolvedState(resolvedAt), + setSetupPageOpened: (opened: boolean) => deps.setSetupPageOpened(opened), + onSuccess: () => deps.onSuccess(), + closeWindow: () => deps.closeWindow(), + }); +} + +export function createBuildHandleAnilistSetupProtocolUrlMainDepsHandler( + deps: HandleAnilistSetupProtocolUrlMainDeps, +) { + return (): HandleAnilistSetupProtocolUrlMainDeps => ({ + consumeAnilistSetupTokenFromUrl: (rawUrl: string) => deps.consumeAnilistSetupTokenFromUrl(rawUrl), + logWarn: (message: string, details: unknown) => deps.logWarn(message, details), + }); +} + +export function createBuildRegisterSubminerProtocolClientMainDepsHandler( + deps: RegisterSubminerProtocolClientMainDeps, +) { + return (): RegisterSubminerProtocolClientMainDeps => ({ + isDefaultApp: () => deps.isDefaultApp(), + getArgv: () => deps.getArgv(), + execPath: deps.execPath, + resolvePath: (value: string) => deps.resolvePath(value), + setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => + deps.setAsDefaultProtocolClient(scheme, path, args), + logWarn: (message: string, details?: unknown) => deps.logWarn(message, details), + }); +} diff --git a/src/main/runtime/anilist-setup-protocol.test.ts b/src/main/runtime/anilist-setup-protocol.test.ts new file mode 100644 index 0000000..183b938 --- /dev/null +++ b/src/main/runtime/anilist-setup-protocol.test.ts @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createConsumeAnilistSetupTokenFromUrlHandler, + createHandleAnilistSetupProtocolUrlHandler, + createNotifyAnilistSetupHandler, + createRegisterSubminerProtocolClientHandler, +} from './anilist-setup-protocol'; + +test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => { + const calls: string[] = []; + const notify = createNotifyAnilistSetupHandler({ + hasMpvClient: () => true, + showMpvOsd: (message) => calls.push(`osd:${message}`), + showDesktopNotification: () => calls.push('desktop'), + logInfo: () => calls.push('log'), + }); + notify('AniList login success'); + assert.deepEqual(calls, ['osd:AniList login success']); +}); + +test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => { + const consume = createConsumeAnilistSetupTokenFromUrlHandler({ + consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'), + saveToken: () => {}, + setCachedToken: () => {}, + setResolvedState: () => {}, + setSetupPageOpened: () => {}, + onSuccess: () => {}, + closeWindow: () => {}, + }); + assert.equal(consume('subminer://anilist-setup?access_token=ok'), true); + assert.equal(consume('subminer://anilist-setup'), false); +}); + +test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => { + const warnings: string[] = []; + const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({ + consumeAnilistSetupTokenFromUrl: () => false, + logWarn: (message) => warnings.push(message), + }); + + assert.equal(handleProtocolUrl('https://example.com'), false); + assert.equal(handleProtocolUrl('subminer://anilist-setup'), true); + assert.deepEqual(warnings, ['AniList setup protocol URL missing access token']); +}); + +test('createRegisterSubminerProtocolClientHandler registers default app entry', () => { + const calls: string[] = []; + const register = createRegisterSubminerProtocolClientHandler({ + isDefaultApp: () => true, + getArgv: () => ['electron', './entry.js'], + execPath: '/usr/local/bin/electron', + resolvePath: (value) => `/resolved/${value}`, + setAsDefaultProtocolClient: (_scheme, _path, args) => { + calls.push(`register:${String(args?.[0])}`); + return true; + }, + logWarn: (message) => calls.push(`warn:${message}`), + }); + + register(); + assert.deepEqual(calls, ['register:/resolved/./entry.js']); +}); diff --git a/src/main/runtime/anilist-setup-protocol.ts b/src/main/runtime/anilist-setup-protocol.ts new file mode 100644 index 0000000..6c119ce --- /dev/null +++ b/src/main/runtime/anilist-setup-protocol.ts @@ -0,0 +1,91 @@ +export type ConsumeAnilistSetupTokenDeps = { + consumeAnilistSetupCallbackUrl: (input: { + rawUrl: string; + saveToken: (token: string) => void; + setCachedToken: (token: string) => void; + setResolvedState: (resolvedAt: number) => void; + setSetupPageOpened: (opened: boolean) => void; + onSuccess: () => void; + closeWindow: () => void; + }) => boolean; + saveToken: (token: string) => void; + setCachedToken: (token: string) => void; + setResolvedState: (resolvedAt: number) => void; + setSetupPageOpened: (opened: boolean) => void; + onSuccess: () => void; + closeWindow: () => void; +}; + +export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilistSetupTokenDeps) { + return (rawUrl: string): boolean => + deps.consumeAnilistSetupCallbackUrl({ + rawUrl, + saveToken: deps.saveToken, + setCachedToken: deps.setCachedToken, + setResolvedState: deps.setResolvedState, + setSetupPageOpened: deps.setSetupPageOpened, + onSuccess: deps.onSuccess, + closeWindow: deps.closeWindow, + }); +} + +export function createNotifyAnilistSetupHandler(deps: { + hasMpvClient: () => boolean; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + logInfo: (message: string) => void; +}) { + return (message: string): void => { + if (deps.hasMpvClient()) { + deps.showMpvOsd(message); + return; + } + deps.showDesktopNotification('SubMiner AniList', { body: message }); + deps.logInfo(`[AniList setup] ${message}`); + }; +} + +export function createHandleAnilistSetupProtocolUrlHandler(deps: { + consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean; + logWarn: (message: string, details: unknown) => void; +}) { + return (rawUrl: string): boolean => { + if (!rawUrl.startsWith('subminer://anilist-setup')) { + return false; + } + if (deps.consumeAnilistSetupTokenFromUrl(rawUrl)) { + return true; + } + deps.logWarn('AniList setup protocol URL missing access token', { rawUrl }); + return true; + }; +} + +export function createRegisterSubminerProtocolClientHandler(deps: { + isDefaultApp: () => boolean; + getArgv: () => string[]; + execPath: string; + resolvePath: (value: string) => string; + setAsDefaultProtocolClient: ( + scheme: string, + path?: string, + args?: string[], + ) => boolean; + logWarn: (message: string, details?: unknown) => void; +}) { + return (): void => { + try { + const defaultAppEntry = deps.isDefaultApp() ? deps.getArgv()[1] : undefined; + const success = defaultAppEntry + ? deps.setAsDefaultProtocolClient('subminer', deps.execPath, [ + deps.resolvePath(defaultAppEntry), + ]) + : deps.setAsDefaultProtocolClient('subminer'); + if (!success) { + deps.logWarn('Failed to register default protocol handler for subminer:// URLs'); + } + } catch (error) { + deps.logWarn('Failed to register subminer:// protocol handler', error); + } + }; +} diff --git a/src/main/runtime/anilist-setup-window-main-deps.test.ts b/src/main/runtime/anilist-setup-window-main-deps.test.ts new file mode 100644 index 0000000..eceb34d --- /dev/null +++ b/src/main/runtime/anilist-setup-window-main-deps.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOpenAnilistSetupWindowMainDepsHandler } from './anilist-setup-window-main-deps'; + +test('open anilist setup window main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildOpenAnilistSetupWindowMainDepsHandler({ + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => ({}) as never, + buildAuthorizeUrl: () => 'https://anilist.co/auth', + consumeCallbackUrl: () => true, + openSetupInBrowser: (url) => calls.push(`browser:${url}`), + loadManualTokenEntry: () => calls.push('manual'), + redirectUri: 'subminer://anilist-auth', + developerSettingsUrl: 'https://anilist.co/settings/developer', + isAllowedExternalUrl: () => true, + isAllowedNavigationUrl: () => true, + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + clearSetupWindow: () => calls.push('clear'), + setSetupPageOpened: (opened) => calls.push(`opened:${String(opened)}`), + setSetupWindow: () => calls.push('window'), + openExternal: (url) => calls.push(`external:${url}`), + })(); + + assert.equal(deps.maybeFocusExistingSetupWindow(), false); + assert.equal(deps.buildAuthorizeUrl(), 'https://anilist.co/auth'); + assert.equal(deps.consumeCallbackUrl('subminer://anilist-setup?access_token=x'), true); + assert.equal(deps.redirectUri, 'subminer://anilist-auth'); + assert.equal(deps.developerSettingsUrl, 'https://anilist.co/settings/developer'); + assert.equal(deps.isAllowedExternalUrl('https://anilist.co'), true); + assert.equal(deps.isAllowedNavigationUrl('https://anilist.co/oauth'), true); + deps.openSetupInBrowser('https://anilist.co/auth'); + deps.loadManualTokenEntry({} as never, 'https://anilist.co/auth'); + deps.logWarn('warn'); + deps.logError('error', null); + deps.clearSetupWindow(); + deps.setSetupPageOpened(true); + deps.setSetupWindow({} as never); + deps.openExternal('https://anilist.co'); + + assert.deepEqual(calls, [ + 'browser:https://anilist.co/auth', + 'manual', + 'warn:warn', + 'error:error', + 'clear', + 'opened:true', + 'window', + 'external:https://anilist.co', + ]); +}); diff --git a/src/main/runtime/anilist-setup-window-main-deps.ts b/src/main/runtime/anilist-setup-window-main-deps.ts new file mode 100644 index 0000000..1718d28 --- /dev/null +++ b/src/main/runtime/anilist-setup-window-main-deps.ts @@ -0,0 +1,27 @@ +import type { createOpenAnilistSetupWindowHandler } from './anilist-setup-window'; + +type OpenAnilistSetupWindowMainDeps = Parameters[0]; + +export function createBuildOpenAnilistSetupWindowMainDepsHandler( + deps: OpenAnilistSetupWindowMainDeps, +) { + return (): OpenAnilistSetupWindowMainDeps => ({ + maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(), + createSetupWindow: () => deps.createSetupWindow(), + buildAuthorizeUrl: () => deps.buildAuthorizeUrl(), + consumeCallbackUrl: (rawUrl: string) => deps.consumeCallbackUrl(rawUrl), + openSetupInBrowser: (authorizeUrl: string) => deps.openSetupInBrowser(authorizeUrl), + loadManualTokenEntry: (setupWindow, authorizeUrl: string) => + deps.loadManualTokenEntry(setupWindow, authorizeUrl), + redirectUri: deps.redirectUri, + developerSettingsUrl: deps.developerSettingsUrl, + isAllowedExternalUrl: (url: string) => deps.isAllowedExternalUrl(url), + isAllowedNavigationUrl: (url: string) => deps.isAllowedNavigationUrl(url), + logWarn: (message: string, details?: unknown) => deps.logWarn(message, details), + logError: (message: string, details: unknown) => deps.logError(message, details), + clearSetupWindow: () => deps.clearSetupWindow(), + setSetupPageOpened: (opened: boolean) => deps.setSetupPageOpened(opened), + setSetupWindow: (setupWindow) => deps.setSetupWindow(setupWindow), + openExternal: (url: string) => deps.openExternal(url), + }); +} diff --git a/src/main/runtime/anilist-setup-window.test.ts b/src/main/runtime/anilist-setup-window.test.ts new file mode 100644 index 0000000..96b37d3 --- /dev/null +++ b/src/main/runtime/anilist-setup-window.test.ts @@ -0,0 +1,367 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createHandleAnilistSetupWindowClosedHandler, + createMaybeFocusExistingAnilistSetupWindowHandler, + createHandleAnilistSetupWindowOpenedHandler, + createAnilistSetupDidFailLoadHandler, + createAnilistSetupDidFinishLoadHandler, + createAnilistSetupDidNavigateHandler, + createAnilistSetupFallbackHandler, + createAnilistSetupWillNavigateHandler, + createAnilistSetupWillRedirectHandler, + createAnilistSetupWindowOpenHandler, + createHandleManualAnilistSetupSubmissionHandler, + createOpenAnilistSetupWindowHandler, +} from './anilist-setup-window'; + +test('manual anilist setup submission forwards access token to callback consumer', () => { + const consumed: string[] = []; + const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({ + consumeCallbackUrl: (rawUrl) => { + consumed.push(rawUrl); + return true; + }, + redirectUri: 'https://anilist.subminer.moe/', + logWarn: () => {}, + }); + + const handled = handleSubmission('subminer://anilist-setup?access_token=abc123'); + assert.equal(handled, true); + assert.equal(consumed.length, 1); + assert.ok(consumed[0]!.includes('https://anilist.subminer.moe/#access_token=abc123')); +}); + +test('maybe focus anilist setup window focuses existing window', () => { + let focused = false; + const handler = createMaybeFocusExistingAnilistSetupWindowHandler({ + getSetupWindow: () => ({ + focus: () => { + focused = true; + }, + }), + }); + const handled = handler(); + assert.equal(handled, true); + assert.equal(focused, true); +}); + +test('manual anilist setup submission warns on missing token', () => { + const warnings: string[] = []; + const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({ + consumeCallbackUrl: () => false, + redirectUri: 'https://anilist.subminer.moe/', + logWarn: (message) => warnings.push(message), + }); + + const handled = handleSubmission('subminer://anilist-setup'); + assert.equal(handled, true); + assert.deepEqual(warnings, ['AniList setup submission missing access token']); +}); + +test('anilist setup fallback handler triggers browser + manual entry on load fail', () => { + const calls: string[] = []; + const fallback = createAnilistSetupFallbackHandler({ + authorizeUrl: 'https://anilist.co', + developerSettingsUrl: 'https://anilist.co/settings/developer', + setupWindow: { + isDestroyed: () => false, + }, + openSetupInBrowser: () => calls.push('open-browser'), + loadManualTokenEntry: () => calls.push('load-manual'), + logError: () => calls.push('error'), + logWarn: () => calls.push('warn'), + }); + + fallback.onLoadFailure({ + errorCode: -1, + errorDescription: 'failed', + validatedURL: 'about:blank', + }); + + assert.deepEqual(calls, ['error', 'open-browser', 'load-manual']); +}); + +test('anilist setup window open handler denies unsafe url', () => { + const calls: string[] = []; + const handler = createAnilistSetupWindowOpenHandler({ + isAllowedExternalUrl: () => false, + openExternal: () => calls.push('open'), + logWarn: () => calls.push('warn'), + }); + + const result = handler({ url: 'https://malicious.example' }); + assert.deepEqual(result, { action: 'deny' }); + assert.deepEqual(calls, ['warn']); +}); + +test('anilist setup will-navigate handler blocks callback redirect uri', () => { + let prevented = false; + const handler = createAnilistSetupWillNavigateHandler({ + handleManualSubmission: () => false, + consumeCallbackUrl: () => false, + redirectUri: 'https://anilist.subminer.moe/', + isAllowedNavigationUrl: () => true, + logWarn: () => {}, + }); + + handler({ + url: 'https://anilist.subminer.moe/#access_token=abc', + preventDefault: () => { + prevented = true; + }, + }); + + assert.equal(prevented, true); +}); + +test('anilist setup will-navigate handler blocks unsafe urls', () => { + const calls: string[] = []; + let prevented = false; + const handler = createAnilistSetupWillNavigateHandler({ + handleManualSubmission: () => false, + consumeCallbackUrl: () => false, + redirectUri: 'https://anilist.subminer.moe/', + isAllowedNavigationUrl: () => false, + logWarn: () => calls.push('warn'), + }); + + handler({ + url: 'https://unsafe.example', + preventDefault: () => { + prevented = true; + }, + }); + + assert.equal(prevented, true); + assert.deepEqual(calls, ['warn']); +}); + +test('anilist setup will-redirect handler prevents callback redirects', () => { + let prevented = false; + const handler = createAnilistSetupWillRedirectHandler({ + consumeCallbackUrl: () => true, + }); + + handler({ + url: 'https://anilist.subminer.moe/#access_token=abc', + preventDefault: () => { + prevented = true; + }, + }); + + assert.equal(prevented, true); +}); + +test('anilist setup did-navigate handler consumes callback url', () => { + const seen: string[] = []; + const handler = createAnilistSetupDidNavigateHandler({ + consumeCallbackUrl: (url) => { + seen.push(url); + return true; + }, + }); + + handler('https://anilist.subminer.moe/#access_token=abc'); + assert.deepEqual(seen, ['https://anilist.subminer.moe/#access_token=abc']); +}); + +test('anilist setup did-fail-load handler forwards details', () => { + const seen: Array<{ errorCode: number; errorDescription: string; validatedURL: string }> = []; + const handler = createAnilistSetupDidFailLoadHandler({ + onLoadFailure: (details) => seen.push(details), + }); + + handler({ + errorCode: -3, + errorDescription: 'timeout', + validatedURL: 'https://anilist.co/api/v2/oauth/authorize', + }); + + assert.equal(seen.length, 1); + assert.equal(seen[0]!.errorCode, -3); +}); + +test('anilist setup did-finish-load handler triggers fallback on blank page', () => { + const calls: string[] = []; + const handler = createAnilistSetupDidFinishLoadHandler({ + getLoadedUrl: () => 'about:blank', + onBlankPageLoaded: () => calls.push('fallback'), + }); + + handler(); + assert.deepEqual(calls, ['fallback']); +}); + +test('anilist setup did-finish-load handler no-ops on non-blank page', () => { + const calls: string[] = []; + const handler = createAnilistSetupDidFinishLoadHandler({ + getLoadedUrl: () => 'https://anilist.co/api/v2/oauth/authorize', + onBlankPageLoaded: () => calls.push('fallback'), + }); + + handler(); + assert.equal(calls.length, 0); +}); + +test('anilist setup window closed handler clears references', () => { + const calls: string[] = []; + const handler = createHandleAnilistSetupWindowClosedHandler({ + clearSetupWindow: () => calls.push('clear-window'), + setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), + }); + + handler(); + assert.deepEqual(calls, ['clear-window', 'opened:no']); +}); + +test('anilist setup window opened handler sets references', () => { + const calls: string[] = []; + const handler = createHandleAnilistSetupWindowOpenedHandler({ + setSetupWindow: () => calls.push('set-window'), + setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), + }); + + handler(); + assert.deepEqual(calls, ['set-window', 'opened:yes']); +}); + +test('open anilist setup handler no-ops when existing setup window focused', () => { + const calls: string[] = []; + const handler = createOpenAnilistSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => { + calls.push('focus-existing'); + return true; + }, + createSetupWindow: () => { + calls.push('create-window'); + throw new Error('should not create'); + }, + buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084', + consumeCallbackUrl: () => false, + openSetupInBrowser: () => {}, + loadManualTokenEntry: () => {}, + redirectUri: 'https://anilist.subminer.moe/', + developerSettingsUrl: 'https://anilist.co/settings/developer', + isAllowedExternalUrl: () => true, + isAllowedNavigationUrl: () => true, + logWarn: () => {}, + logError: () => {}, + clearSetupWindow: () => {}, + setSetupPageOpened: () => {}, + setSetupWindow: () => {}, + openExternal: () => {}, + }); + + handler(); + assert.deepEqual(calls, ['focus-existing']); +}); + +test('open anilist setup handler wires navigation, fallback, and lifecycle', () => { + let openHandler: ((params: { url: string }) => { action: 'deny' }) | null = null; + let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null; + let didNavigateHandler: ((event: unknown, url: string) => void) | null = null; + let didFinishLoadHandler: (() => void) | null = null; + let didFailLoadHandler: + | ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void) + | null = null; + let closedHandler: (() => void) | null = null; + let prevented = false; + const calls: string[] = []; + + const fakeWindow = { + focus: () => {}, + webContents: { + setWindowOpenHandler: (handler: (params: { url: string }) => { action: 'deny' }) => { + openHandler = handler; + }, + on: ( + event: 'will-navigate' | 'will-redirect' | 'did-navigate' | 'did-fail-load' | 'did-finish-load', + handler: (...args: any[]) => void, + ) => { + if (event === 'will-navigate') willNavigateHandler = handler as never; + if (event === 'did-navigate') didNavigateHandler = handler as never; + if (event === 'did-finish-load') didFinishLoadHandler = handler as never; + if (event === 'did-fail-load') didFailLoadHandler = handler as never; + }, + getURL: () => 'about:blank', + }, + on: (event: 'closed', handler: () => void) => { + if (event === 'closed') closedHandler = handler; + }, + isDestroyed: () => false, + }; + + const handler = createOpenAnilistSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => fakeWindow, + buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084', + consumeCallbackUrl: (rawUrl) => { + calls.push(`consume:${rawUrl}`); + return rawUrl.includes('access_token='); + }, + openSetupInBrowser: () => calls.push('open-browser'), + loadManualTokenEntry: () => calls.push('load-manual'), + redirectUri: 'https://anilist.subminer.moe/', + developerSettingsUrl: 'https://anilist.co/settings/developer', + isAllowedExternalUrl: () => true, + isAllowedNavigationUrl: () => true, + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + clearSetupWindow: () => calls.push('clear-window'), + setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), + setSetupWindow: () => calls.push('set-window'), + openExternal: (url) => calls.push(`open:${url}`), + }); + + handler(); + assert.ok(openHandler); + assert.ok(willNavigateHandler); + assert.ok(didNavigateHandler); + assert.ok(didFinishLoadHandler); + assert.ok(didFailLoadHandler); + assert.ok(closedHandler); + assert.deepEqual(calls.slice(0, 3), ['load-manual', 'set-window', 'opened:yes']); + + const onOpen = openHandler as ((params: { url: string }) => { action: 'deny' }) | null; + if (!onOpen) throw new Error('missing window open handler'); + assert.deepEqual(onOpen({ url: 'https://anilist.co/settings/developer' }), { action: 'deny' }); + assert.ok(calls.includes('open:https://anilist.co/settings/developer')); + + const onWillNavigate = willNavigateHandler as + | ((event: { preventDefault: () => void }, url: string) => void) + | null; + if (!onWillNavigate) throw new Error('missing will navigate handler'); + onWillNavigate( + { + preventDefault: () => { + prevented = true; + }, + }, + 'https://anilist.subminer.moe/#access_token=abc', + ); + assert.equal(prevented, true); + + const onDidNavigate = didNavigateHandler as ((event: unknown, url: string) => void) | null; + if (!onDidNavigate) throw new Error('missing did navigate handler'); + onDidNavigate({}, 'https://anilist.subminer.moe/#access_token=abc'); + + const onDidFinishLoad = didFinishLoadHandler as (() => void) | null; + if (!onDidFinishLoad) throw new Error('missing did finish load handler'); + onDidFinishLoad(); + assert.ok(calls.includes('warn:AniList setup loaded a blank page; using fallback')); + assert.ok(calls.includes('open-browser')); + + const onDidFailLoad = didFailLoadHandler as + | ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void) + | null; + if (!onDidFailLoad) throw new Error('missing did fail load handler'); + onDidFailLoad({}, -1, 'load failed', 'about:blank'); + assert.ok(calls.includes('error:AniList setup window failed to load')); + + const onClosed = closedHandler as (() => void) | null; + if (!onClosed) throw new Error('missing closed handler'); + onClosed(); + assert.ok(calls.includes('clear-window')); + assert.ok(calls.includes('opened:no')); +}); diff --git a/src/main/runtime/anilist-setup-window.ts b/src/main/runtime/anilist-setup-window.ts new file mode 100644 index 0000000..636f404 --- /dev/null +++ b/src/main/runtime/anilist-setup-window.ts @@ -0,0 +1,323 @@ +type SetupWindowLike = { + isDestroyed: () => boolean; +}; + +type OpenHandlerDecision = { action: 'deny' }; + +type FocusableWindowLike = { + focus: () => void; +}; + +type AnilistSetupWebContentsLike = { + setWindowOpenHandler: (...args: any[]) => unknown; + on: (...args: any[]) => unknown; + getURL: () => string; +}; + +type AnilistSetupWindowLike = FocusableWindowLike & { + webContents: AnilistSetupWebContentsLike; + on: (...args: any[]) => unknown; + isDestroyed: () => boolean; +}; + +export function createHandleManualAnilistSetupSubmissionHandler(deps: { + consumeCallbackUrl: (rawUrl: string) => boolean; + redirectUri: string; + logWarn: (message: string) => void; +}) { + return (rawUrl: string): boolean => { + if (!rawUrl.startsWith('subminer://anilist-setup')) { + return false; + } + try { + const parsed = new URL(rawUrl); + const accessToken = parsed.searchParams.get('access_token')?.trim() ?? ''; + if (accessToken.length > 0) { + return deps.consumeCallbackUrl( + `${deps.redirectUri}#access_token=${encodeURIComponent(accessToken)}`, + ); + } + deps.logWarn('AniList setup submission missing access token'); + return true; + } catch { + deps.logWarn('AniList setup submission had invalid callback input'); + return true; + } + }; +} + +export function createMaybeFocusExistingAnilistSetupWindowHandler(deps: { + getSetupWindow: () => FocusableWindowLike | null; +}) { + return (): boolean => { + const window = deps.getSetupWindow(); + if (!window) { + return false; + } + window.focus(); + return true; + }; +} + +export function createAnilistSetupWindowOpenHandler(deps: { + isAllowedExternalUrl: (url: string) => boolean; + openExternal: (url: string) => void; + logWarn: (message: string, details?: unknown) => void; +}) { + return ({ url }: { url: string }): OpenHandlerDecision => { + if (!deps.isAllowedExternalUrl(url)) { + deps.logWarn('Blocked unsafe AniList setup external URL', { url }); + return { action: 'deny' }; + } + deps.openExternal(url); + return { action: 'deny' }; + }; +} + +export function createAnilistSetupWillNavigateHandler(deps: { + handleManualSubmission: (url: string) => boolean; + consumeCallbackUrl: (url: string) => boolean; + redirectUri: string; + isAllowedNavigationUrl: (url: string) => boolean; + logWarn: (message: string, details?: unknown) => void; +}) { + return (params: { url: string; preventDefault: () => void }): void => { + const { url, preventDefault } = params; + if (deps.handleManualSubmission(url)) { + preventDefault(); + return; + } + if (deps.consumeCallbackUrl(url)) { + preventDefault(); + return; + } + if (url.startsWith(deps.redirectUri)) { + preventDefault(); + return; + } + if (url.startsWith(`${deps.redirectUri}#`)) { + preventDefault(); + return; + } + if (deps.isAllowedNavigationUrl(url)) { + return; + } + preventDefault(); + deps.logWarn('Blocked unsafe AniList setup navigation URL', { url }); + }; +} + +export function createAnilistSetupWillRedirectHandler(deps: { + consumeCallbackUrl: (url: string) => boolean; +}) { + return (params: { url: string; preventDefault: () => void }): void => { + if (deps.consumeCallbackUrl(params.url)) { + params.preventDefault(); + } + }; +} + +export function createAnilistSetupDidNavigateHandler(deps: { + consumeCallbackUrl: (url: string) => boolean; +}) { + return (url: string): void => { + deps.consumeCallbackUrl(url); + }; +} + +export function createAnilistSetupDidFailLoadHandler(deps: { + onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => void; +}) { + return (details: { errorCode: number; errorDescription: string; validatedURL: string }): void => { + deps.onLoadFailure(details); + }; +} + +export function createAnilistSetupDidFinishLoadHandler(deps: { + getLoadedUrl: () => string; + onBlankPageLoaded: () => void; +}) { + return (): void => { + const loadedUrl = deps.getLoadedUrl(); + if (!loadedUrl || loadedUrl === 'about:blank') { + deps.onBlankPageLoaded(); + } + }; +} + +export function createHandleAnilistSetupWindowClosedHandler(deps: { + clearSetupWindow: () => void; + setSetupPageOpened: (opened: boolean) => void; +}) { + return (): void => { + deps.clearSetupWindow(); + deps.setSetupPageOpened(false); + }; +} + +export function createHandleAnilistSetupWindowOpenedHandler(deps: { + setSetupWindow: () => void; + setSetupPageOpened: (opened: boolean) => void; +}) { + return (): void => { + deps.setSetupWindow(); + deps.setSetupPageOpened(true); + }; +} + +export function createAnilistSetupFallbackHandler(deps: { + authorizeUrl: string; + developerSettingsUrl: string; + setupWindow: SetupWindowLike; + openSetupInBrowser: () => void; + loadManualTokenEntry: () => void; + logError: (message: string, details: unknown) => void; + logWarn: (message: string) => void; +}) { + return { + onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => { + deps.logError('AniList setup window failed to load', details); + deps.openSetupInBrowser(); + if (!deps.setupWindow.isDestroyed()) { + deps.loadManualTokenEntry(); + } + }, + onBlankPageLoaded: () => { + deps.logWarn('AniList setup loaded a blank page; using fallback'); + deps.openSetupInBrowser(); + if (!deps.setupWindow.isDestroyed()) { + deps.loadManualTokenEntry(); + } + }, + }; +} + +export function createOpenAnilistSetupWindowHandler(deps: { + maybeFocusExistingSetupWindow: () => boolean; + createSetupWindow: () => TWindow; + buildAuthorizeUrl: () => string; + consumeCallbackUrl: (rawUrl: string) => boolean; + openSetupInBrowser: (authorizeUrl: string) => void; + loadManualTokenEntry: (setupWindow: TWindow, authorizeUrl: string) => void; + redirectUri: string; + developerSettingsUrl: string; + isAllowedExternalUrl: (url: string) => boolean; + isAllowedNavigationUrl: (url: string) => boolean; + logWarn: (message: string, details?: unknown) => void; + logError: (message: string, details: unknown) => void; + clearSetupWindow: () => void; + setSetupPageOpened: (opened: boolean) => void; + setSetupWindow: (window: TWindow) => void; + openExternal: (url: string) => void; +}) { + return (): void => { + if (deps.maybeFocusExistingSetupWindow()) { + return; + } + + const setupWindow = deps.createSetupWindow(); + const authorizeUrl = deps.buildAuthorizeUrl(); + const consumeCallbackUrl = (rawUrl: string): boolean => deps.consumeCallbackUrl(rawUrl); + const openSetupInBrowser = () => deps.openSetupInBrowser(authorizeUrl); + const loadManualTokenEntry = () => deps.loadManualTokenEntry(setupWindow, authorizeUrl); + const handleManualSubmission = createHandleManualAnilistSetupSubmissionHandler({ + consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl), + redirectUri: deps.redirectUri, + logWarn: (message) => deps.logWarn(message), + }); + const fallback = createAnilistSetupFallbackHandler({ + authorizeUrl, + developerSettingsUrl: deps.developerSettingsUrl, + setupWindow, + openSetupInBrowser, + loadManualTokenEntry, + logError: (message, details) => deps.logError(message, details), + logWarn: (message) => deps.logWarn(message), + }); + const handleWindowOpen = createAnilistSetupWindowOpenHandler({ + isAllowedExternalUrl: (url) => deps.isAllowedExternalUrl(url), + openExternal: (url) => deps.openExternal(url), + logWarn: (message, details) => deps.logWarn(message, details), + }); + const handleWillNavigate = createAnilistSetupWillNavigateHandler({ + handleManualSubmission: (url) => handleManualSubmission(url), + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + redirectUri: deps.redirectUri, + isAllowedNavigationUrl: (url) => deps.isAllowedNavigationUrl(url), + logWarn: (message, details) => deps.logWarn(message, details), + }); + const handleWillRedirect = createAnilistSetupWillRedirectHandler({ + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + }); + const handleDidNavigate = createAnilistSetupDidNavigateHandler({ + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + }); + const handleDidFailLoad = createAnilistSetupDidFailLoadHandler({ + onLoadFailure: (details) => fallback.onLoadFailure(details), + }); + const handleDidFinishLoad = createAnilistSetupDidFinishLoadHandler({ + getLoadedUrl: () => setupWindow.webContents.getURL(), + onBlankPageLoaded: () => fallback.onBlankPageLoaded(), + }); + const handleWindowClosed = createHandleAnilistSetupWindowClosedHandler({ + clearSetupWindow: () => deps.clearSetupWindow(), + setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened), + }); + const handleWindowOpened = createHandleAnilistSetupWindowOpenedHandler({ + setSetupWindow: () => deps.setSetupWindow(setupWindow), + setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened), + }); + + setupWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => + handleWindowOpen({ url }), + ); + setupWindow.webContents.on('will-navigate', (event: unknown, url: string) => { + handleWillNavigate({ + url, + preventDefault: () => { + if (event && typeof event === 'object' && 'preventDefault' in event) { + const typedEvent = event as { preventDefault?: () => void }; + typedEvent.preventDefault?.(); + } + }, + }); + }); + setupWindow.webContents.on('will-redirect', (event: unknown, url: string) => { + handleWillRedirect({ + url, + preventDefault: () => { + if (event && typeof event === 'object' && 'preventDefault' in event) { + const typedEvent = event as { preventDefault?: () => void }; + typedEvent.preventDefault?.(); + } + }, + }); + }); + setupWindow.webContents.on('did-navigate', (_event: unknown, url: string) => { + handleDidNavigate(url); + }); + setupWindow.webContents.on( + 'did-fail-load', + ( + _event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + ) => { + handleDidFailLoad({ + errorCode, + errorDescription, + validatedURL, + }); + }, + ); + setupWindow.webContents.on('did-finish-load', () => { + handleDidFinishLoad(); + }); + loadManualTokenEntry(); + setupWindow.on('closed', () => { + handleWindowClosed(); + }); + handleWindowOpened(); + }; +} diff --git a/src/main/runtime/anilist-setup.test.ts b/src/main/runtime/anilist-setup.test.ts new file mode 100644 index 0000000..80e8247 --- /dev/null +++ b/src/main/runtime/anilist-setup.test.ts @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAnilistSetupFallbackHtml, + buildAnilistManualTokenEntryHtml, + buildAnilistSetupUrl, + consumeAnilistSetupCallbackUrl, + extractAnilistAccessTokenFromUrl, + findAnilistSetupDeepLinkArgvUrl, +} from './anilist-setup'; + +test('buildAnilistSetupUrl includes required query params', () => { + const url = buildAnilistSetupUrl({ + authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize', + clientId: '36084', + responseType: 'token', + redirectUri: 'https://anilist.subminer.moe/', + }); + assert.match(url, /client_id=36084/); + assert.match(url, /response_type=token/); + assert.match(url, /redirect_uri=https%3A%2F%2Fanilist\.subminer\.moe%2F/); +}); + +test('buildAnilistSetupUrl omits redirect_uri when unset', () => { + const url = buildAnilistSetupUrl({ + authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize', + clientId: '36084', + responseType: 'token', + }); + assert.match(url, /client_id=36084/); + assert.match(url, /response_type=token/); + assert.equal(url.includes('redirect_uri='), false); +}); + +test('buildAnilistSetupFallbackHtml escapes reason content', () => { + const html = buildAnilistSetupFallbackHtml({ + reason: '', + authorizeUrl: 'https://anilist.example/auth', + developerSettingsUrl: 'https://anilist.example/dev', + }); + assert.equal(html.includes(''), false); + assert.match(html, /<script>alert\(1\)<\/script>/); +}); + +test('buildAnilistManualTokenEntryHtml includes access-token submit route only', () => { + const html = buildAnilistManualTokenEntryHtml({ + authorizeUrl: 'https://anilist.example/auth', + developerSettingsUrl: 'https://anilist.example/dev', + }); + assert.match(html, /subminer:\/\/anilist-setup\?access_token=/); + assert.equal(html.includes('callback_url='), false); + assert.equal(html.includes('subminer://anilist-setup?code='), false); +}); + +test('extractAnilistAccessTokenFromUrl returns access token from hash fragment', () => { + const token = extractAnilistAccessTokenFromUrl( + 'https://anilist.subminer.moe/#access_token=token-from-hash&token_type=Bearer', + ); + assert.equal(token, 'token-from-hash'); +}); + +test('extractAnilistAccessTokenFromUrl returns access token from query', () => { + const token = extractAnilistAccessTokenFromUrl( + 'https://anilist.subminer.moe/?access_token=token-from-query&token_type=Bearer', + ); + assert.equal(token, 'token-from-query'); +}); + +test('findAnilistSetupDeepLinkArgvUrl finds subminer deep link from argv', () => { + const rawUrl = findAnilistSetupDeepLinkArgvUrl([ + '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + '--start', + 'subminer://anilist-setup?access_token=argv-token', + ]); + assert.equal(rawUrl, 'subminer://anilist-setup?access_token=argv-token'); +}); + +test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => { + const rawUrl = findAnilistSetupDeepLinkArgvUrl([ + '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + '--start', + ]); + assert.equal(rawUrl, null); +}); + +test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => { + const events: string[] = []; + const handled = consumeAnilistSetupCallbackUrl({ + rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token', + saveToken: (value: string) => events.push(`save:${value}`), + setCachedToken: (value: string) => events.push(`cache:${value}`), + setResolvedState: (timestampMs: number) => + events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`), + setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`), + onSuccess: () => events.push('success'), + closeWindow: () => events.push('close'), + }); + + assert.equal(handled, true); + assert.deepEqual(events, [ + 'save:saved-token', + 'cache:saved-token', + 'state:ok', + 'opened:false', + 'success', + 'close', + ]); +}); + +test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => { + const events: string[] = []; + const handled = consumeAnilistSetupCallbackUrl({ + rawUrl: 'subminer://anilist-setup?access_token=saved-token', + saveToken: (value: string) => events.push(`save:${value}`), + setCachedToken: (value: string) => events.push(`cache:${value}`), + setResolvedState: (timestampMs: number) => + events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`), + setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`), + onSuccess: () => events.push('success'), + closeWindow: () => events.push('close'), + }); + + assert.equal(handled, true); + assert.deepEqual(events, [ + 'save:saved-token', + 'cache:saved-token', + 'state:ok', + 'opened:false', + 'success', + 'close', + ]); +}); + +test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => { + const events: string[] = []; + const handled = consumeAnilistSetupCallbackUrl({ + rawUrl: 'https://anilist.co/settings/developer', + saveToken: () => events.push('save'), + setCachedToken: () => events.push('cache'), + setResolvedState: () => events.push('state'), + setSetupPageOpened: () => events.push('opened'), + onSuccess: () => events.push('success'), + closeWindow: () => events.push('close'), + }); + + assert.equal(handled, false); + assert.deepEqual(events, []); +}); diff --git a/src/main/runtime/anilist-setup.ts b/src/main/runtime/anilist-setup.ts new file mode 100644 index 0000000..afca57a --- /dev/null +++ b/src/main/runtime/anilist-setup.ts @@ -0,0 +1,177 @@ +import type { BrowserWindow } from 'electron'; +import type { ResolvedConfig } from '../../types'; + +export type BuildAnilistSetupUrlDeps = { + authorizeUrl: string; + clientId: string; + responseType: string; + redirectUri?: string; +}; + +export type ConsumeAnilistSetupCallbackUrlDeps = { + rawUrl: string; + saveToken: (token: string) => void; + setCachedToken: (token: string) => void; + setResolvedState: (resolvedAt: number) => void; + setSetupPageOpened: (opened: boolean) => void; + onSuccess: () => void; + closeWindow: () => void; +}; + +export function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean { + return resolved.anilist.enabled; +} + +export function buildAnilistSetupUrl(params: BuildAnilistSetupUrlDeps): string { + const authorizeUrl = new URL(params.authorizeUrl); + authorizeUrl.searchParams.set('client_id', params.clientId); + authorizeUrl.searchParams.set('response_type', params.responseType); + if (params.redirectUri && params.redirectUri.trim().length > 0) { + authorizeUrl.searchParams.set('redirect_uri', params.redirectUri); + } + return authorizeUrl.toString(); +} + +export function extractAnilistAccessTokenFromUrl(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl); + + const fromQuery = parsed.searchParams.get('access_token')?.trim(); + if (fromQuery && fromQuery.length > 0) { + return fromQuery; + } + + const hash = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash; + if (hash.length === 0) { + return null; + } + const hashParams = new URLSearchParams(hash); + const fromHash = hashParams.get('access_token')?.trim(); + if (fromHash && fromHash.length > 0) { + return fromHash; + } + return null; + } catch { + return null; + } +} + +export function findAnilistSetupDeepLinkArgvUrl(argv: readonly string[]): string | null { + for (const value of argv) { + if (value.startsWith('subminer://anilist-setup')) { + return value; + } + } + return null; +} + +export function consumeAnilistSetupCallbackUrl( + deps: ConsumeAnilistSetupCallbackUrlDeps, +): boolean { + const token = extractAnilistAccessTokenFromUrl(deps.rawUrl); + if (!token) { + return false; + } + + const resolvedAt = Date.now(); + deps.saveToken(token); + deps.setCachedToken(token); + deps.setResolvedState(resolvedAt); + deps.setSetupPageOpened(false); + deps.onSuccess(); + deps.closeWindow(); + return true; +} + +export function openAnilistSetupInBrowser(params: { + authorizeUrl: string; + openExternal: (url: string) => Promise; + logError: (message: string, error: unknown) => void; +}): void { + void params.openExternal(params.authorizeUrl).catch((error) => { + params.logError('Failed to open AniList authorize URL in browser', error); + }); +} + +export function buildAnilistSetupFallbackHtml(params: { + reason: string; + authorizeUrl: string; + developerSettingsUrl: string; +}): string { + const safeReason = params.reason.replace(//g, '>'); + const safeAuth = params.authorizeUrl.replace(/"/g, '"'); + const safeDev = params.developerSettingsUrl.replace(/"/g, '"'); + return ` +AniList Setup + +

AniList setup

+

Automatic page load failed (${safeReason}).

+

Open AniList authorize page

+

Open AniList developer settings

+`; +} + +export function buildAnilistManualTokenEntryHtml(params: { + authorizeUrl: string; + developerSettingsUrl: string; +}): string { + const safeAuth = params.authorizeUrl.replace(/"/g, '"'); + const safeDev = params.developerSettingsUrl.replace(/"/g, '"'); + return ` +AniList Setup + +

AniList setup

+

Authorize in browser, then paste the access token below.

+

Open AniList authorize page

+

Open AniList developer settings

+
+
+ +
+ +
+ +`; +} + +export function loadAnilistSetupFallback(params: { + setupWindow: BrowserWindow; + reason: string; + authorizeUrl: string; + developerSettingsUrl: string; + logWarn: (message: string, data: unknown) => void; +}): void { + const html = buildAnilistSetupFallbackHtml({ + reason: params.reason, + authorizeUrl: params.authorizeUrl, + developerSettingsUrl: params.developerSettingsUrl, + }); + void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + params.logWarn('Loaded AniList setup fallback page', { reason: params.reason }); +} + +export function loadAnilistManualTokenEntry(params: { + setupWindow: BrowserWindow; + authorizeUrl: string; + developerSettingsUrl: string; + logWarn: (message: string, data: unknown) => void; +}): void { + const html = buildAnilistManualTokenEntryHtml({ + authorizeUrl: params.authorizeUrl, + developerSettingsUrl: params.developerSettingsUrl, + }); + void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + params.logWarn('Loaded AniList manual token entry page', { + authorizeUrl: params.authorizeUrl, + }); +} diff --git a/src/main/runtime/anilist-state.test.ts b/src/main/runtime/anilist-state.test.ts new file mode 100644 index 0000000..bc53248 --- /dev/null +++ b/src/main/runtime/anilist-state.test.ts @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAnilistStateRuntime } from './anilist-state'; +import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state'; + +function createRuntime() { + let clientState: AnilistSecretResolutionState = { + status: 'resolved', + source: 'stored', + message: 'ok' as string | null, + resolvedAt: 1000 as number | null, + errorAt: null as number | null, + }; + let queueState: AnilistRetryQueueState = { + pending: 1, + ready: 2, + deadLetter: 3, + lastAttemptAt: 2000 as number | null, + lastError: 'none' as string | null, + }; + let clearedStoredToken = false; + let clearedCachedToken = false; + + const runtime = createAnilistStateRuntime({ + getClientSecretState: () => clientState, + setClientSecretState: (next) => { + clientState = next; + }, + getRetryQueueState: () => queueState, + setRetryQueueState: (next) => { + queueState = next; + }, + getUpdateQueueSnapshot: () => ({ + pending: 7, + ready: 8, + deadLetter: 9, + }), + clearStoredToken: () => { + clearedStoredToken = true; + }, + clearCachedAccessToken: () => { + clearedCachedToken = true; + }, + }); + + return { + runtime, + getClientState: () => clientState, + getQueueState: () => queueState, + getClearedStoredToken: () => clearedStoredToken, + getClearedCachedToken: () => clearedCachedToken, + }; +} + +test('setClientSecretState merges partial updates', () => { + const harness = createRuntime(); + harness.runtime.setClientSecretState({ + status: 'error', + source: 'none', + errorAt: 4000, + }); + + assert.deepEqual(harness.getClientState(), { + status: 'error', + source: 'none', + message: 'ok', + resolvedAt: 1000, + errorAt: 4000, + }); +}); + +test('queue refresh preserves metadata while syncing counts', () => { + const harness = createRuntime(); + const snapshot = harness.runtime.getQueueStatusSnapshot(); + + assert.deepEqual(snapshot, { + pending: 7, + ready: 8, + deadLetter: 9, + lastAttemptAt: 2000, + lastError: 'none', + }); + assert.deepEqual(harness.getQueueState(), snapshot); +}); + +test('clearTokenState resets token state and clears caches', () => { + const harness = createRuntime(); + const queueBeforeClear = { ...harness.getQueueState() }; + harness.runtime.clearTokenState(); + + assert.equal(harness.getClearedStoredToken(), true); + assert.equal(harness.getClearedCachedToken(), true); + assert.deepEqual(harness.getClientState(), { + status: 'not_checked', + source: 'none', + message: 'stored token cleared', + resolvedAt: null, + errorAt: null, + }); + assert.deepEqual(harness.getQueueState(), queueBeforeClear); +}); diff --git a/src/main/runtime/anilist-state.ts b/src/main/runtime/anilist-state.ts new file mode 100644 index 0000000..1a90230 --- /dev/null +++ b/src/main/runtime/anilist-state.ts @@ -0,0 +1,97 @@ +import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state'; + +type AnilistQueueSnapshot = Pick; + +type AnilistStatusSnapshot = { + tokenStatus: AnilistSecretResolutionState['status']; + tokenSource: AnilistSecretResolutionState['source']; + tokenMessage: string | null; + tokenResolvedAt: number | null; + tokenErrorAt: number | null; + queuePending: number; + queueReady: number; + queueDeadLetter: number; + queueLastAttemptAt: number | null; + queueLastError: string | null; +}; + +export type AnilistStateRuntimeDeps = { + getClientSecretState: () => AnilistSecretResolutionState; + setClientSecretState: (next: AnilistSecretResolutionState) => void; + getRetryQueueState: () => AnilistRetryQueueState; + setRetryQueueState: (next: AnilistRetryQueueState) => void; + getUpdateQueueSnapshot: () => AnilistQueueSnapshot; + clearStoredToken: () => void; + clearCachedAccessToken: () => void; +}; + +export function createAnilistStateRuntime(deps: AnilistStateRuntimeDeps): { + setClientSecretState: (partial: Partial) => void; + refreshRetryQueueState: () => void; + getStatusSnapshot: () => AnilistStatusSnapshot; + getQueueStatusSnapshot: () => AnilistRetryQueueState; + clearTokenState: () => void; +} { + const setClientSecretState = (partial: Partial): void => { + deps.setClientSecretState({ + ...deps.getClientSecretState(), + ...partial, + }); + }; + + const refreshRetryQueueState = (): void => { + deps.setRetryQueueState({ + ...deps.getRetryQueueState(), + ...deps.getUpdateQueueSnapshot(), + }); + }; + + const getStatusSnapshot = (): AnilistStatusSnapshot => { + const client = deps.getClientSecretState(); + const queue = deps.getRetryQueueState(); + return { + tokenStatus: client.status, + tokenSource: client.source, + tokenMessage: client.message, + tokenResolvedAt: client.resolvedAt, + tokenErrorAt: client.errorAt, + queuePending: queue.pending, + queueReady: queue.ready, + queueDeadLetter: queue.deadLetter, + queueLastAttemptAt: queue.lastAttemptAt, + queueLastError: queue.lastError, + }; + }; + + const getQueueStatusSnapshot = (): AnilistRetryQueueState => { + refreshRetryQueueState(); + const queue = deps.getRetryQueueState(); + return { + pending: queue.pending, + ready: queue.ready, + deadLetter: queue.deadLetter, + lastAttemptAt: queue.lastAttemptAt, + lastError: queue.lastError, + }; + }; + + const clearTokenState = (): void => { + deps.clearStoredToken(); + deps.clearCachedAccessToken(); + setClientSecretState({ + status: 'not_checked', + source: 'none', + message: 'stored token cleared', + resolvedAt: null, + errorAt: null, + }); + }; + + return { + setClientSecretState, + refreshRetryQueueState, + getStatusSnapshot, + getQueueStatusSnapshot, + clearTokenState, + }; +} diff --git a/src/main/runtime/anilist-token-refresh-main-deps.test.ts b/src/main/runtime/anilist-token-refresh-main-deps.test.ts new file mode 100644 index 0000000..b6b4a72 --- /dev/null +++ b/src/main/runtime/anilist-token-refresh-main-deps.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './anilist-token-refresh-main-deps'; + +test('refresh anilist client secret state main deps builder maps callbacks', () => { + const calls: string[] = []; + const config = { anilist: { accessToken: 'token' } }; + const deps = createBuildRefreshAnilistClientSecretStateMainDepsHandler({ + getResolvedConfig: () => config as never, + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => 'cached', + setCachedAccessToken: () => calls.push('set-cache'), + saveStoredToken: () => calls.push('save'), + loadStoredToken: () => 'stored', + setClientSecretState: () => calls.push('set-state'), + getAnilistSetupPageOpened: () => false, + setAnilistSetupPageOpened: () => calls.push('set-opened'), + openAnilistSetupWindow: () => calls.push('open-window'), + now: () => 123, + })(); + + assert.equal(deps.getResolvedConfig(), config); + assert.equal(deps.isAnilistTrackingEnabled(config as never), true); + assert.equal(deps.getCachedAccessToken(), 'cached'); + deps.setCachedAccessToken(null); + deps.saveStoredToken('x'); + assert.equal(deps.loadStoredToken(), 'stored'); + deps.setClientSecretState({} as never); + assert.equal(deps.getAnilistSetupPageOpened(), false); + deps.setAnilistSetupPageOpened(true); + deps.openAnilistSetupWindow(); + assert.equal(deps.now(), 123); + assert.deepEqual(calls, ['set-cache', 'save', 'set-state', 'set-opened', 'open-window']); +}); diff --git a/src/main/runtime/anilist-token-refresh-main-deps.ts b/src/main/runtime/anilist-token-refresh-main-deps.ts new file mode 100644 index 0000000..40e8dad --- /dev/null +++ b/src/main/runtime/anilist-token-refresh-main-deps.ts @@ -0,0 +1,23 @@ +import type { createRefreshAnilistClientSecretStateHandler } from './anilist-token-refresh'; + +type RefreshAnilistClientSecretStateMainDeps = Parameters< + typeof createRefreshAnilistClientSecretStateHandler +>[0]; + +export function createBuildRefreshAnilistClientSecretStateMainDepsHandler( + deps: RefreshAnilistClientSecretStateMainDeps, +) { + return (): RefreshAnilistClientSecretStateMainDeps => ({ + getResolvedConfig: () => deps.getResolvedConfig(), + isAnilistTrackingEnabled: (config) => deps.isAnilistTrackingEnabled(config), + getCachedAccessToken: () => deps.getCachedAccessToken(), + setCachedAccessToken: (token) => deps.setCachedAccessToken(token), + saveStoredToken: (token: string) => deps.saveStoredToken(token), + loadStoredToken: () => deps.loadStoredToken(), + setClientSecretState: (state) => deps.setClientSecretState(state), + getAnilistSetupPageOpened: () => deps.getAnilistSetupPageOpened(), + setAnilistSetupPageOpened: (opened: boolean) => deps.setAnilistSetupPageOpened(opened), + openAnilistSetupWindow: () => deps.openAnilistSetupWindow(), + now: () => deps.now(), + }); +} diff --git a/src/main/runtime/anilist-token-refresh.test.ts b/src/main/runtime/anilist-token-refresh.test.ts new file mode 100644 index 0000000..b41406e --- /dev/null +++ b/src/main/runtime/anilist-token-refresh.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRefreshAnilistClientSecretStateHandler } from './anilist-token-refresh'; + +test('refresh handler marks state not_checked when tracking disabled', async () => { + let cached: string | null = 'abc'; + let opened = true; + const states: Array<{ status: string; source: string }> = []; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: '' } }), + isAnilistTrackingEnabled: () => false, + getCachedAccessToken: () => cached, + setCachedAccessToken: (token) => { + cached = token; + }, + saveStoredToken: () => {}, + loadStoredToken: () => '', + setClientSecretState: (state) => { + states.push({ status: state.status, source: state.source }); + }, + getAnilistSetupPageOpened: () => opened, + setAnilistSetupPageOpened: (next) => { + opened = next; + }, + openAnilistSetupWindow: () => {}, + now: () => 100, + }); + + const token = await refresh(); + assert.equal(token, null); + assert.equal(cached, null); + assert.equal(opened, false); + assert.deepEqual(states, [{ status: 'not_checked', source: 'none' }]); +}); + +test('refresh handler uses literal config token and stores it', async () => { + let cached: string | null = null; + const saves: string[] = []; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: ' token-1 ' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => cached, + setCachedAccessToken: (token) => { + cached = token; + }, + saveStoredToken: (token) => saves.push(token), + loadStoredToken: () => '', + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => false, + setAnilistSetupPageOpened: () => {}, + openAnilistSetupWindow: () => {}, + now: () => 200, + }); + + const token = await refresh({ force: true }); + assert.equal(token, 'token-1'); + assert.equal(cached, 'token-1'); + assert.deepEqual(saves, ['token-1']); +}); + +test('refresh handler prefers cached token when not forced', async () => { + let loadCalls = 0; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: '' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => 'cached-token', + setCachedAccessToken: () => {}, + saveStoredToken: () => {}, + loadStoredToken: () => { + loadCalls += 1; + return 'stored-token'; + }, + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => false, + setAnilistSetupPageOpened: () => {}, + openAnilistSetupWindow: () => {}, + now: () => 300, + }); + + const token = await refresh(); + assert.equal(token, 'cached-token'); + assert.equal(loadCalls, 0); +}); + +test('refresh handler falls back to stored token then opens setup when missing', async () => { + let cached: string | null = null; + let opened = false; + let openCalls = 0; + const refresh = createRefreshAnilistClientSecretStateHandler({ + getResolvedConfig: () => ({ anilist: { accessToken: '' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => cached, + setCachedAccessToken: (token) => { + cached = token; + }, + saveStoredToken: () => {}, + loadStoredToken: () => '', + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => opened, + setAnilistSetupPageOpened: (next) => { + opened = next; + }, + openAnilistSetupWindow: () => { + openCalls += 1; + }, + now: () => 400, + }); + + const token = await refresh({ force: true }); + assert.equal(token, null); + assert.equal(cached, null); + assert.equal(openCalls, 1); +}); diff --git a/src/main/runtime/anilist-token-refresh.ts b/src/main/runtime/anilist-token-refresh.ts new file mode 100644 index 0000000..583eda1 --- /dev/null +++ b/src/main/runtime/anilist-token-refresh.ts @@ -0,0 +1,93 @@ +type AnilistSecretResolutionState = { + status: 'not_checked' | 'resolved' | 'error'; + source: 'none' | 'literal' | 'stored'; + message: string | null; + resolvedAt: number | null; + errorAt: number | null; +}; + +type ConfigWithAnilistToken = { + anilist: { + accessToken: string; + }; +}; + +export function createRefreshAnilistClientSecretStateHandler(deps: { + getResolvedConfig: () => TConfig; + isAnilistTrackingEnabled: (config: TConfig) => boolean; + getCachedAccessToken: () => string | null; + setCachedAccessToken: (token: string | null) => void; + saveStoredToken: (token: string) => void; + loadStoredToken: () => string | null | undefined; + setClientSecretState: (state: AnilistSecretResolutionState) => void; + getAnilistSetupPageOpened: () => boolean; + setAnilistSetupPageOpened: (opened: boolean) => void; + openAnilistSetupWindow: () => void; + now: () => number; +}) { + return async (options?: { force?: boolean }): Promise => { + const resolved = deps.getResolvedConfig(); + const now = deps.now(); + if (!deps.isAnilistTrackingEnabled(resolved)) { + deps.setCachedAccessToken(null); + deps.setClientSecretState({ + status: 'not_checked', + source: 'none', + message: 'anilist tracking disabled', + resolvedAt: null, + errorAt: null, + }); + deps.setAnilistSetupPageOpened(false); + return null; + } + + const rawAccessToken = resolved.anilist.accessToken.trim(); + if (rawAccessToken.length > 0) { + if (options?.force || rawAccessToken !== deps.getCachedAccessToken()) { + deps.saveStoredToken(rawAccessToken); + } + deps.setCachedAccessToken(rawAccessToken); + deps.setClientSecretState({ + status: 'resolved', + source: 'literal', + message: 'using configured anilist.accessToken', + resolvedAt: now, + errorAt: null, + }); + deps.setAnilistSetupPageOpened(false); + return rawAccessToken; + } + + const cachedAccessToken = deps.getCachedAccessToken(); + if (!options?.force && cachedAccessToken && cachedAccessToken.length > 0) { + return cachedAccessToken; + } + + const storedToken = deps.loadStoredToken()?.trim() ?? ''; + if (storedToken.length > 0) { + deps.setCachedAccessToken(storedToken); + deps.setClientSecretState({ + status: 'resolved', + source: 'stored', + message: 'using stored anilist access token', + resolvedAt: now, + errorAt: null, + }); + deps.setAnilistSetupPageOpened(false); + return storedToken; + } + + deps.setCachedAccessToken(null); + deps.setClientSecretState({ + status: 'error', + source: 'none', + message: 'cannot authenticate without anilist.accessToken', + resolvedAt: null, + errorAt: now, + }); + if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) { + deps.openAnilistSetupWindow(); + } + return null; + }; +} diff --git a/src/main/runtime/anki-actions-main-deps.test.ts b/src/main/runtime/anki-actions-main-deps.test.ts new file mode 100644 index 0000000..805508f --- /dev/null +++ b/src/main/runtime/anki-actions-main-deps.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildMarkLastCardAsAudioCardMainDepsHandler, + createBuildMineSentenceCardMainDepsHandler, + createBuildRefreshKnownWordCacheMainDepsHandler, + createBuildTriggerFieldGroupingMainDepsHandler, + createBuildUpdateLastCardFromClipboardMainDepsHandler, +} from './anki-actions-main-deps'; + +test('anki action main deps builders map callbacks', async () => { + const calls: string[] = []; + + const update = createBuildUpdateLastCardFromClipboardMainDepsHandler({ + getAnkiIntegration: () => ({ enabled: true }), + readClipboardText: () => 'clip', + showMpvOsd: (text) => calls.push(`osd:${text}`), + updateLastCardFromClipboardCore: async () => { + calls.push('update'); + }, + })(); + assert.deepEqual(update.getAnkiIntegration(), { enabled: true }); + assert.equal(update.readClipboardText(), 'clip'); + update.showMpvOsd('x'); + await update.updateLastCardFromClipboardCore({ + ankiIntegration: { enabled: true }, + readClipboardText: () => '', + showMpvOsd: () => {}, + }); + + const refresh = createBuildRefreshKnownWordCacheMainDepsHandler({ + getAnkiIntegration: () => null, + missingIntegrationMessage: 'missing', + })(); + assert.equal(refresh.getAnkiIntegration(), null); + assert.equal(refresh.missingIntegrationMessage, 'missing'); + + const fieldGrouping = createBuildTriggerFieldGroupingMainDepsHandler({ + getAnkiIntegration: () => ({ enabled: true }), + showMpvOsd: (text) => calls.push(`fg:${text}`), + triggerFieldGroupingCore: async () => { + calls.push('trigger'); + }, + })(); + fieldGrouping.showMpvOsd('fg'); + await fieldGrouping.triggerFieldGroupingCore({ + ankiIntegration: { enabled: true }, + showMpvOsd: () => {}, + }); + + const markAudio = createBuildMarkLastCardAsAudioCardMainDepsHandler({ + getAnkiIntegration: () => ({ enabled: true }), + showMpvOsd: (text) => calls.push(`audio:${text}`), + markLastCardAsAudioCardCore: async () => { + calls.push('mark'); + }, + })(); + markAudio.showMpvOsd('a'); + await markAudio.markLastCardAsAudioCardCore({ + ankiIntegration: { enabled: true }, + showMpvOsd: () => {}, + }); + + const mine = createBuildMineSentenceCardMainDepsHandler({ + getAnkiIntegration: () => ({ enabled: true }), + getMpvClient: () => ({ connected: true }), + showMpvOsd: (text) => calls.push(`mine:${text}`), + mineSentenceCardCore: async () => true, + recordCardsMined: (count) => calls.push(`cards:${count}`), + })(); + assert.deepEqual(mine.getMpvClient(), { connected: true }); + mine.showMpvOsd('m'); + await mine.mineSentenceCardCore({ + ankiIntegration: { enabled: true }, + mpvClient: { connected: true }, + showMpvOsd: () => {}, + }); + mine.recordCardsMined(1); + + assert.deepEqual(calls, [ + 'osd:x', + 'update', + 'fg:fg', + 'trigger', + 'audio:a', + 'mark', + 'mine:m', + 'cards:1', + ]); +}); diff --git a/src/main/runtime/anki-actions-main-deps.ts b/src/main/runtime/anki-actions-main-deps.ts new file mode 100644 index 0000000..8317a57 --- /dev/null +++ b/src/main/runtime/anki-actions-main-deps.ts @@ -0,0 +1,88 @@ +import type { createRefreshKnownWordCacheHandler } from './anki-actions'; + +type RefreshKnownWordCacheMainDeps = Parameters[0]; + +export function createBuildUpdateLastCardFromClipboardMainDepsHandler(deps: { + getAnkiIntegration: () => TAnki; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + updateLastCardFromClipboardCore: (options: { + ankiIntegration: TAnki; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return () => ({ + getAnkiIntegration: () => deps.getAnkiIntegration(), + readClipboardText: () => deps.readClipboardText(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + updateLastCardFromClipboardCore: (options: { + ankiIntegration: TAnki; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + }) => deps.updateLastCardFromClipboardCore(options), + }); +} + +export function createBuildRefreshKnownWordCacheMainDepsHandler(deps: RefreshKnownWordCacheMainDeps) { + return (): RefreshKnownWordCacheMainDeps => ({ + getAnkiIntegration: () => deps.getAnkiIntegration(), + missingIntegrationMessage: deps.missingIntegrationMessage, + }); +} + +export function createBuildTriggerFieldGroupingMainDepsHandler(deps: { + getAnkiIntegration: () => TAnki; + showMpvOsd: (text: string) => void; + triggerFieldGroupingCore: (options: { + ankiIntegration: TAnki; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return () => ({ + getAnkiIntegration: () => deps.getAnkiIntegration(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + triggerFieldGroupingCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) => + deps.triggerFieldGroupingCore(options), + }); +} + +export function createBuildMarkLastCardAsAudioCardMainDepsHandler(deps: { + getAnkiIntegration: () => TAnki; + showMpvOsd: (text: string) => void; + markLastCardAsAudioCardCore: (options: { + ankiIntegration: TAnki; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return () => ({ + getAnkiIntegration: () => deps.getAnkiIntegration(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + markLastCardAsAudioCardCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) => + deps.markLastCardAsAudioCardCore(options), + }); +} + +export function createBuildMineSentenceCardMainDepsHandler(deps: { + getAnkiIntegration: () => TAnki; + getMpvClient: () => TMpv; + showMpvOsd: (text: string) => void; + mineSentenceCardCore: (options: { + ankiIntegration: TAnki; + mpvClient: TMpv; + showMpvOsd: (text: string) => void; + }) => Promise; + recordCardsMined: (count: number) => void; +}) { + return () => ({ + getAnkiIntegration: () => deps.getAnkiIntegration(), + getMpvClient: () => deps.getMpvClient(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + mineSentenceCardCore: (options: { + ankiIntegration: TAnki; + mpvClient: TMpv; + showMpvOsd: (text: string) => void; + }) => deps.mineSentenceCardCore(options), + recordCardsMined: (count: number) => deps.recordCardsMined(count), + }); +} diff --git a/src/main/runtime/anki-actions.test.ts b/src/main/runtime/anki-actions.test.ts new file mode 100644 index 0000000..1c32ab0 --- /dev/null +++ b/src/main/runtime/anki-actions.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createMarkLastCardAsAudioCardHandler, + createMineSentenceCardHandler, + createRefreshKnownWordCacheHandler, + createTriggerFieldGroupingHandler, + createUpdateLastCardFromClipboardHandler, +} from './anki-actions'; + +test('update last card handler forwards integration/clipboard/osd deps', async () => { + const calls: string[] = []; + const integration = {}; + const updateLastCard = createUpdateLastCardFromClipboardHandler({ + getAnkiIntegration: () => integration, + readClipboardText: () => 'clipboard-value', + showMpvOsd: (text) => calls.push(`osd:${text}`), + updateLastCardFromClipboardCore: async (options) => { + assert.equal(options.ankiIntegration, integration); + assert.equal(options.readClipboardText(), 'clipboard-value'); + options.showMpvOsd('ok'); + calls.push('core'); + }, + }); + + await updateLastCard(); + assert.deepEqual(calls, ['osd:ok', 'core']); +}); + +test('refresh known word cache handler throws when Anki integration missing', async () => { + const refresh = createRefreshKnownWordCacheHandler({ + getAnkiIntegration: () => null, + missingIntegrationMessage: 'AnkiConnect integration not enabled', + }); + + await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/); +}); + +test('trigger and mark handlers delegate to core services', async () => { + const calls: string[] = []; + const integration = {}; + const triggerFieldGrouping = createTriggerFieldGroupingHandler({ + getAnkiIntegration: () => integration, + showMpvOsd: (text) => calls.push(`osd:${text}`), + triggerFieldGroupingCore: async (options) => { + assert.equal(options.ankiIntegration, integration); + options.showMpvOsd('group'); + calls.push('group-core'); + }, + }); + const markAudio = createMarkLastCardAsAudioCardHandler({ + getAnkiIntegration: () => integration, + showMpvOsd: (text) => calls.push(`osd:${text}`), + markLastCardAsAudioCardCore: async (options) => { + assert.equal(options.ankiIntegration, integration); + options.showMpvOsd('mark'); + calls.push('mark-core'); + }, + }); + + await triggerFieldGrouping(); + await markAudio(); + assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']); +}); + +test('mine sentence handler records mined cards only when core returns true', async () => { + const calls: string[] = []; + const integration = {}; + const mpvClient = {}; + let created = false; + const mineSentenceCard = createMineSentenceCardHandler({ + getAnkiIntegration: () => integration, + getMpvClient: () => mpvClient, + showMpvOsd: (text) => calls.push(`osd:${text}`), + mineSentenceCardCore: async (options) => { + assert.equal(options.ankiIntegration, integration); + assert.equal(options.mpvClient, mpvClient); + options.showMpvOsd('mine'); + return created; + }, + recordCardsMined: (count) => calls.push(`cards:${count}`), + }); + + created = false; + await mineSentenceCard(); + created = true; + await mineSentenceCard(); + assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']); +}); diff --git a/src/main/runtime/anki-actions.ts b/src/main/runtime/anki-actions.ts new file mode 100644 index 0000000..443a918 --- /dev/null +++ b/src/main/runtime/anki-actions.ts @@ -0,0 +1,90 @@ +type AnkiIntegrationLike = { + refreshKnownWordCache: () => Promise; +}; + +export function createUpdateLastCardFromClipboardHandler(deps: { + getAnkiIntegration: () => TAnki; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + updateLastCardFromClipboardCore: (options: { + ankiIntegration: TAnki; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return async (): Promise => { + await deps.updateLastCardFromClipboardCore({ + ankiIntegration: deps.getAnkiIntegration(), + readClipboardText: deps.readClipboardText, + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createRefreshKnownWordCacheHandler(deps: { + getAnkiIntegration: () => AnkiIntegrationLike | null; + missingIntegrationMessage: string; +}) { + return async (): Promise => { + const anki = deps.getAnkiIntegration(); + if (!anki) { + throw new Error(deps.missingIntegrationMessage); + } + await anki.refreshKnownWordCache(); + }; +} + +export function createTriggerFieldGroupingHandler(deps: { + getAnkiIntegration: () => TAnki; + showMpvOsd: (text: string) => void; + triggerFieldGroupingCore: (options: { + ankiIntegration: TAnki; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return async (): Promise => { + await deps.triggerFieldGroupingCore({ + ankiIntegration: deps.getAnkiIntegration(), + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createMarkLastCardAsAudioCardHandler(deps: { + getAnkiIntegration: () => TAnki; + showMpvOsd: (text: string) => void; + markLastCardAsAudioCardCore: (options: { + ankiIntegration: TAnki; + showMpvOsd: (text: string) => void; + }) => Promise; +}) { + return async (): Promise => { + await deps.markLastCardAsAudioCardCore({ + ankiIntegration: deps.getAnkiIntegration(), + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createMineSentenceCardHandler(deps: { + getAnkiIntegration: () => TAnki; + getMpvClient: () => TMpv; + showMpvOsd: (text: string) => void; + mineSentenceCardCore: (options: { + ankiIntegration: TAnki; + mpvClient: TMpv; + showMpvOsd: (text: string) => void; + }) => Promise; + recordCardsMined: (count: number) => void; +}) { + return async (): Promise => { + const created = await deps.mineSentenceCardCore({ + ankiIntegration: deps.getAnkiIntegration(), + mpvClient: deps.getMpvClient(), + showMpvOsd: deps.showMpvOsd, + }); + if (created) { + deps.recordCardsMined(1); + } + }; +} diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts new file mode 100644 index 0000000..a4754f6 --- /dev/null +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createOnWillQuitCleanupHandler, + createRestoreWindowsOnActivateHandler, + createShouldRestoreWindowsOnActivateHandler, +} from './app-lifecycle-actions'; + +test('on will quit cleanup handler runs all cleanup steps', () => { + const calls: string[] = []; + const cleanup = createOnWillQuitCleanupHandler({ + destroyTray: () => calls.push('destroy-tray'), + stopConfigHotReload: () => calls.push('stop-config'), + restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), + unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), + stopSubtitleWebsocket: () => calls.push('stop-ws'), + stopTexthookerService: () => calls.push('stop-texthooker'), + destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'), + clearYomitanParserState: () => calls.push('clear-yomitan-state'), + stopWindowTracker: () => calls.push('stop-tracker'), + flushMpvLog: () => calls.push('flush-mpv-log'), + destroyMpvSocket: () => calls.push('destroy-socket'), + clearReconnectTimer: () => calls.push('clear-reconnect'), + destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'), + destroyImmersionTracker: () => calls.push('destroy-immersion'), + destroyAnkiIntegration: () => calls.push('destroy-anki'), + destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'), + clearAnilistSetupWindow: () => calls.push('clear-anilist-window'), + destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'), + clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), + stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + stopDiscordPresenceService: () => calls.push('stop-discord-presence'), + }); + + cleanup(); + assert.equal(calls.length, 21); + assert.equal(calls[0], 'destroy-tray'); + assert.equal(calls[calls.length - 1], 'stop-discord-presence'); + assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); +}); + +test('should restore windows on activate requires initialized runtime and no windows', () => { + let initialized = false; + let windowCount = 1; + const shouldRestore = createShouldRestoreWindowsOnActivateHandler({ + isOverlayRuntimeInitialized: () => initialized, + getAllWindowCount: () => windowCount, + }); + + assert.equal(shouldRestore(), false); + initialized = true; + assert.equal(shouldRestore(), false); + windowCount = 0; + assert.equal(shouldRestore(), true); +}); + +test('restore windows on activate recreates windows then syncs visibility', () => { + const calls: string[] = []; + const restore = createRestoreWindowsOnActivateHandler({ + createMainWindow: () => calls.push('main'), + createInvisibleWindow: () => calls.push('invisible'), + updateVisibleOverlayVisibility: () => calls.push('visible-sync'), + updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'), + }); + + restore(); + assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']); +}); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts new file mode 100644 index 0000000..62f9d3e --- /dev/null +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -0,0 +1,68 @@ +export function createOnWillQuitCleanupHandler(deps: { + destroyTray: () => void; + stopConfigHotReload: () => void; + restorePreviousSecondarySubVisibility: () => void; + unregisterAllGlobalShortcuts: () => void; + stopSubtitleWebsocket: () => void; + stopTexthookerService: () => void; + destroyYomitanParserWindow: () => void; + clearYomitanParserState: () => void; + stopWindowTracker: () => void; + flushMpvLog: () => void; + destroyMpvSocket: () => void; + clearReconnectTimer: () => void; + destroySubtitleTimingTracker: () => void; + destroyImmersionTracker: () => void; + destroyAnkiIntegration: () => void; + destroyAnilistSetupWindow: () => void; + clearAnilistSetupWindow: () => void; + destroyJellyfinSetupWindow: () => void; + clearJellyfinSetupWindow: () => void; + stopJellyfinRemoteSession: () => void; + stopDiscordPresenceService: () => void; +}) { + return (): void => { + deps.destroyTray(); + deps.stopConfigHotReload(); + deps.restorePreviousSecondarySubVisibility(); + deps.unregisterAllGlobalShortcuts(); + deps.stopSubtitleWebsocket(); + deps.stopTexthookerService(); + deps.destroyYomitanParserWindow(); + deps.clearYomitanParserState(); + deps.stopWindowTracker(); + deps.flushMpvLog(); + deps.destroyMpvSocket(); + deps.clearReconnectTimer(); + deps.destroySubtitleTimingTracker(); + deps.destroyImmersionTracker(); + deps.destroyAnkiIntegration(); + deps.destroyAnilistSetupWindow(); + deps.clearAnilistSetupWindow(); + deps.destroyJellyfinSetupWindow(); + deps.clearJellyfinSetupWindow(); + deps.stopJellyfinRemoteSession(); + deps.stopDiscordPresenceService(); + }; +} + +export function createShouldRestoreWindowsOnActivateHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + getAllWindowCount: () => number; +}) { + return (): boolean => deps.isOverlayRuntimeInitialized() && deps.getAllWindowCount() === 0; +} + +export function createRestoreWindowsOnActivateHandler(deps: { + createMainWindow: () => void; + createInvisibleWindow: () => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; +}) { + return (): void => { + deps.createMainWindow(); + deps.createInvisibleWindow(); + deps.updateVisibleOverlayVisibility(); + deps.updateInvisibleOverlayVisibility(); + }; +} diff --git a/src/main/runtime/app-lifecycle-main-activate.test.ts b/src/main/runtime/app-lifecycle-main-activate.test.ts new file mode 100644 index 0000000..8569cc6 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-activate.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildRestoreWindowsOnActivateMainDepsHandler, + createBuildShouldRestoreWindowsOnActivateMainDepsHandler, +} from './app-lifecycle-main-activate'; + +test('should restore windows on activate deps builder maps visibility state checks', () => { + const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({ + isOverlayRuntimeInitialized: () => true, + getAllWindowCount: () => 0, + })(); + + assert.equal(deps.isOverlayRuntimeInitialized(), true); + assert.equal(deps.getAllWindowCount(), 0); +}); + +test('restore windows on activate deps builder maps all restoration callbacks', () => { + const calls: string[] = []; + const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({ + createMainWindow: () => calls.push('main'), + createInvisibleWindow: () => calls.push('invisible'), + updateVisibleOverlayVisibility: () => calls.push('visible'), + updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'), + })(); + + deps.createMainWindow(); + deps.createInvisibleWindow(); + deps.updateVisibleOverlayVisibility(); + deps.updateInvisibleOverlayVisibility(); + assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']); +}); diff --git a/src/main/runtime/app-lifecycle-main-activate.ts b/src/main/runtime/app-lifecycle-main-activate.ts new file mode 100644 index 0000000..3fde767 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-activate.ts @@ -0,0 +1,23 @@ +export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + getAllWindowCount: () => number; +}) { + return () => ({ + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + getAllWindowCount: () => deps.getAllWindowCount(), + }); +} + +export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: { + createMainWindow: () => void; + createInvisibleWindow: () => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; +}) { + return () => ({ + createMainWindow: () => deps.createMainWindow(), + createInvisibleWindow: () => deps.createInvisibleWindow(), + updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(), + }); +} diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts new file mode 100644 index 0000000..70286e2 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup'; +import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions'; + +test('cleanup deps builder returns handlers that guard optional runtime objects', () => { + const calls: string[] = []; + let reconnectTimer: ReturnType | null = setTimeout(() => {}, 60_000); + let immersionTracker: { destroy: () => void } | null = { + destroy: () => calls.push('destroy-immersion'), + }; + + const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ + destroyTray: () => calls.push('destroy-tray'), + stopConfigHotReload: () => calls.push('stop-config'), + restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), + unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), + stopSubtitleWebsocket: () => calls.push('stop-ws'), + stopTexthookerService: () => calls.push('stop-texthooker'), + + getYomitanParserWindow: () => ({ + isDestroyed: () => false, + destroy: () => calls.push('destroy-yomitan-window'), + }), + clearYomitanParserState: () => calls.push('clear-yomitan-state'), + + getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }), + flushMpvLog: () => calls.push('flush-mpv-log'), + getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }), + getReconnectTimer: () => reconnectTimer, + clearReconnectTimerRef: () => { + reconnectTimer = null; + calls.push('clear-reconnect-ref'); + }, + + getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }), + getImmersionTracker: () => immersionTracker, + clearImmersionTracker: () => { + immersionTracker = null; + calls.push('clear-immersion-ref'); + }, + getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }), + + getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }), + clearAnilistSetupWindow: () => calls.push('clear-anilist-window'), + getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }), + clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), + + stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + stopDiscordPresenceService: () => calls.push('stop-discord-presence'), + }); + + const cleanup = createOnWillQuitCleanupHandler(depsFactory()); + cleanup(); + + assert.ok(calls.includes('destroy-tray')); + assert.ok(calls.includes('destroy-yomitan-window')); + assert.ok(calls.includes('flush-mpv-log')); + assert.ok(calls.includes('destroy-socket')); + assert.ok(calls.includes('clear-reconnect-ref')); + assert.ok(calls.includes('destroy-immersion')); + assert.ok(calls.includes('clear-immersion-ref')); + assert.ok(calls.includes('stop-jellyfin-remote')); + assert.ok(calls.includes('stop-discord-presence')); + assert.equal(reconnectTimer, null); + assert.equal(immersionTracker, null); +}); + +test('cleanup deps builder skips destroyed yomitan window', () => { + const calls: string[] = []; + const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ + destroyTray: () => {}, + stopConfigHotReload: () => {}, + restorePreviousSecondarySubVisibility: () => {}, + unregisterAllGlobalShortcuts: () => {}, + stopSubtitleWebsocket: () => {}, + stopTexthookerService: () => {}, + getYomitanParserWindow: () => ({ + isDestroyed: () => true, + destroy: () => calls.push('destroy-yomitan-window'), + }), + clearYomitanParserState: () => {}, + getWindowTracker: () => null, + flushMpvLog: () => {}, + getMpvSocket: () => null, + getReconnectTimer: () => null, + clearReconnectTimerRef: () => {}, + getSubtitleTimingTracker: () => null, + getImmersionTracker: () => null, + clearImmersionTracker: () => {}, + getAnkiIntegration: () => null, + getAnilistSetupWindow: () => null, + clearAnilistSetupWindow: () => {}, + getJellyfinSetupWindow: () => null, + clearJellyfinSetupWindow: () => {}, + stopJellyfinRemoteSession: () => {}, + stopDiscordPresenceService: () => {}, + }); + + const cleanup = createOnWillQuitCleanupHandler(depsFactory()); + cleanup(); + + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts new file mode 100644 index 0000000..e897739 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -0,0 +1,102 @@ +// Narrow structural types used by cleanup assembly. +type Destroyable = { + destroy: () => void; +}; + +type DestroyableWindow = Destroyable & { + isDestroyed: () => boolean; +}; + +type Stoppable = { + stop: () => void; +}; + +type SocketLike = { + destroy: () => void; +}; + +type TimerLike = ReturnType; + +export function createBuildOnWillQuitCleanupDepsHandler(deps: { + destroyTray: () => void; + stopConfigHotReload: () => void; + restorePreviousSecondarySubVisibility: () => void; + unregisterAllGlobalShortcuts: () => void; + stopSubtitleWebsocket: () => void; + stopTexthookerService: () => void; + + getYomitanParserWindow: () => DestroyableWindow | null; + clearYomitanParserState: () => void; + + getWindowTracker: () => Stoppable | null; + flushMpvLog: () => void; + getMpvSocket: () => SocketLike | null; + getReconnectTimer: () => TimerLike | null; + clearReconnectTimerRef: () => void; + + getSubtitleTimingTracker: () => Destroyable | null; + getImmersionTracker: () => Destroyable | null; + clearImmersionTracker: () => void; + getAnkiIntegration: () => Destroyable | null; + + getAnilistSetupWindow: () => Destroyable | null; + clearAnilistSetupWindow: () => void; + getJellyfinSetupWindow: () => Destroyable | null; + clearJellyfinSetupWindow: () => void; + + stopJellyfinRemoteSession: () => void; + stopDiscordPresenceService: () => void; +}) { + return () => ({ + destroyTray: () => deps.destroyTray(), + stopConfigHotReload: () => deps.stopConfigHotReload(), + restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(), + unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), + stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), + stopTexthookerService: () => deps.stopTexthookerService(), + destroyYomitanParserWindow: () => { + const window = deps.getYomitanParserWindow(); + if (!window) return; + if (window.isDestroyed()) return; + window.destroy(); + }, + clearYomitanParserState: () => deps.clearYomitanParserState(), + stopWindowTracker: () => { + const tracker = deps.getWindowTracker(); + tracker?.stop(); + }, + flushMpvLog: () => deps.flushMpvLog(), + destroyMpvSocket: () => { + const socket = deps.getMpvSocket(); + socket?.destroy(); + }, + clearReconnectTimer: () => { + const timer = deps.getReconnectTimer(); + if (!timer) return; + clearTimeout(timer); + deps.clearReconnectTimerRef(); + }, + destroySubtitleTimingTracker: () => { + deps.getSubtitleTimingTracker()?.destroy(); + }, + destroyImmersionTracker: () => { + const tracker = deps.getImmersionTracker(); + if (!tracker) return; + tracker.destroy(); + deps.clearImmersionTracker(); + }, + destroyAnkiIntegration: () => { + deps.getAnkiIntegration()?.destroy(); + }, + destroyAnilistSetupWindow: () => { + deps.getAnilistSetupWindow()?.destroy(); + }, + clearAnilistSetupWindow: () => deps.clearAnilistSetupWindow(), + destroyJellyfinSetupWindow: () => { + deps.getJellyfinSetupWindow()?.destroy(); + }, + clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(), + stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), + stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), + }); +} diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts new file mode 100644 index 0000000..94df508 --- /dev/null +++ b/src/main/runtime/app-ready-main-deps.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps'; + +test('app-ready main deps builder returns mapped app-ready runtime deps', async () => { + const calls: string[] = []; + const onReady = createBuildAppReadyRuntimeMainDepsHandler({ + loadSubtitlePosition: () => calls.push('load-subtitle-position'), + resolveKeybindings: () => calls.push('resolve-keybindings'), + createMpvClient: () => calls.push('create-mpv-client'), + reloadConfig: () => calls.push('reload-config'), + getResolvedConfig: () => ({ websocket: {} }), + getConfigWarnings: () => [], + logConfigWarning: () => calls.push('log-config-warning'), + initRuntimeOptionsManager: () => calls.push('init-runtime-options'), + setSecondarySubMode: () => calls.push('set-secondary-sub-mode'), + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 5174, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => calls.push('start-ws'), + log: () => calls.push('log'), + setLogLevel: () => calls.push('set-log-level'), + createMecabTokenizerAndCheck: async () => { + calls.push('create-mecab'); + }, + createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'), + createImmersionTracker: () => calls.push('create-immersion'), + startJellyfinRemoteSession: async () => { + calls.push('start-jellyfin'); + }, + loadYomitanExtension: async () => { + calls.push('load-yomitan'); + }, + prewarmSubtitleDictionaries: async () => { + calls.push('prewarm-dicts'); + }, + startBackgroundWarmups: () => calls.push('start-warmups'), + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + initializeOverlayRuntime: () => calls.push('init-overlay'), + handleInitialArgs: () => calls.push('handle-initial-args'), + onCriticalConfigErrors: () => { + throw new Error('should not call'); + }, + logDebug: () => calls.push('debug'), + now: () => 123, + })(); + + assert.equal(onReady.defaultSecondarySubMode, 'hover'); + assert.equal(onReady.defaultWebsocketPort, 5174); + assert.equal(onReady.texthookerOnlyMode, false); + assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true); + assert.equal(onReady.now?.(), 123); + onReady.loadSubtitlePosition(); + onReady.resolveKeybindings(); + onReady.createMpvClient(); + await onReady.createMecabTokenizerAndCheck(); + await onReady.loadYomitanExtension(); + await onReady.prewarmSubtitleDictionaries?.(); + onReady.startBackgroundWarmups(); + + assert.deepEqual(calls, [ + 'load-subtitle-position', + 'resolve-keybindings', + 'create-mpv-client', + 'create-mecab', + 'load-yomitan', + 'prewarm-dicts', + 'start-warmups', + ]); +}); diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts new file mode 100644 index 0000000..74fcbd0 --- /dev/null +++ b/src/main/runtime/app-ready-main-deps.ts @@ -0,0 +1,38 @@ +import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle'; + +export function createBuildAppReadyRuntimeMainDepsHandler( + deps: AppReadyRuntimeDepsFactoryInput, +) { + return (): AppReadyRuntimeDepsFactoryInput => ({ + loadSubtitlePosition: deps.loadSubtitlePosition, + resolveKeybindings: deps.resolveKeybindings, + createMpvClient: deps.createMpvClient, + reloadConfig: deps.reloadConfig, + getResolvedConfig: deps.getResolvedConfig, + getConfigWarnings: deps.getConfigWarnings, + logConfigWarning: deps.logConfigWarning, + initRuntimeOptionsManager: deps.initRuntimeOptionsManager, + setSecondarySubMode: deps.setSecondarySubMode, + defaultSecondarySubMode: deps.defaultSecondarySubMode, + defaultWebsocketPort: deps.defaultWebsocketPort, + hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin, + startSubtitleWebsocket: deps.startSubtitleWebsocket, + log: deps.log, + setLogLevel: deps.setLogLevel, + createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck, + createSubtitleTimingTracker: deps.createSubtitleTimingTracker, + createImmersionTracker: deps.createImmersionTracker, + startJellyfinRemoteSession: deps.startJellyfinRemoteSession, + loadYomitanExtension: deps.loadYomitanExtension, + prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries, + startBackgroundWarmups: deps.startBackgroundWarmups, + texthookerOnlyMode: deps.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: + deps.shouldAutoInitializeOverlayRuntimeFromConfig, + initializeOverlayRuntime: deps.initializeOverlayRuntime, + handleInitialArgs: deps.handleInitialArgs, + onCriticalConfigErrors: deps.onCriticalConfigErrors, + logDebug: deps.logDebug, + now: deps.now, + }); +} diff --git a/src/main/runtime/app-runtime-main-deps.test.ts b/src/main/runtime/app-runtime-main-deps.test.ts new file mode 100644 index 0000000..c0767a5 --- /dev/null +++ b/src/main/runtime/app-runtime-main-deps.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildDestroyTrayMainDepsHandler, + createBuildEnsureTrayMainDepsHandler, + createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler, + createBuildOpenYomitanSettingsMainDepsHandler, +} from './app-runtime-main-deps'; + +test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => { + const calls: string[] = []; + const deps = createBuildEnsureTrayMainDepsHandler({ + getTray: () => null, + setTray: () => calls.push('set-tray'), + buildTrayMenu: () => ({}), + resolveTrayIconPath: () => null, + createImageFromPath: () => ({}), + createEmptyImage: () => ({}), + createTray: () => ({}), + trayTooltip: 'SubMiner', + platform: 'darwin', + logWarn: (message) => calls.push(`warn:${message}`), + initializeOverlayRuntime: () => calls.push('init-overlay'), + isOverlayRuntimeInitialized: () => false, + setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), + })(); + + deps.ensureOverlayVisibleFromTrayClick(); + assert.deepEqual(calls, ['init-overlay', 'set-visible:true']); +}); + +test('destroy tray main deps map passthrough getters/setters', () => { + let tray: unknown = { id: 'tray' }; + const deps = createBuildDestroyTrayMainDepsHandler({ + getTray: () => tray, + setTray: (next) => { + tray = next; + }, + })(); + + assert.deepEqual(deps.getTray(), { id: 'tray' }); + deps.setTray(null); + assert.equal(tray, null); +}); + +test('initialize overlay runtime main deps map build options and callbacks', () => { + const calls: string[] = []; + const options = { id: 'opts' }; + const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({ + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntimeCore: (value) => { + calls.push(`core:${JSON.stringify(value)}`); + return { invisibleOverlayVisible: true }; + }, + buildOptions: () => options, + setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`), + setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`), + startBackgroundWarmups: () => calls.push('warmups'), + })(); + + assert.equal(deps.isOverlayRuntimeInitialized(), false); + assert.equal(deps.buildOptions(), options); + assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true }); + deps.setInvisibleOverlayVisible(true); + deps.setOverlayRuntimeInitialized(true); + deps.startBackgroundWarmups(); + assert.deepEqual(calls, [ + 'core:{"id":"opts"}', + 'set-invisible:true', + 'set-initialized:true', + 'warmups', + ]); +}); + +test('open yomitan settings main deps map async open callbacks', async () => { + const calls: string[] = []; + let currentWindow: unknown = null; + const extension = { id: 'ext' }; + const deps = createBuildOpenYomitanSettingsMainDepsHandler({ + ensureYomitanExtensionLoaded: async () => extension, + openYomitanSettingsWindow: ({ yomitanExt }) => calls.push(`open:${(yomitanExt as { id: string }).id}`), + getExistingWindow: () => currentWindow, + setWindow: (window) => { + currentWindow = window; + calls.push('set-window'); + }, + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + })(); + + assert.equal(await deps.ensureYomitanExtensionLoaded(), extension); + assert.equal(deps.getExistingWindow(), null); + deps.setWindow({ id: 'win' }); + deps.openYomitanSettingsWindow({ + yomitanExt: extension, + getExistingWindow: () => deps.getExistingWindow(), + setWindow: (window) => deps.setWindow(window), + }); + deps.logWarn('warn'); + deps.logError('error', new Error('boom')); + assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']); + assert.deepEqual(currentWindow, { id: 'win' }); +}); diff --git a/src/main/runtime/app-runtime-main-deps.ts b/src/main/runtime/app-runtime-main-deps.ts new file mode 100644 index 0000000..da27598 --- /dev/null +++ b/src/main/runtime/app-runtime-main-deps.ts @@ -0,0 +1,89 @@ +export function createBuildEnsureTrayMainDepsHandler(deps: { + getTray: () => TTray | null; + setTray: (tray: TTray | null) => void; + buildTrayMenu: () => TTrayMenu; + resolveTrayIconPath: () => string | null; + createImageFromPath: (iconPath: string) => TTrayIcon; + createEmptyImage: () => TTrayIcon; + createTray: (icon: TTrayIcon) => TTray; + trayTooltip: string; + platform: string; + logWarn: (message: string) => void; + initializeOverlayRuntime: () => void; + isOverlayRuntimeInitialized: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; +}) { + return () => ({ + getTray: () => deps.getTray(), + setTray: (tray: TTray | null) => deps.setTray(tray), + buildTrayMenu: () => deps.buildTrayMenu(), + resolveTrayIconPath: () => deps.resolveTrayIconPath(), + createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath), + createEmptyImage: () => deps.createEmptyImage(), + createTray: (icon: TTrayIcon) => deps.createTray(icon), + trayTooltip: deps.trayTooltip, + platform: deps.platform, + logWarn: (message: string) => deps.logWarn(message), + ensureOverlayVisibleFromTrayClick: () => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.setVisibleOverlayVisible(true); + }, + }); +} + +export function createBuildDestroyTrayMainDepsHandler(deps: { + getTray: () => TTray | null; + setTray: (tray: TTray | null) => void; +}) { + return () => ({ + getTray: () => deps.getTray(), + setTray: (tray: TTray | null) => deps.setTray(tray), + }); +} + +export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean }; + buildOptions: () => TOptions; + setInvisibleOverlayVisible: (visible: boolean) => void; + setOverlayRuntimeInitialized: (initialized: boolean) => void; + startBackgroundWarmups: () => void; +}) { + return () => ({ + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options), + buildOptions: () => deps.buildOptions(), + setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible), + setOverlayRuntimeInitialized: (initialized: boolean) => + deps.setOverlayRuntimeInitialized(initialized), + startBackgroundWarmups: () => deps.startBackgroundWarmups(), + }); +} + +export function createBuildOpenYomitanSettingsMainDepsHandler(deps: { + ensureYomitanExtensionLoaded: () => Promise; + openYomitanSettingsWindow: (params: { + yomitanExt: TYomitanExt; + getExistingWindow: () => TWindow | null; + setWindow: (window: TWindow | null) => void; + }) => void; + getExistingWindow: () => TWindow | null; + setWindow: (window: TWindow | null) => void; + logWarn: (message: string) => void; + logError: (message: string, error: unknown) => void; +}) { + return () => ({ + ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(), + openYomitanSettingsWindow: (params: { + yomitanExt: TYomitanExt; + getExistingWindow: () => TWindow | null; + setWindow: (window: TWindow | null) => void; + }) => deps.openYomitanSettingsWindow(params), + getExistingWindow: () => deps.getExistingWindow(), + setWindow: (window: TWindow | null) => deps.setWindow(window), + logWarn: (message: string) => deps.logWarn(message), + logError: (message: string, error: unknown) => deps.logError(message, error), + }); +} diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts new file mode 100644 index 0000000..53540b6 --- /dev/null +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps'; + +test('build cli command context deps maps handlers and values', () => { + const calls: string[] = []; + const buildDeps = createBuildCliCommandContextDepsHandler({ + getSocketPath: () => '/tmp/mpv.sock', + setSocketPath: (socketPath) => calls.push(`socket:${socketPath}`), + getMpvClient: () => null, + showOsd: (text) => calls.push(`osd:${text}`), + texthookerService: { start: () => null, status: () => ({ running: false }) } as never, + getTexthookerPort: () => 5174, + setTexthookerPort: (port) => calls.push(`port:${port}`), + shouldOpenBrowser: () => true, + openExternal: async (url) => calls.push(`open:${url}`), + logBrowserOpenError: (url) => calls.push(`open-error:${url}`), + isOverlayInitialized: () => true, + initializeOverlay: () => calls.push('init'), + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`), + setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`), + copyCurrentSubtitle: () => calls.push('copy'), + startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`), + mineSentenceCard: async () => { + calls.push('mine'); + }, + startPendingMineSentenceMultiple: (ms) => calls.push(`mine-multi:${ms}`), + updateLastCardFromClipboard: async () => { + calls.push('update'); + }, + refreshKnownWordCache: async () => { + calls.push('refresh'); + }, + triggerFieldGrouping: async () => { + calls.push('group'); + }, + triggerSubsyncFromConfig: async () => { + calls.push('subsync'); + }, + markLastCardAsAudioCard: async () => { + calls.push('mark'); + }, + getAnilistStatus: () => ({}) as never, + clearAnilistToken: () => calls.push('clear-token'), + openAnilistSetup: () => calls.push('anilist'), + openJellyfinSetup: () => calls.push('jellyfin'), + getAnilistQueueStatus: () => ({}) as never, + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + runJellyfinCommand: async () => { + calls.push('run-jellyfin'); + }, + openYomitanSettings: () => calls.push('yomitan'), + cycleSecondarySubMode: () => calls.push('cycle-secondary'), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + printHelp: () => calls.push('help'), + stopApp: () => calls.push('stop'), + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 5000, + schedule: (fn) => { + fn(); + return setTimeout(() => {}, 0); + }, + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + }); + + const deps = buildDeps(); + assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); + assert.equal(deps.getTexthookerPort(), 5174); + assert.equal(deps.shouldOpenBrowser(), true); + assert.equal(deps.isOverlayInitialized(), true); + assert.equal(deps.hasMainWindow(), true); + assert.equal(deps.getMultiCopyTimeoutMs(), 5000); + + deps.setSocketPath('/tmp/next.sock'); + deps.showOsd('hello'); + deps.setTexthookerPort(5175); + deps.printHelp(); + assert.deepEqual(calls, ['socket:/tmp/next.sock', 'osd:hello', 'port:5175', 'help']); +}); diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts new file mode 100644 index 0000000..f268210 --- /dev/null +++ b/src/main/runtime/cli-command-context-deps.ts @@ -0,0 +1,94 @@ +import type { CliArgs } from '../../cli/args'; +import type { CliCommandContextFactoryDeps } from './cli-command-context'; + +export function createBuildCliCommandContextDepsHandler(deps: { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getMpvClient: CliCommandContextFactoryDeps['getMpvClient']; + showOsd: (text: string) => void; + texthookerService: CliCommandContextFactoryDeps['texthookerService']; + getTexthookerPort: () => number; + setTexthookerPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openExternal: (url: string) => Promise; + logBrowserOpenError: (url: string, error: unknown) => void; + isOverlayInitialized: () => boolean; + initializeOverlay: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + setVisibleOverlay: (visible: boolean) => void; + setInvisibleOverlay: (visible: boolean) => void; + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; + clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken']; + openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup']; + openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup']; + getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; + retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow']; + runJellyfinCommand: (args: CliArgs) => Promise; + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, err: unknown) => void; +}) { + return (): CliCommandContextFactoryDeps => ({ + getSocketPath: deps.getSocketPath, + setSocketPath: deps.setSocketPath, + getMpvClient: deps.getMpvClient, + showOsd: deps.showOsd, + texthookerService: deps.texthookerService, + getTexthookerPort: deps.getTexthookerPort, + setTexthookerPort: deps.setTexthookerPort, + shouldOpenBrowser: deps.shouldOpenBrowser, + openExternal: deps.openExternal, + logBrowserOpenError: deps.logBrowserOpenError, + isOverlayInitialized: deps.isOverlayInitialized, + initializeOverlay: deps.initializeOverlay, + toggleVisibleOverlay: deps.toggleVisibleOverlay, + toggleInvisibleOverlay: deps.toggleInvisibleOverlay, + setVisibleOverlay: deps.setVisibleOverlay, + setInvisibleOverlay: deps.setInvisibleOverlay, + copyCurrentSubtitle: deps.copyCurrentSubtitle, + startPendingMultiCopy: deps.startPendingMultiCopy, + mineSentenceCard: deps.mineSentenceCard, + startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: deps.updateLastCardFromClipboard, + refreshKnownWordCache: deps.refreshKnownWordCache, + triggerFieldGrouping: deps.triggerFieldGrouping, + triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, + markLastCardAsAudioCard: deps.markLastCardAsAudioCard, + getAnilistStatus: deps.getAnilistStatus, + clearAnilistToken: deps.clearAnilistToken, + openAnilistSetup: deps.openAnilistSetup, + openJellyfinSetup: deps.openJellyfinSetup, + getAnilistQueueStatus: deps.getAnilistQueueStatus, + retryAnilistQueueNow: deps.retryAnilistQueueNow, + runJellyfinCommand: deps.runJellyfinCommand, + openYomitanSettings: deps.openYomitanSettings, + cycleSecondarySubMode: deps.cycleSecondarySubMode, + openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + printHelp: deps.printHelp, + stopApp: deps.stopApp, + hasMainWindow: deps.hasMainWindow, + getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, + schedule: deps.schedule, + logInfo: deps.logInfo, + logWarn: deps.logWarn, + logError: deps.logError, + }); +} diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts new file mode 100644 index 0000000..584e6c4 --- /dev/null +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCliCommandContextFactory } from './cli-command-context-factory'; + +test('cli command context factory composes main deps and context handlers', () => { + const calls: string[] = []; + const appState = { + mpvSocketPath: '/tmp/mpv.sock', + mpvClient: null, + texthookerPort: 5174, + overlayRuntimeInitialized: false, + }; + + const createContext = createCliCommandContextFactory({ + appState, + texthookerService: { isRunning: () => false, start: () => null }, + getResolvedConfig: () => ({ texthooker: { openBrowser: true } }), + openExternal: async () => {}, + logBrowserOpenError: () => {}, + showMpvOsd: (text) => calls.push(`osd:${text}`), + initializeOverlayRuntime: () => calls.push('init-overlay'), + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), + setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`), + copyCurrentSubtitle: () => calls.push('copy-sub'), + startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), + mineSentenceCard: async () => {}, + startPendingMineSentenceMultiple: () => {}, + updateLastCardFromClipboard: async () => {}, + refreshKnownWordCache: async () => {}, + triggerFieldGrouping: async () => {}, + triggerSubsyncFromConfig: async () => {}, + markLastCardAsAudioCard: async () => {}, + getAnilistStatus: () => ({ + tokenStatus: 'resolved', + tokenSource: 'literal', + tokenMessage: null, + tokenResolvedAt: null, + tokenErrorAt: null, + queuePending: 0, + queueReady: 0, + queueDeadLetter: 0, + queueLastAttemptAt: null, + queueLastError: null, + }), + clearAnilistToken: () => {}, + openAnilistSetupWindow: () => {}, + openJellyfinSetupWindow: () => {}, + getAnilistQueueStatus: () => ({ + pending: 0, + ready: 0, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }), + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), + runJellyfinCommand: async () => {}, + openYomitanSettings: () => {}, + cycleSecondarySubMode: () => {}, + openRuntimeOptionsPalette: () => {}, + printHelp: () => {}, + stopApp: () => {}, + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 5000, + schedule: (fn) => setTimeout(fn, 0), + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + }); + + const context = createContext(); + context.setSocketPath('/tmp/new.sock'); + context.showOsd('hello'); + context.setVisibleOverlay(true); + context.setInvisibleOverlay(false); + context.toggleVisibleOverlay(); + context.toggleInvisibleOverlay(); + + assert.equal(appState.mpvSocketPath, '/tmp/new.sock'); + assert.deepEqual(calls, [ + 'osd:hello', + 'set-visible:true', + 'set-invisible:false', + 'toggle-visible', + 'toggle-invisible', + ]); +}); diff --git a/src/main/runtime/cli-command-context-factory.ts b/src/main/runtime/cli-command-context-factory.ts new file mode 100644 index 0000000..0542236 --- /dev/null +++ b/src/main/runtime/cli-command-context-factory.ts @@ -0,0 +1,16 @@ +import { createCliCommandContext } from './cli-command-context'; +import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps'; +import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps'; + +type CliCommandContextMainDeps = Parameters< + typeof createBuildCliCommandContextMainDepsHandler +>[0]; + +export function createCliCommandContextFactory(deps: CliCommandContextMainDeps) { + const buildCliCommandContextMainDepsHandler = createBuildCliCommandContextMainDepsHandler(deps); + const cliCommandContextMainDeps = buildCliCommandContextMainDepsHandler(); + const buildCliCommandContextDepsHandler = + createBuildCliCommandContextDepsHandler(cliCommandContextMainDeps); + + return () => createCliCommandContext(buildCliCommandContextDepsHandler()); +} diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts new file mode 100644 index 0000000..7a06eb3 --- /dev/null +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps'; + +test('cli command context main deps builder maps state and callbacks', async () => { + const calls: string[] = []; + const appState = { + mpvSocketPath: '/tmp/mpv.sock', + mpvClient: null, + texthookerPort: 5174, + overlayRuntimeInitialized: false, + }; + + const build = createBuildCliCommandContextMainDepsHandler({ + appState, + texthookerService: { isRunning: () => false, start: () => null }, + getResolvedConfig: () => ({ texthooker: { openBrowser: true } }), + openExternal: async (url) => { + calls.push(`open:${url}`); + }, + logBrowserOpenError: (url) => calls.push(`open-error:${url}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + + initializeOverlayRuntime: () => calls.push('init-overlay'), + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), + setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`), + + copyCurrentSubtitle: () => calls.push('copy-sub'), + startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), + mineSentenceCard: async () => { + calls.push('mine'); + }, + startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`), + updateLastCardFromClipboard: async () => { + calls.push('update-last-card'); + }, + refreshKnownWordCache: async () => { + calls.push('refresh-known'); + }, + triggerFieldGrouping: async () => { + calls.push('field-grouping'); + }, + triggerSubsyncFromConfig: async () => { + calls.push('subsync'); + }, + markLastCardAsAudioCard: async () => { + calls.push('mark-audio'); + }, + + getAnilistStatus: () => ({ + tokenStatus: 'resolved', + tokenSource: 'literal', + tokenMessage: null, + tokenResolvedAt: null, + tokenErrorAt: null, + queuePending: 0, + queueReady: 0, + queueDeadLetter: 0, + queueLastAttemptAt: null, + queueLastError: null, + }), + clearAnilistToken: () => calls.push('clear-token'), + openAnilistSetupWindow: () => calls.push('open-anilist-setup'), + openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'), + getAnilistQueueStatus: () => ({ + pending: 1, + ready: 0, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }), + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), + runJellyfinCommand: async () => { + calls.push('run-jellyfin'); + }, + + openYomitanSettings: () => calls.push('open-yomitan'), + cycleSecondarySubMode: () => calls.push('cycle-secondary'), + openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), + printHelp: () => calls.push('help'), + stopApp: () => calls.push('stop-app'), + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 5000, + schedule: (fn) => { + fn(); + return setTimeout(() => {}, 0); + }, + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + }); + + const deps = build(); + assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); + deps.setSocketPath('/tmp/next.sock'); + assert.equal(appState.mpvSocketPath, '/tmp/next.sock'); + assert.equal(deps.getTexthookerPort(), 5174); + deps.setTexthookerPort(5175); + assert.equal(appState.texthookerPort, 5175); + assert.equal(deps.shouldOpenBrowser(), true); + deps.showOsd('hello'); + deps.initializeOverlay(); + deps.setVisibleOverlay(true); + deps.setInvisibleOverlay(false); + deps.printHelp(); + + assert.deepEqual(calls, [ + 'osd:hello', + 'init-overlay', + 'set-visible:true', + 'set-invisible:false', + 'help', + ]); + + const retry = await deps.retryAnilistQueueNow(); + assert.deepEqual(retry, { ok: true, message: 'ok' }); +}); diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts new file mode 100644 index 0000000..5200fd8 --- /dev/null +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -0,0 +1,105 @@ +import type { CliArgs } from '../../cli/args'; +import type { CliCommandContextFactoryDeps } from './cli-command-context'; + +type CliCommandContextMainState = { + mpvSocketPath: string; + mpvClient: ReturnType; + texthookerPort: number; + overlayRuntimeInitialized: boolean; +}; + +export function createBuildCliCommandContextMainDepsHandler(deps: { + appState: CliCommandContextMainState; + texthookerService: CliCommandContextFactoryDeps['texthookerService']; + getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } }; + openExternal: (url: string) => Promise; + logBrowserOpenError: (url: string, error: unknown) => void; + showMpvOsd: (text: string) => void; + + initializeOverlayRuntime: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + setVisibleOverlayVisible: (visible: boolean) => void; + setInvisibleOverlayVisible: (visible: boolean) => void; + + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + + getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; + clearAnilistToken: () => void; + openAnilistSetupWindow: () => void; + openJellyfinSetupWindow: () => void; + getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; + processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow']; + runJellyfinCommand: (args: CliArgs) => Promise; + + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, err: unknown) => void; +}) { + return (): CliCommandContextFactoryDeps => ({ + getSocketPath: () => deps.appState.mpvSocketPath, + setSocketPath: (socketPath: string) => { + deps.appState.mpvSocketPath = socketPath; + }, + getMpvClient: () => deps.appState.mpvClient, + showOsd: (text: string) => deps.showMpvOsd(text), + texthookerService: deps.texthookerService, + getTexthookerPort: () => deps.appState.texthookerPort, + setTexthookerPort: (port: number) => { + deps.appState.texthookerPort = port; + }, + shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false, + openExternal: (url: string) => deps.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error), + isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, + initializeOverlay: () => deps.initializeOverlayRuntime(), + toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(), + setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), + setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible), + copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => deps.mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + deps.startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(), + refreshKnownWordCache: () => deps.refreshKnownWordCache(), + triggerFieldGrouping: () => deps.triggerFieldGrouping(), + triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), + getAnilistStatus: () => deps.getAnilistStatus(), + clearAnilistToken: () => deps.clearAnilistToken(), + openAnilistSetup: () => deps.openAnilistSetupWindow(), + openJellyfinSetup: () => deps.openJellyfinSetupWindow(), + getAnilistQueueStatus: () => deps.getAnilistQueueStatus(), + retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), + runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), + openYomitanSettings: () => deps.openYomitanSettings(), + cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + printHelp: () => deps.printHelp(), + stopApp: () => deps.stopApp(), + hasMainWindow: () => deps.hasMainWindow(), + getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(), + schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs), + logInfo: (message: string) => deps.logInfo(message), + logWarn: (message: string) => deps.logWarn(message), + logError: (message: string, err: unknown) => deps.logError(message, err), + }); +} diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts new file mode 100644 index 0000000..6d73755 --- /dev/null +++ b/src/main/runtime/cli-command-context.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCliCommandContext } from './cli-command-context'; + +function createDeps() { + let socketPath = '/tmp/mpv.sock'; + const logs: string[] = []; + const browserErrors: string[] = []; + + return { + deps: { + getSocketPath: () => socketPath, + setSocketPath: (value: string) => { + socketPath = value; + }, + getMpvClient: () => null, + showOsd: () => {}, + texthookerService: {} as never, + getTexthookerPort: () => 6677, + setTexthookerPort: () => {}, + shouldOpenBrowser: () => true, + openExternal: async () => {}, + logBrowserOpenError: (url: string) => browserErrors.push(url), + isOverlayInitialized: () => true, + initializeOverlay: () => {}, + toggleVisibleOverlay: () => {}, + toggleInvisibleOverlay: () => {}, + setVisibleOverlay: () => {}, + setInvisibleOverlay: () => {}, + copyCurrentSubtitle: () => {}, + startPendingMultiCopy: () => {}, + mineSentenceCard: async () => {}, + startPendingMineSentenceMultiple: () => {}, + updateLastCardFromClipboard: async () => {}, + refreshKnownWordCache: async () => {}, + triggerFieldGrouping: async () => {}, + triggerSubsyncFromConfig: async () => {}, + markLastCardAsAudioCard: async () => {}, + getAnilistStatus: () => ({} as never), + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + openJellyfinSetup: () => {}, + getAnilistQueueStatus: () => ({} as never), + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + runJellyfinCommand: async () => {}, + openYomitanSettings: () => {}, + cycleSecondarySubMode: () => {}, + openRuntimeOptionsPalette: () => {}, + printHelp: () => {}, + stopApp: () => {}, + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 1000, + schedule: (fn: () => void) => setTimeout(fn, 0), + logInfo: (message: string) => { + logs.push(`i:${message}`); + }, + logWarn: (message: string) => { + logs.push(`w:${message}`); + }, + logError: (message: string) => { + logs.push(`e:${message}`); + }, + }, + getLogs: () => logs, + getBrowserErrors: () => browserErrors, + }; +} + +test('cli command context proxies socket path getters/setters', () => { + const { deps } = createDeps(); + const context = createCliCommandContext(deps); + assert.equal(context.getSocketPath(), '/tmp/mpv.sock'); + context.setSocketPath('/tmp/next.sock'); + assert.equal(context.getSocketPath(), '/tmp/next.sock'); +}); + +test('cli command context openInBrowser reports failures', async () => { + const { deps, getBrowserErrors } = createDeps(); + deps.openExternal = async () => { + throw new Error('no browser'); + }; + const context = createCliCommandContext(deps); + context.openInBrowser('https://example.com'); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(getBrowserErrors(), ['https://example.com']); +}); + +test('cli command context log methods map to deps loggers', () => { + const { deps, getLogs } = createDeps(); + const context = createCliCommandContext(deps); + context.log('info'); + context.warn('warn'); + context.error('error', new Error('x')); + assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']); +}); diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts new file mode 100644 index 0000000..a644961 --- /dev/null +++ b/src/main/runtime/cli-command-context.ts @@ -0,0 +1,106 @@ +import type { CliArgs } from '../../cli/args'; +import type { + CliCommandRuntimeServiceContext, + CliCommandRuntimeServiceContextHandlers, +} from '../cli-runtime'; + +type MpvClientLike = CliCommandRuntimeServiceContext['getClient'] extends () => infer T ? T : never; + +export type CliCommandContextFactoryDeps = { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getMpvClient: () => MpvClientLike; + showOsd: (text: string) => void; + texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService']; + getTexthookerPort: () => number; + setTexthookerPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openExternal: (url: string) => Promise; + logBrowserOpenError: (url: string, error: unknown) => void; + isOverlayInitialized: () => boolean; + initializeOverlay: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + setVisibleOverlay: (visible: boolean) => void; + setInvisibleOverlay: (visible: boolean) => void; + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus']; + clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken']; + openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup']; + openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup']; + getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; + retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; + runJellyfinCommand: (args: CliArgs) => Promise; + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, err: unknown) => void; +}; + +export function createCliCommandContext( + deps: CliCommandContextFactoryDeps, +): CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers { + return { + getSocketPath: deps.getSocketPath, + setSocketPath: deps.setSocketPath, + getClient: deps.getMpvClient, + showOsd: deps.showOsd, + texthookerService: deps.texthookerService, + getTexthookerPort: deps.getTexthookerPort, + setTexthookerPort: deps.setTexthookerPort, + shouldOpenBrowser: deps.shouldOpenBrowser, + openInBrowser: (url: string) => { + void deps.openExternal(url).catch((error) => { + deps.logBrowserOpenError(url, error); + }); + }, + isOverlayInitialized: deps.isOverlayInitialized, + initializeOverlay: deps.initializeOverlay, + toggleVisibleOverlay: deps.toggleVisibleOverlay, + toggleInvisibleOverlay: deps.toggleInvisibleOverlay, + setVisibleOverlay: deps.setVisibleOverlay, + setInvisibleOverlay: deps.setInvisibleOverlay, + copyCurrentSubtitle: deps.copyCurrentSubtitle, + startPendingMultiCopy: deps.startPendingMultiCopy, + mineSentenceCard: deps.mineSentenceCard, + startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: deps.updateLastCardFromClipboard, + refreshKnownWordCache: deps.refreshKnownWordCache, + triggerFieldGrouping: deps.triggerFieldGrouping, + triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, + markLastCardAsAudioCard: deps.markLastCardAsAudioCard, + getAnilistStatus: deps.getAnilistStatus, + clearAnilistToken: deps.clearAnilistToken, + openAnilistSetup: deps.openAnilistSetup, + openJellyfinSetup: deps.openJellyfinSetup, + getAnilistQueueStatus: deps.getAnilistQueueStatus, + retryAnilistQueueNow: deps.retryAnilistQueueNow, + runJellyfinCommand: deps.runJellyfinCommand, + openYomitanSettings: deps.openYomitanSettings, + cycleSecondarySubMode: deps.cycleSecondarySubMode, + openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + printHelp: deps.printHelp, + stopApp: deps.stopApp, + hasMainWindow: deps.hasMainWindow, + getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, + schedule: deps.schedule, + log: deps.logInfo, + warn: deps.logWarn, + error: deps.logError, + }; +} diff --git a/src/main/runtime/cli-command-prechecks-main-deps.test.ts b/src/main/runtime/cli-command-prechecks-main-deps.test.ts new file mode 100644 index 0000000..d11f00b --- /dev/null +++ b/src/main/runtime/cli-command-prechecks-main-deps.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps'; + +test('cli prechecks main deps builder maps transition handlers', () => { + const calls: string[] = []; + const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({ + isTexthookerOnlyMode: () => true, + setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`), + commandNeedsOverlayRuntime: () => true, + startBackgroundWarmups: () => calls.push('warmups'), + logInfo: (message) => calls.push(`info:${message}`), + })(); + + assert.equal(deps.isTexthookerOnlyMode(), true); + assert.equal(deps.commandNeedsOverlayRuntime({} as never), true); + deps.setTexthookerOnlyMode(false); + deps.startBackgroundWarmups(); + deps.logInfo('x'); + assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']); +}); diff --git a/src/main/runtime/cli-command-prechecks-main-deps.ts b/src/main/runtime/cli-command-prechecks-main-deps.ts new file mode 100644 index 0000000..ac3b88d --- /dev/null +++ b/src/main/runtime/cli-command-prechecks-main-deps.ts @@ -0,0 +1,17 @@ +import type { CliArgs } from '../../cli/args'; + +export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: { + isTexthookerOnlyMode: () => boolean; + setTexthookerOnlyMode: (enabled: boolean) => void; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + startBackgroundWarmups: () => void; + logInfo: (message: string) => void; +}) { + return () => ({ + isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(), + setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled), + commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args), + startBackgroundWarmups: () => deps.startBackgroundWarmups(), + logInfo: (message: string) => deps.logInfo(message), + }); +} diff --git a/src/main/runtime/cli-command-prechecks.test.ts b/src/main/runtime/cli-command-prechecks.test.ts new file mode 100644 index 0000000..0541d11 --- /dev/null +++ b/src/main/runtime/cli-command-prechecks.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleTexthookerOnlyModeTransitionHandler } from './cli-command-prechecks'; + +test('texthooker precheck no-ops when mode is disabled', () => { + let warmups = 0; + const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({ + isTexthookerOnlyMode: () => false, + setTexthookerOnlyMode: () => {}, + commandNeedsOverlayRuntime: () => true, + startBackgroundWarmups: () => { + warmups += 1; + }, + logInfo: () => {}, + }); + + handlePrecheck({ start: true, texthooker: false } as never); + assert.equal(warmups, 0); +}); + +test('texthooker precheck disables mode and warms up on start command', () => { + let mode = true; + let warmups = 0; + let logs = 0; + const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({ + isTexthookerOnlyMode: () => mode, + setTexthookerOnlyMode: (enabled) => { + mode = enabled; + }, + commandNeedsOverlayRuntime: () => false, + startBackgroundWarmups: () => { + warmups += 1; + }, + logInfo: () => { + logs += 1; + }, + }); + + handlePrecheck({ start: true, texthooker: false } as never); + assert.equal(mode, false); + assert.equal(warmups, 1); + assert.equal(logs, 1); +}); + +test('texthooker precheck no-ops for texthooker command', () => { + let mode = true; + const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({ + isTexthookerOnlyMode: () => mode, + setTexthookerOnlyMode: (enabled) => { + mode = enabled; + }, + commandNeedsOverlayRuntime: () => true, + startBackgroundWarmups: () => {}, + logInfo: () => {}, + }); + + handlePrecheck({ start: true, texthooker: true } as never); + assert.equal(mode, true); +}); diff --git a/src/main/runtime/cli-command-prechecks.ts b/src/main/runtime/cli-command-prechecks.ts new file mode 100644 index 0000000..ee51c1b --- /dev/null +++ b/src/main/runtime/cli-command-prechecks.ts @@ -0,0 +1,21 @@ +import type { CliArgs } from '../../cli/args'; + +export function createHandleTexthookerOnlyModeTransitionHandler(deps: { + isTexthookerOnlyMode: () => boolean; + setTexthookerOnlyMode: (enabled: boolean) => void; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + startBackgroundWarmups: () => void; + logInfo: (message: string) => void; +}) { + return (args: CliArgs): void => { + if ( + deps.isTexthookerOnlyMode() && + !args.texthooker && + (args.start || deps.commandNeedsOverlayRuntime(args)) + ) { + deps.setTexthookerOnlyMode(false); + deps.logInfo('Disabling texthooker-only mode after overlay/start command.'); + deps.startBackgroundWarmups(); + } + }; +} diff --git a/src/main/runtime/cli-command-runtime-handler.test.ts b/src/main/runtime/cli-command-runtime-handler.test.ts new file mode 100644 index 0000000..45ae393 --- /dev/null +++ b/src/main/runtime/cli-command-runtime-handler.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCliCommandRuntimeHandler } from './cli-command-runtime-handler'; + +test('cli command runtime handler applies precheck and forwards command with context', () => { + const calls: string[] = []; + const handler = createCliCommandRuntimeHandler({ + handleTexthookerOnlyModeTransitionMainDeps: { + isTexthookerOnlyMode: () => true, + setTexthookerOnlyMode: () => calls.push('set-mode'), + commandNeedsOverlayRuntime: () => true, + 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({ start: true } as never); + + assert.deepEqual(calls, [ + 'set-mode', + 'log:Disabling texthooker-only mode after overlay/start command.', + 'warmups', + 'context', + 'cli:initial:ctx', + ]); +}); diff --git a/src/main/runtime/cli-command-runtime-handler.ts b/src/main/runtime/cli-command-runtime-handler.ts new file mode 100644 index 0000000..1faeb15 --- /dev/null +++ b/src/main/runtime/cli-command-runtime-handler.ts @@ -0,0 +1,30 @@ +import type { CliArgs, CliCommandSource } from '../../cli/args'; +import { createHandleTexthookerOnlyModeTransitionHandler } from './cli-command-prechecks'; +import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps'; + +type HandleTexthookerOnlyModeTransitionMainDeps = Parameters< + typeof createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler +>[0]; + +export function createCliCommandRuntimeHandler(deps: { + handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps; + createCliCommandContext: () => TCliContext; + handleCliCommandRuntimeServiceWithContext: ( + args: CliArgs, + source: CliCommandSource, + cliContext: TCliContext, + ) => void; +}) { + const handleTexthookerOnlyModeTransitionHandler = + createHandleTexthookerOnlyModeTransitionHandler( + createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler( + deps.handleTexthookerOnlyModeTransitionMainDeps, + )(), + ); + + return (args: CliArgs, source: CliCommandSource = 'initial'): void => { + handleTexthookerOnlyModeTransitionHandler(args); + const cliContext = deps.createCliCommandContext(); + deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext); + }; +} diff --git a/src/main/runtime/clipboard-queue.test.ts b/src/main/runtime/clipboard-queue.test.ts new file mode 100644 index 0000000..08d23bc --- /dev/null +++ b/src/main/runtime/clipboard-queue.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { appendClipboardVideoToQueueRuntime } from './clipboard-queue'; + +test('appendClipboardVideoToQueueRuntime returns disconnected when mpv unavailable', () => { + const result = appendClipboardVideoToQueueRuntime({ + getMpvClient: () => null, + readClipboardText: () => '', + showMpvOsd: () => {}, + sendMpvCommand: () => {}, + }); + assert.deepEqual(result, { ok: false, message: 'MPV is not connected.' }); +}); + +test('appendClipboardVideoToQueueRuntime rejects unsupported clipboard path', () => { + const osdMessages: string[] = []; + const result = appendClipboardVideoToQueueRuntime({ + getMpvClient: () => ({ connected: true }), + readClipboardText: () => 'not a media path', + showMpvOsd: (text) => osdMessages.push(text), + sendMpvCommand: () => {}, + }); + assert.equal(result.ok, false); + assert.equal(osdMessages[0], 'Clipboard does not contain a supported video path.'); +}); + +test('appendClipboardVideoToQueueRuntime queues readable media file', () => { + const tempPath = path.join(process.cwd(), 'dist', 'clipboard-queue-test-video.mkv'); + fs.writeFileSync(tempPath, 'stub'); + + const commands: Array<(string | number)[]> = []; + const osdMessages: string[] = []; + const result = appendClipboardVideoToQueueRuntime({ + getMpvClient: () => ({ connected: true }), + readClipboardText: () => tempPath, + showMpvOsd: (text) => osdMessages.push(text), + sendMpvCommand: (command) => commands.push(command), + }); + + assert.equal(result.ok, true); + assert.deepEqual(commands[0], ['loadfile', tempPath, 'append']); + assert.equal(osdMessages[0], `Queued from clipboard: ${path.basename(tempPath)}`); + + fs.unlinkSync(tempPath); +}); diff --git a/src/main/runtime/clipboard-queue.ts b/src/main/runtime/clipboard-queue.ts new file mode 100644 index 0000000..b718af8 --- /dev/null +++ b/src/main/runtime/clipboard-queue.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parseClipboardVideoPath } from '../../core/services'; + +type MpvClientLike = { + connected: boolean; +}; + +export type AppendClipboardVideoToQueueRuntimeDeps = { + getMpvClient: () => MpvClientLike | null; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + sendMpvCommand: (command: (string | number)[]) => void; +}; + +export function appendClipboardVideoToQueueRuntime( + deps: AppendClipboardVideoToQueueRuntimeDeps, +): { ok: boolean; message: string } { + const mpvClient = deps.getMpvClient(); + if (!mpvClient || !mpvClient.connected) { + return { ok: false, message: 'MPV is not connected.' }; + } + + const clipboardText = deps.readClipboardText(); + const parsedPath = parseClipboardVideoPath(clipboardText); + if (!parsedPath) { + deps.showMpvOsd('Clipboard does not contain a supported video path.'); + return { ok: false, message: 'Clipboard does not contain a supported video path.' }; + } + + const resolvedPath = path.resolve(parsedPath); + if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) { + deps.showMpvOsd('Clipboard path is not a readable file.'); + return { ok: false, message: 'Clipboard path is not a readable file.' }; + } + + deps.sendMpvCommand(['loadfile', resolvedPath, 'append']); + deps.showMpvOsd(`Queued from clipboard: ${path.basename(resolvedPath)}`); + return { ok: true, message: `Queued ${resolvedPath}` }; +} diff --git a/src/main/runtime/composers/anilist-setup-composer.test.ts b/src/main/runtime/composers/anilist-setup-composer.test.ts new file mode 100644 index 0000000..8914125 --- /dev/null +++ b/src/main/runtime/composers/anilist-setup-composer.test.ts @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { composeAnilistSetupHandlers } from './anilist-setup-composer'; + +test('composeAnilistSetupHandlers returns callable setup handlers', () => { + const composed = composeAnilistSetupHandlers({ + notifyDeps: { + hasMpvClient: () => false, + showMpvOsd: () => {}, + showDesktopNotification: () => {}, + logInfo: () => {}, + }, + consumeTokenDeps: { + consumeAnilistSetupCallbackUrl: () => false, + saveToken: () => {}, + setCachedToken: () => {}, + setResolvedState: () => {}, + setSetupPageOpened: () => {}, + onSuccess: () => {}, + closeWindow: () => {}, + }, + handleProtocolDeps: { + consumeAnilistSetupTokenFromUrl: () => false, + logWarn: () => {}, + }, + registerProtocolClientDeps: { + isDefaultApp: () => false, + getArgv: () => [], + execPath: process.execPath, + resolvePath: (value) => value, + setAsDefaultProtocolClient: () => true, + logWarn: () => {}, + }, + }); + + assert.equal(typeof composed.notifyAnilistSetup, 'function'); + assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function'); + assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function'); + assert.equal(typeof composed.registerSubminerProtocolClient, 'function'); +}); diff --git a/src/main/runtime/composers/anilist-setup-composer.ts b/src/main/runtime/composers/anilist-setup-composer.ts new file mode 100644 index 0000000..b2eb911 --- /dev/null +++ b/src/main/runtime/composers/anilist-setup-composer.ts @@ -0,0 +1,56 @@ +import { + createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler, + createBuildHandleAnilistSetupProtocolUrlMainDepsHandler, + createBuildNotifyAnilistSetupMainDepsHandler, + createBuildRegisterSubminerProtocolClientMainDepsHandler, + createConsumeAnilistSetupTokenFromUrlHandler, + createHandleAnilistSetupProtocolUrlHandler, + createNotifyAnilistSetupHandler, + createRegisterSubminerProtocolClientHandler, +} from '../domains/anilist'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type NotifyHandler = ReturnType; +type ConsumeHandler = ReturnType; +type HandleProtocolHandler = ReturnType; +type RegisterClientHandler = ReturnType; + +export type AnilistSetupComposerOptions = ComposerInputs<{ + notifyDeps: Parameters[0]; + consumeTokenDeps: Parameters[0]; + handleProtocolDeps: Parameters[0]; + registerProtocolClientDeps: Parameters< + typeof createBuildRegisterSubminerProtocolClientMainDepsHandler + >[0]; +}>; + +export type AnilistSetupComposerResult = ComposerOutputs<{ + notifyAnilistSetup: NotifyHandler; + consumeAnilistSetupTokenFromUrl: ConsumeHandler; + handleAnilistSetupProtocolUrl: HandleProtocolHandler; + registerSubminerProtocolClient: RegisterClientHandler; +}>; + +export function composeAnilistSetupHandlers( + options: AnilistSetupComposerOptions, +): AnilistSetupComposerResult { + const notifyAnilistSetup = createNotifyAnilistSetupHandler( + createBuildNotifyAnilistSetupMainDepsHandler(options.notifyDeps)(), + ); + const consumeAnilistSetupTokenFromUrl = createConsumeAnilistSetupTokenFromUrlHandler( + createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler(options.consumeTokenDeps)(), + ); + const handleAnilistSetupProtocolUrl = createHandleAnilistSetupProtocolUrlHandler( + createBuildHandleAnilistSetupProtocolUrlMainDepsHandler(options.handleProtocolDeps)(), + ); + const registerSubminerProtocolClient = createRegisterSubminerProtocolClientHandler( + createBuildRegisterSubminerProtocolClientMainDepsHandler(options.registerProtocolClientDeps)(), + ); + + return { + notifyAnilistSetup, + consumeAnilistSetupTokenFromUrl, + handleAnilistSetupProtocolUrl, + registerSubminerProtocolClient, + }; +} diff --git a/src/main/runtime/composers/anilist-tracking-composer.test.ts b/src/main/runtime/composers/anilist-tracking-composer.test.ts new file mode 100644 index 0000000..c21925a --- /dev/null +++ b/src/main/runtime/composers/anilist-tracking-composer.test.ts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AnilistMediaGuess } from '../../../core/services/anilist/anilist-updater'; +import { composeAnilistTrackingHandlers } from './anilist-tracking-composer'; + +test('composeAnilistTrackingHandlers returns callable handlers and forwards calls to deps', async () => { + const refreshSavedTokens: string[] = []; + let refreshCachedToken: string | null = null; + + let mediaKeyState: string | null = 'media-key'; + let mediaDurationSecState: number | null = null; + let mediaGuessState: AnilistMediaGuess | null = null; + let mediaGuessPromiseState: Promise | null = null; + let lastDurationProbeAtMsState = 0; + let requestMpvDurationCalls = 0; + let guessAnilistMediaInfoCalls = 0; + + let retryUpdateCalls = 0; + let maybeRunUpdateCalls = 0; + + const composed = composeAnilistTrackingHandlers({ + refreshClientSecretMainDeps: { + getResolvedConfig: () => ({ anilist: { accessToken: 'refresh-token' } }), + isAnilistTrackingEnabled: () => true, + getCachedAccessToken: () => refreshCachedToken, + setCachedAccessToken: (token) => { + refreshCachedToken = token; + }, + saveStoredToken: (token) => { + refreshSavedTokens.push(token); + }, + loadStoredToken: () => null, + setClientSecretState: () => {}, + getAnilistSetupPageOpened: () => false, + setAnilistSetupPageOpened: () => {}, + openAnilistSetupWindow: () => {}, + now: () => 100, + }, + getCurrentMediaKeyMainDeps: { + getCurrentMediaPath: () => ' media-key ', + }, + resetMediaTrackingMainDeps: { + setMediaKey: (value) => { + mediaKeyState = value; + }, + setMediaDurationSec: (value) => { + mediaDurationSecState = value; + }, + setMediaGuess: (value) => { + mediaGuessState = value; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromiseState = value; + }, + setLastDurationProbeAtMs: (value) => { + lastDurationProbeAtMsState = value; + }, + }, + getMediaGuessRuntimeStateMainDeps: { + getMediaKey: () => mediaKeyState, + getMediaDurationSec: () => mediaDurationSecState, + getMediaGuess: () => mediaGuessState, + getMediaGuessPromise: () => mediaGuessPromiseState, + getLastDurationProbeAtMs: () => lastDurationProbeAtMsState, + }, + setMediaGuessRuntimeStateMainDeps: { + setMediaKey: (value) => { + mediaKeyState = value; + }, + setMediaDurationSec: (value) => { + mediaDurationSecState = value; + }, + setMediaGuess: (value) => { + mediaGuessState = value; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromiseState = value; + }, + setLastDurationProbeAtMs: (value) => { + lastDurationProbeAtMsState = value; + }, + }, + resetMediaGuessStateMainDeps: { + setMediaGuess: (value) => { + mediaGuessState = value; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromiseState = value; + }, + }, + maybeProbeDurationMainDeps: { + getState: () => ({ + mediaKey: mediaKeyState, + mediaDurationSec: mediaDurationSecState, + mediaGuess: mediaGuessState, + mediaGuessPromise: mediaGuessPromiseState, + lastDurationProbeAtMs: lastDurationProbeAtMsState, + }), + setState: (state) => { + mediaKeyState = state.mediaKey; + mediaDurationSecState = state.mediaDurationSec; + mediaGuessState = state.mediaGuess; + mediaGuessPromiseState = state.mediaGuessPromise; + lastDurationProbeAtMsState = state.lastDurationProbeAtMs; + }, + durationRetryIntervalMs: 0, + now: () => 1000, + requestMpvDuration: async () => { + requestMpvDurationCalls += 1; + return 120; + }, + logWarn: () => {}, + }, + ensureMediaGuessMainDeps: { + getState: () => ({ + mediaKey: mediaKeyState, + mediaDurationSec: mediaDurationSecState, + mediaGuess: mediaGuessState, + mediaGuessPromise: mediaGuessPromiseState, + lastDurationProbeAtMs: lastDurationProbeAtMsState, + }), + setState: (state) => { + mediaKeyState = state.mediaKey; + mediaDurationSecState = state.mediaDurationSec; + mediaGuessState = state.mediaGuess; + mediaGuessPromiseState = state.mediaGuessPromise; + lastDurationProbeAtMsState = state.lastDurationProbeAtMs; + }, + resolveMediaPathForJimaku: (value) => value, + getCurrentMediaPath: () => '/tmp/media.mkv', + getCurrentMediaTitle: () => 'Episode title', + guessAnilistMediaInfo: async () => { + guessAnilistMediaInfoCalls += 1; + return { title: 'Episode title', episode: 7, source: 'guessit' }; + }, + }, + processNextRetryUpdateMainDeps: { + nextReady: () => ({ key: 'retry-key', title: 'Retry title', episode: 1 }), + refreshRetryQueueState: () => {}, + setLastAttemptAt: () => {}, + setLastError: () => {}, + refreshAnilistClientSecretState: async () => 'retry-token', + updateAnilistPostWatchProgress: async () => { + retryUpdateCalls += 1; + return { status: 'updated', message: 'ok' }; + }, + markSuccess: () => {}, + rememberAttemptedUpdateKey: () => {}, + markFailure: () => {}, + logInfo: () => {}, + now: () => 1, + }, + maybeRunPostWatchUpdateMainDeps: { + getInFlight: () => false, + setInFlight: () => {}, + getResolvedConfig: () => ({ tracking: true }), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => 'media-key', + hasMpvClient: () => true, + getTrackedMediaKey: () => 'media-key', + resetTrackedMedia: () => {}, + getWatchedSeconds: () => 500, + maybeProbeAnilistDuration: async () => 600, + ensureAnilistMediaGuess: async () => ({ + title: 'Episode title', + episode: 2, + source: 'guessit', + }), + hasAttemptedUpdateKey: () => false, + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), + refreshAnilistClientSecretState: async () => 'run-token', + enqueueRetry: () => {}, + markRetryFailure: () => {}, + markRetrySuccess: () => {}, + refreshRetryQueueState: () => {}, + updateAnilistPostWatchProgress: async () => { + maybeRunUpdateCalls += 1; + return { status: 'updated', message: 'updated from maybeRun' }; + }, + rememberAttemptedUpdateKey: () => {}, + showMpvOsd: () => {}, + logInfo: () => {}, + logWarn: () => {}, + minWatchSeconds: 10, + minWatchRatio: 0.5, + }, + }); + + assert.equal(typeof composed.refreshAnilistClientSecretState, 'function'); + assert.equal(typeof composed.getCurrentAnilistMediaKey, 'function'); + assert.equal(typeof composed.resetAnilistMediaTracking, 'function'); + assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function'); + assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function'); + assert.equal(typeof composed.resetAnilistMediaGuessState, 'function'); + assert.equal(typeof composed.maybeProbeAnilistDuration, 'function'); + assert.equal(typeof composed.ensureAnilistMediaGuess, 'function'); + assert.equal(typeof composed.processNextAnilistRetryUpdate, 'function'); + assert.equal(typeof composed.maybeRunAnilistPostWatchUpdate, 'function'); + + const refreshed = await composed.refreshAnilistClientSecretState({ force: true }); + assert.equal(refreshed, 'refresh-token'); + assert.deepEqual(refreshSavedTokens, ['refresh-token']); + + assert.equal(composed.getCurrentAnilistMediaKey(), 'media-key'); + composed.resetAnilistMediaTracking('next-key'); + assert.equal(mediaKeyState, 'next-key'); + assert.equal(mediaDurationSecState, null); + + composed.setAnilistMediaGuessRuntimeState({ + mediaKey: 'media-key', + mediaDurationSec: 90, + mediaGuess: { title: 'Known', episode: 3, source: 'fallback' }, + mediaGuessPromise: null, + lastDurationProbeAtMs: 11, + }); + assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90); + + composed.resetAnilistMediaGuessState(); + assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null); + + mediaKeyState = 'media-key'; + mediaDurationSecState = null; + const probedDuration = await composed.maybeProbeAnilistDuration('media-key'); + assert.equal(probedDuration, 120); + assert.equal(requestMpvDurationCalls, 1); + + mediaGuessState = null; + await composed.ensureAnilistMediaGuess('media-key'); + assert.equal(guessAnilistMediaInfoCalls, 1); + + const retryResult = await composed.processNextAnilistRetryUpdate(); + assert.deepEqual(retryResult, { ok: true, message: 'ok' }); + assert.equal(retryUpdateCalls, 1); + + await composed.maybeRunAnilistPostWatchUpdate(); + assert.equal(maybeRunUpdateCalls, 1); +}); diff --git a/src/main/runtime/composers/anilist-tracking-composer.ts b/src/main/runtime/composers/anilist-tracking-composer.ts new file mode 100644 index 0000000..4282245 --- /dev/null +++ b/src/main/runtime/composers/anilist-tracking-composer.ts @@ -0,0 +1,129 @@ +import { + createBuildEnsureAnilistMediaGuessMainDepsHandler, + createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, + createBuildGetCurrentAnilistMediaKeyMainDepsHandler, + createBuildMaybeProbeAnilistDurationMainDepsHandler, + createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, + createBuildProcessNextAnilistRetryUpdateMainDepsHandler, + createBuildRefreshAnilistClientSecretStateMainDepsHandler, + createBuildResetAnilistMediaGuessStateMainDepsHandler, + createBuildResetAnilistMediaTrackingMainDepsHandler, + createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, + createEnsureAnilistMediaGuessHandler, + createGetAnilistMediaGuessRuntimeStateHandler, + createGetCurrentAnilistMediaKeyHandler, + createMaybeProbeAnilistDurationHandler, + createMaybeRunAnilistPostWatchUpdateHandler, + createProcessNextAnilistRetryUpdateHandler, + createRefreshAnilistClientSecretStateHandler, + createResetAnilistMediaGuessStateHandler, + createResetAnilistMediaTrackingHandler, + createSetAnilistMediaGuessRuntimeStateHandler, +} from '../domains/anilist'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +export type AnilistTrackingComposerOptions = ComposerInputs<{ + refreshClientSecretMainDeps: Parameters< + typeof createBuildRefreshAnilistClientSecretStateMainDepsHandler + >[0]; + getCurrentMediaKeyMainDeps: Parameters< + typeof createBuildGetCurrentAnilistMediaKeyMainDepsHandler + >[0]; + resetMediaTrackingMainDeps: Parameters< + typeof createBuildResetAnilistMediaTrackingMainDepsHandler + >[0]; + getMediaGuessRuntimeStateMainDeps: Parameters< + typeof createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler + >[0]; + setMediaGuessRuntimeStateMainDeps: Parameters< + typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler + >[0]; + resetMediaGuessStateMainDeps: Parameters< + typeof createBuildResetAnilistMediaGuessStateMainDepsHandler + >[0]; + maybeProbeDurationMainDeps: Parameters< + typeof createBuildMaybeProbeAnilistDurationMainDepsHandler + >[0]; + ensureMediaGuessMainDeps: Parameters[0]; + processNextRetryUpdateMainDeps: Parameters< + typeof createBuildProcessNextAnilistRetryUpdateMainDepsHandler + >[0]; + maybeRunPostWatchUpdateMainDeps: Parameters< + typeof createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler + >[0]; +}>; + +export type AnilistTrackingComposerResult = ComposerOutputs<{ + refreshAnilistClientSecretState: ReturnType; + getCurrentAnilistMediaKey: ReturnType; + resetAnilistMediaTracking: ReturnType; + getAnilistMediaGuessRuntimeState: ReturnType< + typeof createGetAnilistMediaGuessRuntimeStateHandler + >; + setAnilistMediaGuessRuntimeState: ReturnType< + typeof createSetAnilistMediaGuessRuntimeStateHandler + >; + resetAnilistMediaGuessState: ReturnType; + maybeProbeAnilistDuration: ReturnType; + ensureAnilistMediaGuess: ReturnType; + processNextAnilistRetryUpdate: ReturnType; + maybeRunAnilistPostWatchUpdate: ReturnType; +}>; + +export function composeAnilistTrackingHandlers( + options: AnilistTrackingComposerOptions, +): AnilistTrackingComposerResult { + const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler( + createBuildRefreshAnilistClientSecretStateMainDepsHandler( + options.refreshClientSecretMainDeps, + )(), + ); + const getCurrentAnilistMediaKey = createGetCurrentAnilistMediaKeyHandler( + createBuildGetCurrentAnilistMediaKeyMainDepsHandler(options.getCurrentMediaKeyMainDeps)(), + ); + const resetAnilistMediaTracking = createResetAnilistMediaTrackingHandler( + createBuildResetAnilistMediaTrackingMainDepsHandler(options.resetMediaTrackingMainDeps)(), + ); + const getAnilistMediaGuessRuntimeState = createGetAnilistMediaGuessRuntimeStateHandler( + createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler( + options.getMediaGuessRuntimeStateMainDeps, + )(), + ); + const setAnilistMediaGuessRuntimeState = createSetAnilistMediaGuessRuntimeStateHandler( + createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler( + options.setMediaGuessRuntimeStateMainDeps, + )(), + ); + const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler( + createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(), + ); + const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler( + createBuildMaybeProbeAnilistDurationMainDepsHandler(options.maybeProbeDurationMainDeps)(), + ); + const ensureAnilistMediaGuess = createEnsureAnilistMediaGuessHandler( + createBuildEnsureAnilistMediaGuessMainDepsHandler(options.ensureMediaGuessMainDeps)(), + ); + const processNextAnilistRetryUpdate = createProcessNextAnilistRetryUpdateHandler( + createBuildProcessNextAnilistRetryUpdateMainDepsHandler( + options.processNextRetryUpdateMainDeps, + )(), + ); + const maybeRunAnilistPostWatchUpdate = createMaybeRunAnilistPostWatchUpdateHandler( + createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler( + options.maybeRunPostWatchUpdateMainDeps, + )(), + ); + + return { + refreshAnilistClientSecretState, + getCurrentAnilistMediaKey, + resetAnilistMediaTracking, + getAnilistMediaGuessRuntimeState, + setAnilistMediaGuessRuntimeState, + resetAnilistMediaGuessState, + maybeProbeAnilistDuration, + ensureAnilistMediaGuess, + processNextAnilistRetryUpdate, + maybeRunAnilistPostWatchUpdate, + }; +} diff --git a/src/main/runtime/composers/app-ready-composer.test.ts b/src/main/runtime/composers/app-ready-composer.test.ts new file mode 100644 index 0000000..471ab27 --- /dev/null +++ b/src/main/runtime/composers/app-ready-composer.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeAppReadyRuntime } from './app-ready-composer'; + +test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => { + const composed = composeAppReadyRuntime({ + reloadConfigMainDeps: { + reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }), + logInfo: () => {}, + logWarning: () => {}, + showDesktopNotification: () => {}, + startConfigHotReload: () => {}, + refreshAnilistClientSecretState: async () => {}, + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + criticalConfigErrorMainDeps: { + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + appReadyRuntimeMainDeps: { + loadSubtitlePosition: () => {}, + resolveKeybindings: () => {}, + createMpvClient: () => {}, + getResolvedConfig: () => ({}) as never, + getConfigWarnings: () => [], + logConfigWarning: () => {}, + setLogLevel: () => {}, + initRuntimeOptionsManager: () => {}, + setSecondarySubMode: () => {}, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 5174, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => {}, + log: () => {}, + createMecabTokenizerAndCheck: async () => {}, + createSubtitleTimingTracker: () => {}, + loadYomitanExtension: async () => {}, + startJellyfinRemoteSession: async () => {}, + prewarmSubtitleDictionaries: async () => {}, + startBackgroundWarmups: () => {}, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + initializeOverlayRuntime: () => {}, + handleInitialArgs: () => {}, + logDebug: () => {}, + now: () => Date.now(), + }, + immersionTrackerStartupMainDeps: { + getResolvedConfig: () => ({}) as never, + getConfiguredDbPath: () => '/tmp/immersion.sqlite', + createTrackerService: () => + ({ + startSession: () => {}, + }) as never, + setTracker: () => {}, + getMpvClient: () => null, + seedTrackerFromCurrentMedia: () => {}, + logInfo: () => {}, + logDebug: () => {}, + logWarn: () => {}, + }, + }); + + assert.equal(typeof composed.reloadConfig, 'function'); + assert.equal(typeof composed.criticalConfigError, 'function'); + assert.equal(typeof composed.appReadyRuntimeRunner, 'function'); +}); diff --git a/src/main/runtime/composers/app-ready-composer.ts b/src/main/runtime/composers/app-ready-composer.ts new file mode 100644 index 0000000..7de9b8a --- /dev/null +++ b/src/main/runtime/composers/app-ready-composer.ts @@ -0,0 +1,59 @@ +import { createAppReadyRuntimeRunner } from '../../app-lifecycle'; +import { createBuildAppReadyRuntimeMainDepsHandler } from '../app-ready-main-deps'; +import { + createBuildCriticalConfigErrorMainDepsHandler, + createBuildReloadConfigMainDepsHandler, +} from '../startup-config-main-deps'; +import { createCriticalConfigErrorHandler, createReloadConfigHandler } from '../startup-config'; +import { createBuildImmersionTrackerStartupMainDepsHandler } from '../immersion-startup-main-deps'; +import { createImmersionTrackerStartupHandler } from '../immersion-startup'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type ReloadConfigMainDeps = Parameters[0]; +type CriticalConfigErrorMainDeps = Parameters< + typeof createBuildCriticalConfigErrorMainDepsHandler +>[0]; +type AppReadyRuntimeMainDeps = Parameters[0]; + +export type AppReadyComposerOptions = ComposerInputs<{ + reloadConfigMainDeps: ReloadConfigMainDeps; + criticalConfigErrorMainDeps: CriticalConfigErrorMainDeps; + appReadyRuntimeMainDeps: Omit; + immersionTrackerStartupMainDeps: Parameters< + typeof createBuildImmersionTrackerStartupMainDepsHandler + >[0]; +}>; + +export type AppReadyComposerResult = ComposerOutputs<{ + reloadConfig: ReturnType; + criticalConfigError: ReturnType; + appReadyRuntimeRunner: ReturnType; +}>; + +export function composeAppReadyRuntime(options: AppReadyComposerOptions): AppReadyComposerResult { + const reloadConfig = createReloadConfigHandler( + createBuildReloadConfigMainDepsHandler(options.reloadConfigMainDeps)(), + ); + const criticalConfigError = createCriticalConfigErrorHandler( + createBuildCriticalConfigErrorMainDepsHandler(options.criticalConfigErrorMainDeps)(), + ); + + const appReadyRuntimeRunner = createAppReadyRuntimeRunner( + createBuildAppReadyRuntimeMainDepsHandler({ + ...options.appReadyRuntimeMainDeps, + reloadConfig, + createImmersionTracker: createImmersionTrackerStartupHandler( + createBuildImmersionTrackerStartupMainDepsHandler( + options.immersionTrackerStartupMainDeps, + )(), + ), + onCriticalConfigErrors: criticalConfigError, + })(), + ); + + return { + reloadConfig, + criticalConfigError, + appReadyRuntimeRunner, + }; +} diff --git a/src/main/runtime/composers/composer-contracts.type-test.ts b/src/main/runtime/composers/composer-contracts.type-test.ts new file mode 100644 index 0000000..2eee69d --- /dev/null +++ b/src/main/runtime/composers/composer-contracts.type-test.ts @@ -0,0 +1,95 @@ +import type { ComposerInputs } from './contracts'; +import type { IpcRuntimeComposerOptions } from './ipc-runtime-composer'; +import type { JellyfinRemoteComposerOptions } from './jellyfin-remote-composer'; +import type { MpvRuntimeComposerOptions } from './mpv-runtime-composer'; +import type { AnilistSetupComposerOptions } from './anilist-setup-composer'; + +type Assert = T; +type IsAssignable = [From] extends [To] ? true : false; + +type FakeMpvClient = { + on: (...args: unknown[]) => unknown; + connect: () => void; +}; + +type FakeTokenizerDeps = { isKnownWord: (text: string) => boolean }; +type FakeTokenizedSubtitle = { text: string }; + +type RequiredAnilistSetupInputKeys = keyof ComposerInputs; +type RequiredJellyfinInputKeys = keyof ComposerInputs; +type RequiredIpcInputKeys = keyof ComposerInputs; +type RequiredMpvInputKeys = keyof ComposerInputs< + MpvRuntimeComposerOptions +>; + +type _anilistHasNotifyDeps = Assert>; +type _jellyfinHasGetMpvClient = Assert>; +type _ipcHasRegistration = Assert>; +type _mpvHasTokenizer = Assert>; + +// @ts-expect-error missing required notifyDeps should fail compile-time contract +const anilistMissingRequired: AnilistSetupComposerOptions = { + consumeTokenDeps: {} as AnilistSetupComposerOptions['consumeTokenDeps'], + handleProtocolDeps: {} as AnilistSetupComposerOptions['handleProtocolDeps'], + registerProtocolClientDeps: {} as AnilistSetupComposerOptions['registerProtocolClientDeps'], +}; + +// @ts-expect-error missing required getMpvClient should fail compile-time contract +const jellyfinMissingRequired: JellyfinRemoteComposerOptions = { + getConfiguredSession: {} as JellyfinRemoteComposerOptions['getConfiguredSession'], + getClientInfo: {} as JellyfinRemoteComposerOptions['getClientInfo'], + getJellyfinConfig: {} as JellyfinRemoteComposerOptions['getJellyfinConfig'], + playJellyfinItem: {} as JellyfinRemoteComposerOptions['playJellyfinItem'], + logWarn: {} as JellyfinRemoteComposerOptions['logWarn'], + sendMpvCommand: {} as JellyfinRemoteComposerOptions['sendMpvCommand'], + jellyfinTicksToSeconds: {} as JellyfinRemoteComposerOptions['jellyfinTicksToSeconds'], + getActivePlayback: {} as JellyfinRemoteComposerOptions['getActivePlayback'], + clearActivePlayback: {} as JellyfinRemoteComposerOptions['clearActivePlayback'], + getSession: {} as JellyfinRemoteComposerOptions['getSession'], + getNow: {} as JellyfinRemoteComposerOptions['getNow'], + getLastProgressAtMs: {} as JellyfinRemoteComposerOptions['getLastProgressAtMs'], + setLastProgressAtMs: {} as JellyfinRemoteComposerOptions['setLastProgressAtMs'], + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: {} as JellyfinRemoteComposerOptions['logDebug'], +}; + +// @ts-expect-error missing required registration should fail compile-time contract +const ipcMissingRequired: IpcRuntimeComposerOptions = { + mpvCommandMainDeps: {} as IpcRuntimeComposerOptions['mpvCommandMainDeps'], + handleMpvCommandFromIpcRuntime: {} as IpcRuntimeComposerOptions['handleMpvCommandFromIpcRuntime'], + runSubsyncManualFromIpc: {} as IpcRuntimeComposerOptions['runSubsyncManualFromIpc'], +}; + +// @ts-expect-error missing required tokenizer should fail compile-time contract +const mpvMissingRequired: MpvRuntimeComposerOptions< + FakeMpvClient, + FakeTokenizerDeps, + FakeTokenizedSubtitle +> = { + bindMpvMainEventHandlersMainDeps: {} as MpvRuntimeComposerOptions< + FakeMpvClient, + FakeTokenizerDeps, + FakeTokenizedSubtitle + >['bindMpvMainEventHandlersMainDeps'], + mpvClientRuntimeServiceFactoryMainDeps: {} as MpvRuntimeComposerOptions< + FakeMpvClient, + FakeTokenizerDeps, + FakeTokenizedSubtitle + >['mpvClientRuntimeServiceFactoryMainDeps'], + updateMpvSubtitleRenderMetricsMainDeps: {} as MpvRuntimeComposerOptions< + FakeMpvClient, + FakeTokenizerDeps, + FakeTokenizedSubtitle + >['updateMpvSubtitleRenderMetricsMainDeps'], + warmups: {} as MpvRuntimeComposerOptions< + FakeMpvClient, + FakeTokenizerDeps, + FakeTokenizedSubtitle + >['warmups'], +}; + +void anilistMissingRequired; +void jellyfinMissingRequired; +void ipcMissingRequired; +void mpvMissingRequired; diff --git a/src/main/runtime/composers/contracts.ts b/src/main/runtime/composers/contracts.ts new file mode 100644 index 0000000..354b170 --- /dev/null +++ b/src/main/runtime/composers/contracts.ts @@ -0,0 +1,13 @@ +type ComposerShape = Record; + +export type ComposerInputs = Readonly>; + +export type ComposerOutputs = Readonly; + +export type BuiltMainDeps = TFactory extends ( + ...args: infer _TFactoryArgs +) => infer TBuilder + ? TBuilder extends (...args: infer _TBuilderArgs) => infer TDeps + ? TDeps + : never + : never; diff --git a/src/main/runtime/composers/index.ts b/src/main/runtime/composers/index.ts new file mode 100644 index 0000000..4506366 --- /dev/null +++ b/src/main/runtime/composers/index.ts @@ -0,0 +1,10 @@ +export * from './anilist-setup-composer'; +export * from './anilist-tracking-composer'; +export * from './app-ready-composer'; +export * from './contracts'; +export * from './ipc-runtime-composer'; +export * from './jellyfin-remote-composer'; +export * from './jellyfin-runtime-composer'; +export * from './mpv-runtime-composer'; +export * from './shortcuts-runtime-composer'; +export * from './startup-lifecycle-composer'; diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts new file mode 100644 index 0000000..51596dc --- /dev/null +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeIpcRuntimeHandlers } from './ipc-runtime-composer'; + +test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => { + let registered = false; + let receivedSourceTrackId: number | null | undefined; + + const composed = composeIpcRuntimeHandlers({ + mpvCommandMainDeps: { + triggerSubsyncFromConfig: async () => {}, + openRuntimeOptionsPalette: () => {}, + cycleRuntimeOption: () => ({ ok: true }), + showMpvOsd: () => {}, + replayCurrentSubtitle: () => {}, + playNextSubtitle: () => {}, + sendMpvCommand: () => {}, + isMpvConnected: () => false, + hasRuntimeOptionsManager: () => true, + }, + handleMpvCommandFromIpcRuntime: () => {}, + runSubsyncManualFromIpc: async (request) => { + receivedSourceTrackId = request.sourceTrackId; + return { + ok: true, + message: 'ok', + }; + }, + registration: { + runtimeOptions: { + getRuntimeOptionsManager: () => null, + showMpvOsd: () => {}, + }, + mainDeps: { + getInvisibleWindow: () => null, + getMainWindow: () => null, + getVisibleOverlayVisibility: () => false, + getInvisibleOverlayVisibility: () => false, + focusMainWindow: () => {}, + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleVisibleOverlay: () => {}, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getMpvSubtitleRenderMetrics: () => ({}) as never, + getSubtitlePosition: () => ({}) as never, + getSubtitleStyle: () => ({}) as never, + saveSubtitlePosition: () => {}, + getMecabTokenizer: () => null, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}) as never, + getSecondarySubMode: () => 'hover' as never, + getMpvClient: () => null, + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + reportOverlayContentBounds: () => {}, + reportHoveredSubtitleToken: () => {}, + getAnilistStatus: () => ({}) as never, + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + getAnilistQueueStatus: () => ({}) as never, + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + }, + ankiJimakuDeps: { + patchAnkiConnectEnabled: () => {}, + getResolvedConfig: () => ({}) as never, + getRuntimeOptionsManager: () => null, + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getAnkiIntegration: () => null, + setAnkiIntegration: () => {}, + getKnownWordCacheStatePath: () => '', + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => (() => {}) as never, + broadcastRuntimeOptionsChanged: () => {}, + getFieldGroupingResolver: () => null, + setFieldGroupingResolver: () => {}, + parseMediaInfo: () => ({}) as never, + getCurrentMediaPath: () => null, + jimakuFetchJson: async () => ({ data: null }) as never, + getJimakuMaxEntryResults: () => 0, + getJimakuLanguagePreference: () => 'ja' as never, + resolveJimakuApiKey: async () => null, + isRemoteMediaPath: () => false, + downloadToFile: async () => ({ ok: true, path: '/tmp/file' }), + }, + registerIpcRuntimeServices: () => { + registered = true; + }, + }, + }); + + assert.equal(typeof composed.handleMpvCommandFromIpc, 'function'); + assert.equal(typeof composed.runSubsyncManualFromIpc, 'function'); + assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function'); + + const result = await composed.runSubsyncManualFromIpc({ + engine: 'alass', + sourceTrackId: 7, + }); + assert.deepEqual(result, { ok: true, message: 'ok' }); + assert.equal(receivedSourceTrackId, 7); + + composed.registerIpcRuntimeHandlers(); + assert.equal(registered, true); +}); diff --git a/src/main/runtime/composers/ipc-runtime-composer.ts b/src/main/runtime/composers/ipc-runtime-composer.ts new file mode 100644 index 0000000..f0bec72 --- /dev/null +++ b/src/main/runtime/composers/ipc-runtime-composer.ts @@ -0,0 +1,73 @@ +import type { RegisterIpcRuntimeServicesParams } from '../../ipc-runtime'; +import type { SubsyncManualRunRequest, SubsyncResult } from '../../../types'; +import { + createBuildMpvCommandFromIpcRuntimeMainDepsHandler, + createIpcRuntimeHandlers, +} from '../domains/ipc'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type MpvCommand = (string | number)[]; + +type IpcMainDeps = RegisterIpcRuntimeServicesParams['mainDeps']; +type IpcMainDepsWithoutHandlers = Omit; +type RunSubsyncManual = IpcMainDeps['runSubsyncManual']; + +type IpcRuntimeDeps = Parameters< + typeof createIpcRuntimeHandlers +>[0]; + +export type IpcRuntimeComposerOptions = ComposerInputs<{ + mpvCommandMainDeps: Parameters[0]; + handleMpvCommandFromIpcRuntime: IpcRuntimeDeps['handleMpvCommandFromIpcDeps']['handleMpvCommandFromIpcRuntime']; + runSubsyncManualFromIpc: RunSubsyncManual; + registration: { + runtimeOptions: RegisterIpcRuntimeServicesParams['runtimeOptions']; + mainDeps: IpcMainDepsWithoutHandlers; + ankiJimakuDeps: RegisterIpcRuntimeServicesParams['ankiJimakuDeps']; + registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void; + }; +}>; + +export type IpcRuntimeComposerResult = ComposerOutputs<{ + handleMpvCommandFromIpc: (command: MpvCommand) => void; + runSubsyncManualFromIpc: RunSubsyncManual; + registerIpcRuntimeHandlers: () => void; +}>; + +export function composeIpcRuntimeHandlers( + options: IpcRuntimeComposerOptions, +): IpcRuntimeComposerResult { + const mpvCommandFromIpcRuntimeMainDeps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler( + options.mpvCommandMainDeps, + )(); + const { handleMpvCommandFromIpc, runSubsyncManualFromIpc } = createIpcRuntimeHandlers< + SubsyncManualRunRequest, + SubsyncResult + >({ + handleMpvCommandFromIpcDeps: { + handleMpvCommandFromIpcRuntime: options.handleMpvCommandFromIpcRuntime, + buildMpvCommandDeps: () => mpvCommandFromIpcRuntimeMainDeps, + }, + runSubsyncManualFromIpcDeps: { + runManualFromIpc: (request) => options.runSubsyncManualFromIpc(request), + }, + }); + + const registerIpcRuntimeHandlers = (): void => { + options.registration.registerIpcRuntimeServices({ + runtimeOptions: options.registration.runtimeOptions, + mainDeps: { + ...options.registration.mainDeps, + handleMpvCommand: (command) => handleMpvCommandFromIpc(command), + runSubsyncManual: (request) => runSubsyncManualFromIpc(request), + }, + ankiJimakuDeps: options.registration.ankiJimakuDeps, + }); + }; + + return { + handleMpvCommandFromIpc: (command) => handleMpvCommandFromIpc(command), + runSubsyncManualFromIpc: (request) => runSubsyncManualFromIpc(request), + registerIpcRuntimeHandlers, + }; +} diff --git a/src/main/runtime/composers/jellyfin-remote-composer.test.ts b/src/main/runtime/composers/jellyfin-remote-composer.test.ts new file mode 100644 index 0000000..32782a9 --- /dev/null +++ b/src/main/runtime/composers/jellyfin-remote-composer.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer'; + +test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', () => { + let lastProgressAt = 0; + const composed = composeJellyfinRemoteHandlers({ + getConfiguredSession: () => null, + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: 'test', deviceId: 'dev' }) as never, + getJellyfinConfig: () => ({ enabled: false }) as never, + playJellyfinItem: async () => {}, + logWarn: () => {}, + getMpvClient: () => null, + sendMpvCommand: () => {}, + jellyfinTicksToSeconds: () => 0, + getActivePlayback: () => null, + clearActivePlayback: () => {}, + getSession: () => null, + getNow: () => 0, + getLastProgressAtMs: () => lastProgressAt, + setLastProgressAtMs: (next) => { + lastProgressAt = next; + }, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function'); + assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function'); + assert.equal(typeof composed.handleJellyfinRemotePlay, 'function'); + assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); + assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); +}); diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts new file mode 100644 index 0000000..a759b88 --- /dev/null +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -0,0 +1,137 @@ +import { + createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler, + createBuildHandleJellyfinRemotePlayMainDepsHandler, + createBuildHandleJellyfinRemotePlaystateMainDepsHandler, + createBuildReportJellyfinRemoteProgressMainDepsHandler, + createBuildReportJellyfinRemoteStoppedMainDepsHandler, + createHandleJellyfinRemoteGeneralCommand, + createHandleJellyfinRemotePlay, + createHandleJellyfinRemotePlaystate, + createReportJellyfinRemoteProgressHandler, + createReportJellyfinRemoteStoppedHandler, +} from '../domains/jellyfin'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type RemotePlayPayload = Parameters>[0]; +type RemotePlaystatePayload = Parameters>[0]; +type RemoteGeneralPayload = Parameters< + ReturnType +>[0]; +type JellyfinRemotePlayMainDeps = Parameters< + typeof createBuildHandleJellyfinRemotePlayMainDepsHandler +>[0]; +type JellyfinRemotePlaystateMainDeps = Parameters< + typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler +>[0]; +type JellyfinRemoteGeneralMainDeps = Parameters< + typeof createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler +>[0]; +type JellyfinRemoteProgressMainDeps = Parameters< + typeof createBuildReportJellyfinRemoteProgressMainDepsHandler +>[0]; + +export type JellyfinRemoteComposerOptions = ComposerInputs<{ + getConfiguredSession: JellyfinRemotePlayMainDeps['getConfiguredSession']; + getClientInfo: JellyfinRemotePlayMainDeps['getClientInfo']; + getJellyfinConfig: JellyfinRemotePlayMainDeps['getJellyfinConfig']; + playJellyfinItem: JellyfinRemotePlayMainDeps['playJellyfinItem']; + logWarn: JellyfinRemotePlayMainDeps['logWarn']; + getMpvClient: JellyfinRemoteProgressMainDeps['getMpvClient']; + sendMpvCommand: JellyfinRemotePlaystateMainDeps['sendMpvCommand']; + jellyfinTicksToSeconds: Parameters< + typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler + >[0]['jellyfinTicksToSeconds']; + getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback']; + clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback']; + getSession: JellyfinRemoteProgressMainDeps['getSession']; + getNow: JellyfinRemoteProgressMainDeps['getNow']; + getLastProgressAtMs: Parameters< + typeof createBuildReportJellyfinRemoteProgressMainDepsHandler + >[0]['getLastProgressAtMs']; + setLastProgressAtMs: Parameters< + typeof createBuildReportJellyfinRemoteProgressMainDepsHandler + >[0]['setLastProgressAtMs']; + progressIntervalMs: number; + ticksPerSecond: number; + logDebug: Parameters< + typeof createBuildReportJellyfinRemoteProgressMainDepsHandler + >[0]['logDebug']; +}>; + +export type JellyfinRemoteComposerResult = ComposerOutputs<{ + reportJellyfinRemoteProgress: ReturnType; + reportJellyfinRemoteStopped: ReturnType; + handleJellyfinRemotePlay: (payload: RemotePlayPayload) => Promise; + handleJellyfinRemotePlaystate: (payload: RemotePlaystatePayload) => Promise; + handleJellyfinRemoteGeneralCommand: (payload: RemoteGeneralPayload) => Promise; +}>; + +export function composeJellyfinRemoteHandlers( + options: JellyfinRemoteComposerOptions, +): JellyfinRemoteComposerResult { + const buildReportJellyfinRemoteProgressMainDepsHandler = + createBuildReportJellyfinRemoteProgressMainDepsHandler({ + getActivePlayback: options.getActivePlayback, + clearActivePlayback: options.clearActivePlayback, + getSession: options.getSession, + getMpvClient: options.getMpvClient, + getNow: options.getNow, + getLastProgressAtMs: options.getLastProgressAtMs, + setLastProgressAtMs: options.setLastProgressAtMs, + progressIntervalMs: options.progressIntervalMs, + ticksPerSecond: options.ticksPerSecond, + logDebug: options.logDebug, + }); + const buildReportJellyfinRemoteStoppedMainDepsHandler = + createBuildReportJellyfinRemoteStoppedMainDepsHandler({ + getActivePlayback: options.getActivePlayback, + clearActivePlayback: options.clearActivePlayback, + getSession: options.getSession, + logDebug: options.logDebug, + }); + const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( + buildReportJellyfinRemoteProgressMainDepsHandler(), + ); + const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler( + buildReportJellyfinRemoteStoppedMainDepsHandler(), + ); + + const buildHandleJellyfinRemotePlayMainDepsHandler = + createBuildHandleJellyfinRemotePlayMainDepsHandler({ + getConfiguredSession: options.getConfiguredSession, + getClientInfo: options.getClientInfo, + getJellyfinConfig: options.getJellyfinConfig, + playJellyfinItem: options.playJellyfinItem, + logWarn: options.logWarn, + }); + const buildHandleJellyfinRemotePlaystateMainDepsHandler = + createBuildHandleJellyfinRemotePlaystateMainDepsHandler({ + getMpvClient: options.getMpvClient, + sendMpvCommand: options.sendMpvCommand, + reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force), + reportJellyfinRemoteStopped: () => reportJellyfinRemoteStopped(), + jellyfinTicksToSeconds: options.jellyfinTicksToSeconds, + }); + const buildHandleJellyfinRemoteGeneralCommandMainDepsHandler = + createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({ + getMpvClient: options.getMpvClient, + sendMpvCommand: options.sendMpvCommand, + getActivePlayback: options.getActivePlayback, + reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force), + logDebug: (message) => options.logDebug(message, undefined), + }); + + return { + reportJellyfinRemoteProgress, + reportJellyfinRemoteStopped, + handleJellyfinRemotePlay: createHandleJellyfinRemotePlay( + buildHandleJellyfinRemotePlayMainDepsHandler(), + ), + handleJellyfinRemotePlaystate: createHandleJellyfinRemotePlaystate( + buildHandleJellyfinRemotePlaystateMainDepsHandler(), + ), + handleJellyfinRemoteGeneralCommand: createHandleJellyfinRemoteGeneralCommand( + buildHandleJellyfinRemoteGeneralCommandMainDepsHandler(), + ), + }; +} diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts new file mode 100644 index 0000000..193c2f9 --- /dev/null +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer'; + +test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => { + let activePlayback: unknown = null; + let lastProgressAtMs = 0; + const composed = composeJellyfinRuntimeHandlers({ + getResolvedJellyfinConfigMainDeps: { + getResolvedConfig: () => ({ jellyfin: { enabled: false, serverUrl: '' } }) as never, + loadStoredSession: () => null, + getEnv: () => undefined, + }, + getJellyfinClientInfoMainDeps: { + getResolvedJellyfinConfig: () => ({}) as never, + getDefaultJellyfinConfig: () => ({ + clientName: 'SubMiner', + clientVersion: 'test', + deviceId: 'dev', + }), + }, + waitForMpvConnectedMainDeps: { + getMpvClient: () => null, + now: () => Date.now(), + sleep: async () => {}, + }, + launchMpvIdleForJellyfinPlaybackMainDeps: { + getSocketPath: () => '/tmp/test-mpv.sock', + platform: 'linux', + execPath: process.execPath, + defaultMpvLogPath: '/tmp/test-mpv.log', + defaultMpvArgs: [], + removeSocketPath: () => {}, + spawnMpv: () => ({ unref: () => {} }) as never, + logWarn: () => {}, + logInfo: () => {}, + }, + ensureMpvConnectedForJellyfinPlaybackMainDeps: { + getMpvClient: () => null, + setMpvClient: () => {}, + createMpvClient: () => ({}) as never, + getAutoLaunchInFlight: () => null, + setAutoLaunchInFlight: () => {}, + connectTimeoutMs: 10, + autoLaunchTimeoutMs: 10, + }, + preloadJellyfinExternalSubtitlesMainDeps: { + listJellyfinSubtitleTracks: async () => [], + getMpvClient: () => null, + sendMpvCommand: () => {}, + wait: async () => {}, + logDebug: () => {}, + }, + playJellyfinItemInMpvMainDeps: { + getMpvClient: () => null, + resolvePlaybackPlan: async () => ({ + mode: 'direct', + url: 'https://example.test/video.m3u8', + title: 'Episode 1', + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + sendMpvCommand: () => {}, + armQuitOnDisconnect: () => {}, + schedule: () => undefined, + convertTicksToSeconds: () => 0, + setActivePlayback: (value) => { + activePlayback = value; + }, + setLastProgressAtMs: (value) => { + lastProgressAtMs = value; + }, + reportPlaying: () => {}, + showMpvOsd: () => {}, + }, + remoteComposerOptions: { + getConfiguredSession: () => null, + logWarn: () => {}, + getMpvClient: () => null, + sendMpvCommand: () => {}, + jellyfinTicksToSeconds: () => 0, + getActivePlayback: () => activePlayback as never, + clearActivePlayback: () => { + activePlayback = null; + }, + getSession: () => null, + getNow: () => Date.now(), + getLastProgressAtMs: () => lastProgressAtMs, + setLastProgressAtMs: (value) => { + lastProgressAtMs = value; + }, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }, + handleJellyfinAuthCommandsMainDeps: { + patchRawConfig: () => {}, + authenticateWithPassword: async () => ({ + serverUrl: 'https://example.test', + username: 'user', + accessToken: 'token', + userId: 'id', + }), + saveStoredSession: () => {}, + clearStoredSession: () => {}, + logInfo: () => {}, + }, + handleJellyfinListCommandsMainDeps: { + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: () => {}, + }, + handleJellyfinPlayCommandMainDeps: { + logWarn: () => {}, + }, + handleJellyfinRemoteAnnounceCommandMainDeps: { + getRemoteSession: () => null, + logInfo: () => {}, + logWarn: () => {}, + }, + startJellyfinRemoteSessionMainDeps: { + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: () => + ({ + start: async () => {}, + }) as never, + defaultDeviceId: 'dev', + defaultClientName: 'SubMiner', + defaultClientVersion: 'test', + logInfo: () => {}, + logWarn: () => {}, + }, + stopJellyfinRemoteSessionMainDeps: { + getCurrentSession: () => null, + setCurrentSession: () => {}, + clearActivePlayback: () => { + activePlayback = null; + }, + }, + runJellyfinCommandMainDeps: { + defaultServerUrl: 'https://example.test', + }, + maybeFocusExistingJellyfinSetupWindowMainDeps: { + getSetupWindow: () => null, + }, + openJellyfinSetupWindowMainDeps: { + createSetupWindow: () => + ({ + focus: () => {}, + webContents: { on: () => {} }, + loadURL: () => {}, + on: () => {}, + isDestroyed: () => false, + close: () => {}, + }) as never, + buildSetupFormHtml: (defaultServer, defaultUser) => + `${defaultServer}${defaultUser}`, + parseSubmissionUrl: () => null, + authenticateWithPassword: async () => ({ + serverUrl: 'https://example.test', + username: 'user', + accessToken: 'token', + userId: 'id', + }), + saveStoredSession: () => {}, + patchJellyfinConfig: () => {}, + logInfo: () => {}, + logError: () => {}, + showMpvOsd: () => {}, + clearSetupWindow: () => {}, + setSetupWindow: () => {}, + encodeURIComponent, + }, + }); + + assert.equal(typeof composed.getResolvedJellyfinConfig, 'function'); + assert.equal(typeof composed.getJellyfinClientInfo, 'function'); + assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function'); + assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function'); + assert.equal(typeof composed.handleJellyfinRemotePlay, 'function'); + assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); + assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); + assert.equal(typeof composed.playJellyfinItemInMpv, 'function'); + assert.equal(typeof composed.startJellyfinRemoteSession, 'function'); + assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); + assert.equal(typeof composed.runJellyfinCommand, 'function'); + assert.equal(typeof composed.openJellyfinSetupWindow, 'function'); +}); diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts new file mode 100644 index 0000000..1ba3111 --- /dev/null +++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts @@ -0,0 +1,290 @@ +import { + buildJellyfinSetupFormHtml, + createEnsureMpvConnectedForJellyfinPlaybackHandler, + createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler, + createBuildGetJellyfinClientInfoMainDepsHandler, + createBuildGetResolvedJellyfinConfigMainDepsHandler, + createBuildHandleJellyfinAuthCommandsMainDepsHandler, + createBuildHandleJellyfinListCommandsMainDepsHandler, + createBuildHandleJellyfinPlayCommandMainDepsHandler, + createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler, + createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler, + createBuildOpenJellyfinSetupWindowMainDepsHandler, + createBuildPlayJellyfinItemInMpvMainDepsHandler, + createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler, + createBuildRunJellyfinCommandMainDepsHandler, + createBuildStartJellyfinRemoteSessionMainDepsHandler, + createBuildStopJellyfinRemoteSessionMainDepsHandler, + createBuildWaitForMpvConnectedMainDepsHandler, + createGetJellyfinClientInfoHandler, + createGetResolvedJellyfinConfigHandler, + createHandleJellyfinAuthCommands, + createHandleJellyfinListCommands, + createHandleJellyfinPlayCommand, + createHandleJellyfinRemoteAnnounceCommand, + createLaunchMpvIdleForJellyfinPlaybackHandler, + createOpenJellyfinSetupWindowHandler, + createPlayJellyfinItemInMpvHandler, + createPreloadJellyfinExternalSubtitlesHandler, + createRunJellyfinCommandHandler, + createStartJellyfinRemoteSessionHandler, + createStopJellyfinRemoteSessionHandler, + createWaitForMpvConnectedHandler, + createMaybeFocusExistingJellyfinSetupWindowHandler, + parseJellyfinSetupSubmissionUrl, +} from '../domains/jellyfin'; +import { + composeJellyfinRemoteHandlers, + type JellyfinRemoteComposerOptions, +} from './jellyfin-remote-composer'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type EnsureMpvConnectedMainDeps = Parameters< + typeof createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler +>[0]; +type PlayJellyfinItemMainDeps = Parameters< + typeof createBuildPlayJellyfinItemInMpvMainDepsHandler +>[0]; +type HandlePlayCommandMainDeps = Parameters< + typeof createBuildHandleJellyfinPlayCommandMainDepsHandler +>[0]; +type HandleRemoteAnnounceMainDeps = Parameters< + typeof createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler +>[0]; +type StartRemoteSessionMainDeps = Parameters< + typeof createBuildStartJellyfinRemoteSessionMainDepsHandler +>[0]; +type RunJellyfinCommandMainDeps = Parameters< + typeof createBuildRunJellyfinCommandMainDepsHandler +>[0]; +type OpenJellyfinSetupWindowMainDeps = Parameters< + typeof createBuildOpenJellyfinSetupWindowMainDepsHandler +>[0]; + +export type JellyfinRuntimeComposerOptions = ComposerInputs<{ + getResolvedJellyfinConfigMainDeps: Parameters< + typeof createBuildGetResolvedJellyfinConfigMainDepsHandler + >[0]; + getJellyfinClientInfoMainDeps: Parameters< + typeof createBuildGetJellyfinClientInfoMainDepsHandler + >[0]; + waitForMpvConnectedMainDeps: Parameters[0]; + launchMpvIdleForJellyfinPlaybackMainDeps: Parameters< + typeof createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler + >[0]; + ensureMpvConnectedForJellyfinPlaybackMainDeps: Omit< + EnsureMpvConnectedMainDeps, + 'waitForMpvConnected' | 'launchMpvIdleForJellyfinPlayback' + >; + preloadJellyfinExternalSubtitlesMainDeps: Parameters< + typeof createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler + >[0]; + playJellyfinItemInMpvMainDeps: Omit< + PlayJellyfinItemMainDeps, + 'ensureMpvConnectedForPlayback' | 'preloadExternalSubtitles' + >; + remoteComposerOptions: Omit< + JellyfinRemoteComposerOptions, + 'getClientInfo' | 'getJellyfinConfig' | 'playJellyfinItem' + >; + handleJellyfinAuthCommandsMainDeps: Parameters< + typeof createBuildHandleJellyfinAuthCommandsMainDepsHandler + >[0]; + handleJellyfinListCommandsMainDeps: Parameters< + typeof createBuildHandleJellyfinListCommandsMainDepsHandler + >[0]; + handleJellyfinPlayCommandMainDeps: Omit; + handleJellyfinRemoteAnnounceCommandMainDeps: Omit< + HandleRemoteAnnounceMainDeps, + 'startJellyfinRemoteSession' + >; + startJellyfinRemoteSessionMainDeps: Omit< + StartRemoteSessionMainDeps, + 'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand' + >; + stopJellyfinRemoteSessionMainDeps: Parameters< + typeof createBuildStopJellyfinRemoteSessionMainDepsHandler + >[0]; + runJellyfinCommandMainDeps: Omit< + RunJellyfinCommandMainDeps, + | 'getJellyfinConfig' + | 'getJellyfinClientInfo' + | 'handleAuthCommands' + | 'handleRemoteAnnounceCommand' + | 'handleListCommands' + | 'handlePlayCommand' + >; + maybeFocusExistingJellyfinSetupWindowMainDeps: Parameters< + typeof createMaybeFocusExistingJellyfinSetupWindowHandler + >[0]; + openJellyfinSetupWindowMainDeps: Omit< + OpenJellyfinSetupWindowMainDeps, + 'maybeFocusExistingSetupWindow' | 'getResolvedJellyfinConfig' | 'getJellyfinClientInfo' + >; +}>; + +export type JellyfinRuntimeComposerResult = ComposerOutputs<{ + getResolvedJellyfinConfig: ReturnType; + getJellyfinClientInfo: ReturnType; + reportJellyfinRemoteProgress: ReturnType< + typeof composeJellyfinRemoteHandlers + >['reportJellyfinRemoteProgress']; + reportJellyfinRemoteStopped: ReturnType< + typeof composeJellyfinRemoteHandlers + >['reportJellyfinRemoteStopped']; + handleJellyfinRemotePlay: ReturnType< + typeof composeJellyfinRemoteHandlers + >['handleJellyfinRemotePlay']; + handleJellyfinRemotePlaystate: ReturnType< + typeof composeJellyfinRemoteHandlers + >['handleJellyfinRemotePlaystate']; + handleJellyfinRemoteGeneralCommand: ReturnType< + typeof composeJellyfinRemoteHandlers + >['handleJellyfinRemoteGeneralCommand']; + playJellyfinItemInMpv: ReturnType; + startJellyfinRemoteSession: ReturnType; + stopJellyfinRemoteSession: ReturnType; + runJellyfinCommand: ReturnType; + openJellyfinSetupWindow: ReturnType; +}>; + +export function composeJellyfinRuntimeHandlers( + options: JellyfinRuntimeComposerOptions, +): JellyfinRuntimeComposerResult { + const getResolvedJellyfinConfig = createGetResolvedJellyfinConfigHandler( + createBuildGetResolvedJellyfinConfigMainDepsHandler( + options.getResolvedJellyfinConfigMainDeps, + )(), + ); + const getJellyfinClientInfo = createGetJellyfinClientInfoHandler( + createBuildGetJellyfinClientInfoMainDepsHandler(options.getJellyfinClientInfoMainDeps)(), + ); + + const waitForMpvConnected = createWaitForMpvConnectedHandler( + createBuildWaitForMpvConnectedMainDepsHandler(options.waitForMpvConnectedMainDeps)(), + ); + const launchMpvIdleForJellyfinPlayback = createLaunchMpvIdleForJellyfinPlaybackHandler( + createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler( + options.launchMpvIdleForJellyfinPlaybackMainDeps, + )(), + ); + const ensureMpvConnectedForJellyfinPlayback = createEnsureMpvConnectedForJellyfinPlaybackHandler( + createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler({ + ...options.ensureMpvConnectedForJellyfinPlaybackMainDeps, + waitForMpvConnected: (timeoutMs) => waitForMpvConnected(timeoutMs), + launchMpvIdleForJellyfinPlayback: () => launchMpvIdleForJellyfinPlayback(), + })(), + ); + + const preloadJellyfinExternalSubtitles = createPreloadJellyfinExternalSubtitlesHandler( + createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler( + options.preloadJellyfinExternalSubtitlesMainDeps, + )(), + ); + const playJellyfinItemInMpv = createPlayJellyfinItemInMpvHandler( + createBuildPlayJellyfinItemInMpvMainDepsHandler({ + ...options.playJellyfinItemInMpvMainDeps, + ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(), + preloadExternalSubtitles: (params) => { + void preloadJellyfinExternalSubtitles(params); + }, + })(), + ); + + const { + reportJellyfinRemoteProgress, + reportJellyfinRemoteStopped, + handleJellyfinRemotePlay, + handleJellyfinRemotePlaystate, + handleJellyfinRemoteGeneralCommand, + } = composeJellyfinRemoteHandlers({ + ...options.remoteComposerOptions, + getClientInfo: () => getJellyfinClientInfo(), + getJellyfinConfig: () => getResolvedJellyfinConfig(), + playJellyfinItem: (params) => + playJellyfinItemInMpv(params as Parameters[0]), + }); + + const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands( + createBuildHandleJellyfinAuthCommandsMainDepsHandler( + options.handleJellyfinAuthCommandsMainDeps, + )(), + ); + const handleJellyfinListCommands = createHandleJellyfinListCommands( + createBuildHandleJellyfinListCommandsMainDepsHandler( + options.handleJellyfinListCommandsMainDeps, + )(), + ); + const handleJellyfinPlayCommand = createHandleJellyfinPlayCommand( + createBuildHandleJellyfinPlayCommandMainDepsHandler({ + ...options.handleJellyfinPlayCommandMainDeps, + playJellyfinItemInMpv: (params) => + playJellyfinItemInMpv(params as Parameters[0]), + })(), + ); + + let startJellyfinRemoteSession!: ReturnType; + const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand( + createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({ + ...options.handleJellyfinRemoteAnnounceCommandMainDeps, + startJellyfinRemoteSession: () => startJellyfinRemoteSession(), + })(), + ); + + startJellyfinRemoteSession = createStartJellyfinRemoteSessionHandler( + createBuildStartJellyfinRemoteSessionMainDepsHandler({ + ...options.startJellyfinRemoteSessionMainDeps, + getJellyfinConfig: () => getResolvedJellyfinConfig(), + handlePlay: (payload) => handleJellyfinRemotePlay(payload), + handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload), + handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload), + })(), + ); + + const stopJellyfinRemoteSession = createStopJellyfinRemoteSessionHandler( + createBuildStopJellyfinRemoteSessionMainDepsHandler( + options.stopJellyfinRemoteSessionMainDeps, + )(), + ); + + const runJellyfinCommand = createRunJellyfinCommandHandler( + createBuildRunJellyfinCommandMainDepsHandler({ + ...options.runJellyfinCommandMainDeps, + getJellyfinConfig: () => getResolvedJellyfinConfig(), + getJellyfinClientInfo: (jellyfinConfig) => getJellyfinClientInfo(jellyfinConfig), + handleAuthCommands: (params) => handleJellyfinAuthCommands(params), + handleRemoteAnnounceCommand: (args) => handleJellyfinRemoteAnnounceCommand(args), + handleListCommands: (params) => handleJellyfinListCommands(params), + handlePlayCommand: (params) => handleJellyfinPlayCommand(params), + })(), + ); + + const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler( + options.maybeFocusExistingJellyfinSetupWindowMainDeps, + ); + const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler( + createBuildOpenJellyfinSetupWindowMainDepsHandler({ + ...options.openJellyfinSetupWindowMainDeps, + maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow, + getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), + getJellyfinClientInfo: () => getJellyfinClientInfo(), + })(), + ); + + return { + getResolvedJellyfinConfig, + getJellyfinClientInfo, + reportJellyfinRemoteProgress, + reportJellyfinRemoteStopped, + handleJellyfinRemotePlay, + handleJellyfinRemotePlaystate, + handleJellyfinRemoteGeneralCommand, + playJellyfinItemInMpv, + startJellyfinRemoteSession, + stopJellyfinRemoteSession, + runJellyfinCommand, + openJellyfinSetupWindow, + }; +} + +export { buildJellyfinSetupFormHtml, parseJellyfinSetupSubmissionUrl }; diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts new file mode 100644 index 0000000..ca4654f --- /dev/null +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { MpvSubtitleRenderMetrics } from '../../../types'; +import { composeMpvRuntimeHandlers } from './mpv-runtime-composer'; + +const BASE_METRICS: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: '', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 0, + osdDimensions: null, +}; + +test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => { + const calls: string[] = []; + let started = false; + let metrics = BASE_METRICS; + + class FakeMpvClient { + connected = false; + + constructor( + public socketPath: string, + public options: unknown, + ) { + const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay; + calls.push(`create-client:${socketPath}`); + calls.push(`auto-start:${String(autoStartOverlay)}`); + } + + on(): void {} + + connect(): void { + this.connected = true; + calls.push('client-connect'); + } + } + + const composed = composeMpvRuntimeHandlers< + FakeMpvClient, + { isKnownWord: (text: string) => boolean }, + { text: string } + >({ + bindMpvMainEventHandlersMainDeps: { + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: null, + immersionTracker: null, + subtitleTimingTracker: null, + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: null, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + refreshDiscordPresence: () => {}, + updateCurrentMediaPath: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: FakeMpvClient, + getSocketPath: () => '/tmp/mpv.sock', + getResolvedConfig: () => ({ auto_start_overlay: false }), + isAutoStartOverlayEnabled: () => true, + setOverlayVisible: () => {}, + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => metrics, + setCurrentMetrics: (next) => { + metrics = next; + calls.push('set-metrics'); + }, + applyPatch: (current, patch) => { + calls.push('apply-metrics-patch'); + return { next: { ...current, ...patch }, changed: true }; + }, + broadcastMetrics: () => { + calls.push('broadcast-metrics'); + }, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: (text) => text === 'known', + recordLookup: () => {}, + getKnownWordMatchMode: () => 'headword', + getMinSentenceWordsForNPlusOne: () => 3, + getJlptLevel: () => null, + getJlptEnabled: () => true, + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: () => null, + getYomitanGroupDebugEnabled: () => false, + getMecabTokenizer: () => null, + }, + createTokenizerRuntimeDeps: (deps) => { + calls.push('create-tokenizer-runtime-deps'); + return { isKnownWord: (text: string) => deps.isKnownWord(text) }; + }, + tokenizeSubtitle: async (text, deps) => { + calls.push(`tokenize:${text}`); + deps.isKnownWord('known'); + return { text }; + }, + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => ({ id: 'mecab' }), + setMecabTokenizer: () => {}, + createMecabTokenizer: () => ({ id: 'mecab' }), + checkAvailability: async () => { + calls.push('check-mecab'); + }, + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: async () => { + calls.push('prewarm-jlpt'); + }, + ensureFrequencyDictionaryLookup: async () => { + calls.push('prewarm-frequency'); + }, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => 100, + logDebug: () => { + calls.push('warmup-debug'); + }, + logWarn: () => { + calls.push('warmup-warn'); + }, + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => started, + setStarted: (next) => { + started = next; + calls.push(`set-started:${String(next)}`); + }, + isTexthookerOnlyMode: () => false, + ensureYomitanExtensionLoaded: async () => { + calls.push('warmup-yomitan'); + }, + shouldAutoConnectJellyfinRemote: () => false, + startJellyfinRemoteSession: async () => { + calls.push('warmup-jellyfin'); + }, + }, + }, + }); + + assert.equal(typeof composed.bindMpvClientEventHandlers, 'function'); + assert.equal(typeof composed.createMpvClientRuntimeService, 'function'); + assert.equal(typeof composed.updateMpvSubtitleRenderMetrics, 'function'); + assert.equal(typeof composed.tokenizeSubtitle, 'function'); + assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function'); + assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function'); + assert.equal(typeof composed.launchBackgroundWarmupTask, 'function'); + assert.equal(typeof composed.startBackgroundWarmups, 'function'); + + const client = composed.createMpvClientRuntimeService(); + assert.equal(client.connected, true); + + composed.updateMpvSubtitleRenderMetrics({ subPos: 90 }); + const tokenized = await composed.tokenizeSubtitle('subtitle text'); + await composed.createMecabTokenizerAndCheck(); + await composed.prewarmSubtitleDictionaries(); + composed.startBackgroundWarmups(); + + assert.deepEqual(tokenized, { text: 'subtitle text' }); + assert.equal(metrics.subPos, 90); + assert.ok(calls.includes('create-client:/tmp/mpv.sock')); + assert.ok(calls.includes('auto-start:true')); + assert.ok(calls.includes('client-connect')); + assert.ok(calls.includes('apply-metrics-patch')); + assert.ok(calls.includes('set-metrics')); + assert.ok(calls.includes('broadcast-metrics')); + assert.ok(calls.includes('create-tokenizer-runtime-deps')); + assert.ok(calls.includes('tokenize:subtitle text')); + assert.ok(calls.includes('check-mecab')); + assert.ok(calls.includes('prewarm-jlpt')); + assert.ok(calls.includes('prewarm-frequency')); + assert.ok(calls.includes('set-started:true')); + assert.ok(calls.includes('warmup-yomitan')); +}); diff --git a/src/main/runtime/composers/mpv-runtime-composer.ts b/src/main/runtime/composers/mpv-runtime-composer.ts new file mode 100644 index 0000000..9093d6e --- /dev/null +++ b/src/main/runtime/composers/mpv-runtime-composer.ts @@ -0,0 +1,167 @@ +import { createBindMpvMainEventHandlersHandler } from '../mpv-main-event-bindings'; +import { createBuildBindMpvMainEventHandlersMainDepsHandler } from '../mpv-main-event-main-deps'; +import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from '../mpv-client-runtime-service-main-deps'; +import { createMpvClientRuntimeServiceFactory } from '../mpv-client-runtime-service'; +import type { MpvClientRuntimeServiceOptions } from '../mpv-client-runtime-service'; +import type { Config } from '../../../types'; +import { createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler } from '../mpv-subtitle-render-metrics-main-deps'; +import { createUpdateMpvSubtitleRenderMetricsHandler } from '../mpv-subtitle-render-metrics'; +import { + createBuildTokenizerDepsMainHandler, + createCreateMecabTokenizerAndCheckMainHandler, + createPrewarmSubtitleDictionariesMainHandler, +} from '../subtitle-tokenization-main-deps'; +import { + createBuildLaunchBackgroundWarmupTaskMainDepsHandler, + createBuildStartBackgroundWarmupsMainDepsHandler, +} from '../startup-warmups-main-deps'; +import { + createLaunchBackgroundWarmupTaskHandler as createLaunchBackgroundWarmupTaskFromStartup, + createStartBackgroundWarmupsHandler as createStartBackgroundWarmupsFromStartup, +} from '../startup-warmups'; +import type { BuiltMainDeps, ComposerInputs, ComposerOutputs } from './contracts'; + +type BindMpvMainEventHandlersMainDeps = Parameters< + typeof createBuildBindMpvMainEventHandlersMainDepsHandler +>[0]; +type BindMpvMainEventHandlers = ReturnType; +type BoundMpvClient = Parameters[0]; +type RuntimeMpvClient = BoundMpvClient & { connect: () => void }; +type MpvClientRuntimeServiceFactoryMainDeps = Omit< + Parameters< + typeof createBuildMpvClientRuntimeServiceFactoryDepsHandler< + TMpvClient, + Config, + MpvClientRuntimeServiceOptions + > + >[0], + 'bindEventHandlers' +>; +type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters< + typeof createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler +>[0]; +type BuildTokenizerDepsMainDeps = Parameters[0]; +type TokenizerMainDeps = BuiltMainDeps; +type CreateMecabTokenizerAndCheckMainDeps = Parameters< + typeof createCreateMecabTokenizerAndCheckMainHandler +>[0]; +type PrewarmSubtitleDictionariesMainDeps = Parameters< + typeof createPrewarmSubtitleDictionariesMainHandler +>[0]; +type LaunchBackgroundWarmupTaskMainDeps = Parameters< + typeof createBuildLaunchBackgroundWarmupTaskMainDepsHandler +>[0]; +type StartBackgroundWarmupsMainDeps = Omit< + Parameters[0], + 'launchTask' | 'createMecabTokenizerAndCheck' | 'prewarmSubtitleDictionaries' +>; + +export type MpvRuntimeComposerOptions< + TMpvClient extends RuntimeMpvClient, + TTokenizerRuntimeDeps, + TTokenizedSubtitle, +> = ComposerInputs<{ + bindMpvMainEventHandlersMainDeps: BindMpvMainEventHandlersMainDeps; + mpvClientRuntimeServiceFactoryMainDeps: MpvClientRuntimeServiceFactoryMainDeps; + updateMpvSubtitleRenderMetricsMainDeps: UpdateMpvSubtitleRenderMetricsMainDeps; + tokenizer: { + buildTokenizerDepsMainDeps: BuildTokenizerDepsMainDeps; + createTokenizerRuntimeDeps: (deps: TokenizerMainDeps) => TTokenizerRuntimeDeps; + tokenizeSubtitle: (text: string, deps: TTokenizerRuntimeDeps) => Promise; + createMecabTokenizerAndCheckMainDeps: CreateMecabTokenizerAndCheckMainDeps; + prewarmSubtitleDictionariesMainDeps: PrewarmSubtitleDictionariesMainDeps; + }; + warmups: { + launchBackgroundWarmupTaskMainDeps: LaunchBackgroundWarmupTaskMainDeps; + startBackgroundWarmupsMainDeps: StartBackgroundWarmupsMainDeps; + }; +}>; + +export type MpvRuntimeComposerResult< + TMpvClient extends RuntimeMpvClient, + TTokenizedSubtitle, +> = ComposerOutputs<{ + bindMpvClientEventHandlers: BindMpvMainEventHandlers; + createMpvClientRuntimeService: () => TMpvClient; + updateMpvSubtitleRenderMetrics: ReturnType; + tokenizeSubtitle: (text: string) => Promise; + createMecabTokenizerAndCheck: () => Promise; + prewarmSubtitleDictionaries: () => Promise; + launchBackgroundWarmupTask: ReturnType; + startBackgroundWarmups: ReturnType; +}>; + +export function composeMpvRuntimeHandlers< + TMpvClient extends RuntimeMpvClient, + TTokenizerRuntimeDeps, + TTokenizedSubtitle, +>( + options: MpvRuntimeComposerOptions, +): MpvRuntimeComposerResult { + const bindMpvMainEventHandlersMainDeps = createBuildBindMpvMainEventHandlersMainDepsHandler( + options.bindMpvMainEventHandlersMainDeps, + )(); + const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler( + bindMpvMainEventHandlersMainDeps, + ); + + const buildMpvClientRuntimeServiceFactoryMainDepsHandler = + createBuildMpvClientRuntimeServiceFactoryDepsHandler< + TMpvClient, + Config, + MpvClientRuntimeServiceOptions + >({ + ...options.mpvClientRuntimeServiceFactoryMainDeps, + bindEventHandlers: (client) => bindMpvClientEventHandlers(client), + }); + const createMpvClientRuntimeService = (): TMpvClient => + createMpvClientRuntimeServiceFactory(buildMpvClientRuntimeServiceFactoryMainDepsHandler())(); + + const updateMpvSubtitleRenderMetrics = createUpdateMpvSubtitleRenderMetricsHandler( + createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler( + options.updateMpvSubtitleRenderMetricsMainDeps, + )(), + ); + + const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler( + options.tokenizer.buildTokenizerDepsMainDeps, + ); + const createMecabTokenizerAndCheck = createCreateMecabTokenizerAndCheckMainHandler( + options.tokenizer.createMecabTokenizerAndCheckMainDeps, + ); + const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler( + options.tokenizer.prewarmSubtitleDictionariesMainDeps, + ); + const tokenizeSubtitle = async (text: string): Promise => { + await prewarmSubtitleDictionaries(); + return options.tokenizer.tokenizeSubtitle( + text, + options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()), + ); + }; + + const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup( + createBuildLaunchBackgroundWarmupTaskMainDepsHandler( + options.warmups.launchBackgroundWarmupTaskMainDeps, + )(), + ); + const startBackgroundWarmups = createStartBackgroundWarmupsFromStartup( + createBuildStartBackgroundWarmupsMainDepsHandler({ + ...options.warmups.startBackgroundWarmupsMainDeps, + launchTask: (label, task) => launchBackgroundWarmupTask(label, task), + createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), + prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), + })(), + ); + + return { + bindMpvClientEventHandlers: (client) => bindMpvClientEventHandlers(client), + createMpvClientRuntimeService, + updateMpvSubtitleRenderMetrics: (patch) => updateMpvSubtitleRenderMetrics(patch), + tokenizeSubtitle, + createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), + prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), + launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task), + startBackgroundWarmups: () => startBackgroundWarmups(), + }; +} diff --git a/src/main/runtime/composers/shortcuts-runtime-composer.test.ts b/src/main/runtime/composers/shortcuts-runtime-composer.test.ts new file mode 100644 index 0000000..f498a14 --- /dev/null +++ b/src/main/runtime/composers/shortcuts-runtime-composer.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeShortcutRuntimes } from './shortcuts-runtime-composer'; + +test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => { + const composed = composeShortcutRuntimes({ + globalShortcuts: { + getConfiguredShortcutsMainDeps: { + getResolvedConfig: () => ({}) as never, + defaultConfig: {} as never, + resolveConfiguredShortcuts: () => ({}) as never, + }, + buildRegisterGlobalShortcutsMainDeps: () => ({ + getConfiguredShortcuts: () => ({}) as never, + registerGlobalShortcutsCore: () => {}, + toggleVisibleOverlay: () => {}, + toggleInvisibleOverlay: () => {}, + openYomitanSettings: () => {}, + isDev: false, + getMainWindow: () => null, + }), + buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({ + unregisterAllGlobalShortcuts: () => {}, + registerGlobalShortcuts: () => {}, + syncOverlayShortcuts: () => {}, + }), + }, + numericShortcutRuntimeMainDeps: { + globalShortcut: { + register: () => true, + unregister: () => {}, + }, + showMpvOsd: () => {}, + setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), + clearTimer: (timer) => clearTimeout(timer), + }, + numericSessions: { + onMultiCopyDigit: () => {}, + onMineSentenceDigit: () => {}, + }, + overlayShortcutsRuntimeMainDeps: { + overlayShortcutsRuntime: { + registerOverlayShortcuts: () => {}, + unregisterOverlayShortcuts: () => {}, + syncOverlayShortcuts: () => {}, + refreshOverlayShortcuts: () => {}, + }, + }, + }); + + assert.equal(typeof composed.getConfiguredShortcuts, 'function'); + assert.equal(typeof composed.registerGlobalShortcuts, 'function'); + assert.equal(typeof composed.refreshGlobalAndOverlayShortcuts, 'function'); + assert.equal(typeof composed.cancelPendingMultiCopy, 'function'); + assert.equal(typeof composed.startPendingMultiCopy, 'function'); + assert.equal(typeof composed.cancelPendingMineSentenceMultiple, 'function'); + assert.equal(typeof composed.startPendingMineSentenceMultiple, 'function'); + assert.equal(typeof composed.registerOverlayShortcuts, 'function'); + assert.equal(typeof composed.unregisterOverlayShortcuts, 'function'); + assert.equal(typeof composed.syncOverlayShortcuts, 'function'); + assert.equal(typeof composed.refreshOverlayShortcuts, 'function'); +}); diff --git a/src/main/runtime/composers/shortcuts-runtime-composer.ts b/src/main/runtime/composers/shortcuts-runtime-composer.ts new file mode 100644 index 0000000..cd9b53f --- /dev/null +++ b/src/main/runtime/composers/shortcuts-runtime-composer.ts @@ -0,0 +1,60 @@ +import { createNumericShortcutRuntime } from '../../../core/services/numeric-shortcut'; +import { + createBuildNumericShortcutRuntimeMainDepsHandler, + createGlobalShortcutsRuntimeHandlers, + createNumericShortcutSessionRuntimeHandlers, + createOverlayShortcutsRuntimeHandlers, +} from '../domains/shortcuts'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type GlobalShortcutsOptions = Parameters[0]; +type NumericShortcutRuntimeMainDeps = Parameters< + typeof createBuildNumericShortcutRuntimeMainDepsHandler +>[0]; +type NumericSessionOptions = Omit< + Parameters[0], + 'multiCopySession' | 'mineSentenceSession' +>; +type OverlayShortcutsMainDeps = Parameters< + typeof createOverlayShortcutsRuntimeHandlers +>[0]['overlayShortcutsRuntimeMainDeps']; + +export type ShortcutsRuntimeComposerOptions = ComposerInputs<{ + globalShortcuts: GlobalShortcutsOptions; + numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDeps; + numericSessions: NumericSessionOptions; + overlayShortcutsRuntimeMainDeps: OverlayShortcutsMainDeps; +}>; + +export type ShortcutsRuntimeComposerResult = ComposerOutputs< + ReturnType & + ReturnType & + ReturnType +>; + +export function composeShortcutRuntimes( + options: ShortcutsRuntimeComposerOptions, +): ShortcutsRuntimeComposerResult { + const globalShortcuts = createGlobalShortcutsRuntimeHandlers(options.globalShortcuts); + + const numericShortcutRuntimeMainDeps = createBuildNumericShortcutRuntimeMainDepsHandler( + options.numericShortcutRuntimeMainDeps, + )(); + const numericShortcutRuntime = createNumericShortcutRuntime(numericShortcutRuntimeMainDeps); + const numericSessions = createNumericShortcutSessionRuntimeHandlers({ + multiCopySession: numericShortcutRuntime.createSession(), + mineSentenceSession: numericShortcutRuntime.createSession(), + onMultiCopyDigit: options.numericSessions.onMultiCopyDigit, + onMineSentenceDigit: options.numericSessions.onMineSentenceDigit, + }); + + const overlayShortcuts = createOverlayShortcutsRuntimeHandlers({ + overlayShortcutsRuntimeMainDeps: options.overlayShortcutsRuntimeMainDeps, + }); + + return { + ...globalShortcuts, + ...numericSessions, + ...overlayShortcuts, + }; +} diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts new file mode 100644 index 0000000..341359d --- /dev/null +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer'; + +test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => { + const composed = composeStartupLifecycleHandlers({ + registerProtocolUrlHandlersMainDeps: { + registerOpenUrl: () => {}, + registerSecondInstance: () => {}, + handleAnilistSetupProtocolUrl: () => false, + findAnilistSetupDeepLinkArgvUrl: () => null, + logUnhandledOpenUrl: () => {}, + logUnhandledSecondInstanceUrl: () => {}, + }, + onWillQuitCleanupMainDeps: { + destroyTray: () => {}, + stopConfigHotReload: () => {}, + restorePreviousSecondarySubVisibility: () => {}, + unregisterAllGlobalShortcuts: () => {}, + stopSubtitleWebsocket: () => {}, + stopTexthookerService: () => {}, + getYomitanParserWindow: () => null, + clearYomitanParserState: () => {}, + getWindowTracker: () => null, + flushMpvLog: () => {}, + getMpvSocket: () => null, + getReconnectTimer: () => null, + clearReconnectTimerRef: () => {}, + getSubtitleTimingTracker: () => null, + getImmersionTracker: () => null, + clearImmersionTracker: () => {}, + getAnkiIntegration: () => null, + getAnilistSetupWindow: () => null, + clearAnilistSetupWindow: () => {}, + getJellyfinSetupWindow: () => null, + clearJellyfinSetupWindow: () => {}, + stopJellyfinRemoteSession: async () => {}, + stopDiscordPresenceService: () => {}, + }, + shouldRestoreWindowsOnActivateMainDeps: { + isOverlayRuntimeInitialized: () => false, + getAllWindowCount: () => 0, + }, + restoreWindowsOnActivateMainDeps: { + createMainWindow: () => {}, + createInvisibleWindow: () => {}, + updateVisibleOverlayVisibility: () => {}, + updateInvisibleOverlayVisibility: () => {}, + }, + }); + + assert.equal(typeof composed.registerProtocolUrlHandlers, 'function'); + assert.equal(typeof composed.onWillQuitCleanup, 'function'); + assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function'); + assert.equal(typeof composed.restoreWindowsOnActivate, 'function'); +}); diff --git a/src/main/runtime/composers/startup-lifecycle-composer.ts b/src/main/runtime/composers/startup-lifecycle-composer.ts new file mode 100644 index 0000000..07a15b8 --- /dev/null +++ b/src/main/runtime/composers/startup-lifecycle-composer.ts @@ -0,0 +1,66 @@ +import { + createOnWillQuitCleanupHandler, + createRestoreWindowsOnActivateHandler, + createShouldRestoreWindowsOnActivateHandler, +} from '../app-lifecycle-actions'; +import { createBuildOnWillQuitCleanupDepsHandler } from '../app-lifecycle-main-cleanup'; +import { + createBuildRestoreWindowsOnActivateMainDepsHandler, + createBuildShouldRestoreWindowsOnActivateMainDepsHandler, +} from '../app-lifecycle-main-activate'; +import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps'; +import { registerProtocolUrlHandlers } from '../protocol-url-handlers'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; + +type RegisterProtocolUrlHandlersMainDeps = Parameters< + typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler +>[0]; +type OnWillQuitCleanupDeps = Parameters[0]; +type ShouldRestoreWindowsOnActivateMainDeps = Parameters< + typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler +>[0]; +type RestoreWindowsOnActivateMainDeps = Parameters< + typeof createBuildRestoreWindowsOnActivateMainDepsHandler +>[0]; + +export type StartupLifecycleComposerOptions = ComposerInputs<{ + registerProtocolUrlHandlersMainDeps: RegisterProtocolUrlHandlersMainDeps; + onWillQuitCleanupMainDeps: OnWillQuitCleanupDeps; + shouldRestoreWindowsOnActivateMainDeps: ShouldRestoreWindowsOnActivateMainDeps; + restoreWindowsOnActivateMainDeps: RestoreWindowsOnActivateMainDeps; +}>; + +export type StartupLifecycleComposerResult = ComposerOutputs<{ + registerProtocolUrlHandlers: () => void; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; +}>; + +export function composeStartupLifecycleHandlers( + options: StartupLifecycleComposerOptions, +): StartupLifecycleComposerResult { + const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler( + options.registerProtocolUrlHandlersMainDeps, + )(); + + const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler( + createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(), + ); + const shouldRestoreWindowsOnActivateHandler = createShouldRestoreWindowsOnActivateHandler( + createBuildShouldRestoreWindowsOnActivateMainDepsHandler( + options.shouldRestoreWindowsOnActivateMainDeps, + )(), + ); + const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler( + createBuildRestoreWindowsOnActivateMainDepsHandler(options.restoreWindowsOnActivateMainDeps)(), + ); + + return { + registerProtocolUrlHandlers: () => + registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps), + onWillQuitCleanup: () => onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), + }; +} diff --git a/src/main/runtime/config-derived.ts b/src/main/runtime/config-derived.ts new file mode 100644 index 0000000..3c90fc0 --- /dev/null +++ b/src/main/runtime/config-derived.ts @@ -0,0 +1,64 @@ +import type { RuntimeOptionsManager } from '../../runtime-options'; +import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types'; +import { + getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore, + getJimakuLanguagePreference as getJimakuLanguagePreferenceCore, + getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore, + isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore, + jimakuFetchJson as jimakuFetchJsonCore, + resolveJimakuApiKey as resolveJimakuApiKeyCore, + shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore, + shouldBindVisibleOverlayToMpvSubVisibility as shouldBindVisibleOverlayToMpvSubVisibilityCore, +} from '../../core/services'; + +export type ConfigDerivedRuntimeDeps = { + getResolvedConfig: () => ResolvedConfig; + getRuntimeOptionsManager: () => RuntimeOptionsManager | null; + platform: NodeJS.Platform; + defaultJimakuLanguagePreference: JimakuLanguagePreference; + defaultJimakuMaxEntryResults: number; + defaultJimakuApiBaseUrl: string; +}; + +export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): { + getInitialInvisibleOverlayVisibility: () => boolean; + shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isAutoUpdateEnabledRuntime: () => boolean; + getJimakuLanguagePreference: () => JimakuLanguagePreference; + getJimakuMaxEntryResults: () => number; + resolveJimakuApiKey: () => Promise; + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ) => Promise>; +} { + return { + getInitialInvisibleOverlayVisibility: () => + getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform), + shouldAutoInitializeOverlayRuntimeFromConfig: () => + shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()), + shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibilityCore(deps.getResolvedConfig()), + isAutoUpdateEnabledRuntime: () => + isAutoUpdateEnabledRuntimeCore(deps.getResolvedConfig(), deps.getRuntimeOptionsManager()), + getJimakuLanguagePreference: () => + getJimakuLanguagePreferenceCore( + () => deps.getResolvedConfig(), + deps.defaultJimakuLanguagePreference, + ), + getJimakuMaxEntryResults: () => + getJimakuMaxEntryResultsCore(() => deps.getResolvedConfig(), deps.defaultJimakuMaxEntryResults), + resolveJimakuApiKey: () => resolveJimakuApiKeyCore(() => deps.getResolvedConfig()), + jimakuFetchJson: ( + endpoint: string, + query: Record = {}, + ): Promise> => + jimakuFetchJsonCore(endpoint, query, { + getResolvedConfig: () => deps.getResolvedConfig(), + defaultBaseUrl: deps.defaultJimakuApiBaseUrl, + defaultMaxEntryResults: deps.defaultJimakuMaxEntryResults, + defaultLanguagePreference: deps.defaultJimakuLanguagePreference, + }), + }; +} diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts new file mode 100644 index 0000000..344b67e --- /dev/null +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_CONFIG, deepCloneConfig } from '../../config'; +import { + buildRestartRequiredConfigMessage, + createConfigHotReloadAppliedHandler, + createConfigHotReloadMessageHandler, +} from './config-hot-reload-handlers'; + +test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { + const config = deepCloneConfig(DEFAULT_CONFIG); + const calls: string[] = []; + const ankiPatches: Array<{ enabled: boolean }> = []; + + const applyHotReload = createConfigHotReloadAppliedHandler({ + setKeybindings: () => calls.push('set:keybindings'), + refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), + setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`), + broadcastToOverlayWindows: (channel, payload) => + calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`), + applyAnkiRuntimeConfigPatch: (patch) => { + ankiPatches.push({ enabled: patch.ai.enabled }); + }, + }); + + applyHotReload( + { + hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'], + restartRequiredFields: [], + }, + config, + ); + + assert.ok(calls.includes('set:keybindings')); + assert.ok(calls.includes('refresh:shortcuts')); + assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`)); + assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:'))); + assert.ok(calls.includes('broadcast:config:hot-reload:object')); + assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]); +}); + +test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => { + const config = deepCloneConfig(DEFAULT_CONFIG); + const calls: string[] = []; + + const applyHotReload = createConfigHotReloadAppliedHandler({ + setKeybindings: () => calls.push('set:keybindings'), + refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), + setSecondarySubMode: () => calls.push('set:secondary'), + broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), + applyAnkiRuntimeConfigPatch: () => calls.push('anki:patch'), + }); + + applyHotReload( + { + hotReloadFields: [], + restartRequiredFields: [], + }, + config, + ); + + assert.deepEqual(calls, ['set:keybindings']); +}); + +test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { + const calls: string[] = []; + const handleMessage = createConfigHotReloadMessageHandler({ + showMpvOsd: (message) => calls.push(`osd:${message}`), + showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), + }); + + handleMessage('Config reload failed'); + assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']); +}); + +test('buildRestartRequiredConfigMessage formats changed fields', () => { + assert.equal( + buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']), + 'Config updated; restart required for: websocket, subtitleStyle', + ); +}); diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts new file mode 100644 index 0000000..3e41643 --- /dev/null +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -0,0 +1,73 @@ +import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload'; +import { resolveKeybindings } from '../../core/utils'; +import { DEFAULT_KEYBINDINGS } from '../../config'; +import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; + +type ConfigHotReloadAppliedDeps = { + setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; + refreshGlobalAndOverlayShortcuts: () => void; + setSecondarySubMode: (mode: SecondarySubMode) => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void; +}; + +type ConfigHotReloadMessageDeps = { + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; +}; + +export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) { + if (!config.subtitleStyle) { + return null; + } + return { + ...config.subtitleStyle, + nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne, + knownWordColor: config.ankiConnect.nPlusOne.knownWord, + enableJlpt: config.subtitleStyle.enableJlpt, + frequencyDictionary: config.subtitleStyle.frequencyDictionary, + }; +} + +export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload { + return { + keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS), + subtitleStyle: resolveSubtitleStyleForRenderer(config), + secondarySubMode: config.secondarySub.defaultMode, + }; +} + +export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) { + return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { + const payload = buildConfigHotReloadPayload(config); + deps.setKeybindings(payload.keybindings); + + if (diff.hotReloadFields.includes('shortcuts')) { + deps.refreshGlobalAndOverlayShortcuts(); + } + + if (diff.hotReloadFields.includes('secondarySub.defaultMode')) { + deps.setSecondarySubMode(payload.secondarySubMode); + deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode); + } + + if (diff.hotReloadFields.includes('ankiConnect.ai')) { + deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai }); + } + + if (diff.hotReloadFields.length > 0) { + deps.broadcastToOverlayWindows('config:hot-reload', payload); + } + }; +} + +export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) { + return (message: string): void => { + deps.showMpvOsd(message); + deps.showDesktopNotification('SubMiner', { body: message }); + }; +} + +export function buildRestartRequiredConfigMessage(fields: string[]): string { + return `Config updated; restart required for: ${fields.join(', ')}`; +} diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts new file mode 100644 index 0000000..401e450 --- /dev/null +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { ReloadConfigStrictResult } from '../../config'; +import type { ResolvedConfig } from '../../types'; +import { + createBuildConfigHotReloadMessageMainDepsHandler, + createBuildConfigHotReloadAppliedMainDepsHandler, + createBuildConfigHotReloadRuntimeMainDepsHandler, + createBuildWatchConfigPathMainDepsHandler, + createWatchConfigPathHandler, +} from './config-hot-reload-main-deps'; + +test('watch config path handler watches file directly when config exists', () => { + const calls: string[] = []; + const watchConfigPath = createWatchConfigPathHandler({ + fileExists: () => true, + dirname: (path) => path.split('/').slice(0, -1).join('/'), + watchPath: (targetPath, nextListener) => { + calls.push(`watch:${targetPath}`); + nextListener('change', 'ignored'); + return { close: () => calls.push('close') }; + }, + }); + + const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change')); + watcher.close(); + assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']); +}); + +test('watch config path handler filters directory events to config files only', () => { + const calls: string[] = []; + const watchConfigPath = createWatchConfigPathHandler({ + fileExists: () => false, + dirname: (path) => path.split('/').slice(0, -1).join('/'), + watchPath: (_targetPath, nextListener) => { + nextListener('change', 'foo.txt'); + nextListener('change', 'config.json'); + nextListener('change', 'config.jsonc'); + nextListener('change', null); + return { close: () => {} }; + }, + }); + + watchConfigPath('/tmp/config.jsonc', () => calls.push('change')); + assert.deepEqual(calls, ['change', 'change', 'change']); +}); + +test('watch config path main deps builder maps filesystem callbacks', () => { + const calls: string[] = []; + const deps = createBuildWatchConfigPathMainDepsHandler({ + fileExists: () => true, + dirname: (targetPath) => { + calls.push(`dirname:${targetPath}`); + return '/tmp'; + }, + watchPath: (targetPath, listener) => { + calls.push(`watch:${targetPath}`); + listener('change', 'config.jsonc'); + return { close: () => calls.push('close') }; + }, + })(); + + assert.equal(deps.fileExists('/tmp/config.jsonc'), true); + assert.equal(deps.dirname('/tmp/config.jsonc'), '/tmp'); + const watcher = deps.watchPath('/tmp/config.jsonc', () => calls.push('listener')); + watcher.close(); + assert.deepEqual(calls, [ + 'dirname:/tmp/config.jsonc', + 'watch:/tmp/config.jsonc', + 'listener', + 'close', + ]); +}); + +test('config hot reload message main deps builder maps notifications', () => { + const calls: string[] = []; + const deps = createBuildConfigHotReloadMessageMainDepsHandler({ + showMpvOsd: (message) => calls.push(`osd:${message}`), + showDesktopNotification: (title) => calls.push(`notify:${title}`), + })(); + + deps.showMpvOsd('updated'); + deps.showDesktopNotification('SubMiner', { body: 'updated' }); + assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']); +}); + +test('config hot reload applied main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildConfigHotReloadAppliedMainDepsHandler({ + setKeybindings: () => calls.push('keybindings'), + refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), + setSecondarySubMode: () => calls.push('set-secondary'), + broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), + applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'), + })(); + + deps.setKeybindings([]); + deps.refreshGlobalAndOverlayShortcuts(); + deps.setSecondarySubMode('hover'); + deps.broadcastToOverlayWindows('config:hot-reload', {}); + deps.applyAnkiRuntimeConfigPatch({ ai: {} as never }); + assert.deepEqual(calls, [ + 'keybindings', + 'refresh-shortcuts', + 'set-secondary', + 'broadcast:config:hot-reload', + 'apply-anki', + ]); +}); + +test('config hot reload runtime main deps builder maps runtime callbacks', () => { + const calls: string[] = []; + const deps = createBuildConfigHotReloadRuntimeMainDepsHandler({ + getCurrentConfig: () => ({ id: 1 } as never as ResolvedConfig), + reloadConfigStrict: () => + ({ + ok: true, + config: { id: 1 } as never as ResolvedConfig, + warnings: [], + path: '/tmp/config.jsonc', + }) as ReloadConfigStrictResult, + watchConfigPath: (_configPath, _onChange) => ({ close: () => calls.push('close') }), + setTimeout: (callback) => { + callback(); + return 1 as never; + }, + clearTimeout: () => calls.push('clear-timeout'), + debounceMs: 250, + onHotReloadApplied: () => calls.push('hot-reload'), + onRestartRequired: () => calls.push('restart-required'), + onInvalidConfig: () => calls.push('invalid-config'), + onValidationWarnings: () => calls.push('validation-warnings'), + })(); + + assert.deepEqual(deps.getCurrentConfig(), { id: 1 }); + assert.deepEqual(deps.reloadConfigStrict(), { + ok: true, + config: { id: 1 }, + warnings: [], + path: '/tmp/config.jsonc', + }); + assert.equal(deps.debounceMs, 250); + deps.onHotReloadApplied({} as never, {} as never); + deps.onRestartRequired([]); + deps.onInvalidConfig('bad'); + deps.onValidationWarnings('/tmp/config.jsonc', []); + assert.deepEqual(calls, ['hot-reload', 'restart-required', 'invalid-config', 'validation-warnings']); +}); diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts new file mode 100644 index 0000000..c563290 --- /dev/null +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -0,0 +1,102 @@ +import type { + ConfigHotReloadDiff, + ConfigHotReloadRuntimeDeps, +} from '../../core/services/config-hot-reload'; +import type { ReloadConfigStrictResult } from '../../config'; +import type { ConfigHotReloadPayload, ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types'; +import type { createConfigHotReloadMessageHandler } from './config-hot-reload-handlers'; + +type ConfigWatchListener = (eventType: string, filename: string | null) => void; + +export function createWatchConfigPathHandler(deps: { + fileExists: (path: string) => boolean; + dirname: (path: string) => string; + watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void }; +}) { + return (configPath: string, onChange: () => void): { close: () => void } => { + const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath); + const watcher = deps.watchPath(watchTarget, (_eventType, filename) => { + if (watchTarget === configPath) { + onChange(); + return; + } + + const normalized = + typeof filename === 'string' ? filename : filename ? String(filename) : undefined; + if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') { + onChange(); + } + }); + return { + close: () => watcher.close(), + }; + }; +} + +type WatchConfigPathMainDeps = Parameters[0]; +type ConfigHotReloadMessageMainDeps = Parameters[0]; + +export function createBuildWatchConfigPathMainDepsHandler(deps: WatchConfigPathMainDeps) { + return (): WatchConfigPathMainDeps => ({ + fileExists: (targetPath: string) => deps.fileExists(targetPath), + dirname: (targetPath: string) => deps.dirname(targetPath), + watchPath: (targetPath: string, listener: ConfigWatchListener) => + deps.watchPath(targetPath, listener), + }); +} + +export function createBuildConfigHotReloadMessageMainDepsHandler( + deps: ConfigHotReloadMessageMainDeps, +) { + return (): ConfigHotReloadMessageMainDeps => ({ + showMpvOsd: (message: string) => deps.showMpvOsd(message), + showDesktopNotification: (title: string, options: { body: string }) => + deps.showDesktopNotification(title, options), + }); +} + +export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { + setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; + refreshGlobalAndOverlayShortcuts: () => void; + setSecondarySubMode: (mode: SecondarySubMode) => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void; +}) { + return () => ({ + setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => + deps.setKeybindings(keybindings), + refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), + setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), + broadcastToOverlayWindows: (channel: string, payload: unknown) => + deps.broadcastToOverlayWindows(channel, payload), + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => + deps.applyAnkiRuntimeConfigPatch(patch), + }); +} + +export function createBuildConfigHotReloadRuntimeMainDepsHandler(deps: { + getCurrentConfig: () => ResolvedConfig; + reloadConfigStrict: () => ReloadConfigStrictResult; + watchConfigPath: ConfigHotReloadRuntimeDeps['watchConfigPath']; + setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout; + clearTimeout: (timeout: NodeJS.Timeout) => void; + debounceMs: number; + onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void; + onRestartRequired: (fields: string[]) => void; + onInvalidConfig: (message: string) => void; + onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void; +}) { + return (): ConfigHotReloadRuntimeDeps => ({ + getCurrentConfig: () => deps.getCurrentConfig(), + reloadConfigStrict: () => deps.reloadConfigStrict(), + watchConfigPath: (configPath, onChange) => deps.watchConfigPath(configPath, onChange), + setTimeout: (callback: () => void, delayMs: number) => deps.setTimeout(callback, delayMs), + clearTimeout: (timeout: NodeJS.Timeout) => deps.clearTimeout(timeout), + debounceMs: deps.debounceMs, + onHotReloadApplied: (diff, config) => deps.onHotReloadApplied(diff, config), + onRestartRequired: (fields: string[]) => deps.onRestartRequired(fields), + onInvalidConfig: (message: string) => deps.onInvalidConfig(message), + onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => + deps.onValidationWarnings(configPath, warnings), + }); +} diff --git a/src/main/runtime/dictionary-runtime-main-deps.test.ts b/src/main/runtime/dictionary-runtime-main-deps.test.ts new file mode 100644 index 0000000..5785191 --- /dev/null +++ b/src/main/runtime/dictionary-runtime-main-deps.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRuntimeMainDepsHandler, + createBuildJlptDictionaryRuntimeMainDepsHandler, +} from './dictionary-runtime-main-deps'; + +test('dictionary roots main handler returns expected root list', () => { + const roots = createBuildDictionaryRootsMainHandler({ + dirname: '/repo/dist/main', + appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar', + resourcesPath: '/Applications/SubMiner.app/Contents/Resources', + userDataPath: '/Users/a/.config/SubMiner', + appUserDataPath: '/Users/a/Library/Application Support/SubMiner', + homeDir: '/Users/a', + cwd: '/repo', + joinPath: (...parts) => parts.join('/'), + })(); + + assert.equal(roots.length, 11); + assert.equal(roots[0], '/repo/dist/main/../../vendor/yomitan-jlpt-vocab'); + assert.equal(roots[10], '/repo'); +}); + +test('jlpt dictionary runtime main deps builder maps search paths and log prefix', () => { + const calls: string[] = []; + const deps = createBuildJlptDictionaryRuntimeMainDepsHandler({ + isJlptEnabled: () => true, + getDictionaryRoots: () => ['/root/a'], + getJlptDictionarySearchPaths: ({ getDictionaryRoots }) => getDictionaryRoots().map((path) => `${path}/jlpt`), + setJlptLevelLookup: () => calls.push('set-lookup'), + logInfo: (message) => calls.push(`log:${message}`), + })(); + + assert.equal(deps.isJlptEnabled(), true); + assert.deepEqual(deps.getSearchPaths(), ['/root/a/jlpt']); + deps.setJlptLevelLookup(() => null); + deps.log('loaded'); + assert.deepEqual(calls, ['set-lookup', 'log:[JLPT] loaded']); +}); + +test('frequency dictionary roots main handler returns expected root list', () => { + const roots = createBuildFrequencyDictionaryRootsMainHandler({ + dirname: '/repo/dist/main', + appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar', + resourcesPath: '/Applications/SubMiner.app/Contents/Resources', + userDataPath: '/Users/a/.config/SubMiner', + appUserDataPath: '/Users/a/Library/Application Support/SubMiner', + homeDir: '/Users/a', + cwd: '/repo', + joinPath: (...parts) => parts.join('/'), + })(); + + assert.equal(roots.length, 15); + assert.equal(roots[0], '/repo/dist/main/../../vendor/jiten_freq_global'); + assert.equal(roots[14], '/repo'); +}); + +test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => { + const calls: string[] = []; + const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({ + isFrequencyDictionaryEnabled: () => true, + getDictionaryRoots: () => ['/root/a', ''], + getFrequencyDictionarySearchPaths: ({ getDictionaryRoots, getSourcePath }) => [ + ...getDictionaryRoots().map((path) => `${path}/freq`), + getSourcePath() || '', + ], + getSourcePath: () => '/custom/freq.json', + setFrequencyRankLookup: () => calls.push('set-rank'), + logInfo: (message) => calls.push(`log:${message}`), + })(); + + assert.equal(deps.isFrequencyDictionaryEnabled(), true); + assert.deepEqual(deps.getSearchPaths(), ['/root/a/freq', '/custom/freq.json']); + deps.setFrequencyRankLookup(() => null); + deps.log('loaded'); + assert.deepEqual(calls, ['set-rank', 'log:[Frequency] loaded']); +}); diff --git a/src/main/runtime/dictionary-runtime-main-deps.ts b/src/main/runtime/dictionary-runtime-main-deps.ts new file mode 100644 index 0000000..b64ba5a --- /dev/null +++ b/src/main/runtime/dictionary-runtime-main-deps.ts @@ -0,0 +1,100 @@ +import type { FrequencyDictionaryLookup, JlptLevel } from '../../types'; + +type JlptLookup = (term: string) => JlptLevel | null; + +export function createBuildDictionaryRootsMainHandler(deps: { + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + cwd: string; + joinPath: (...parts: string[]) => string; +}) { + return () => [ + deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'), + deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'), + deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'), + deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'), + deps.userDataPath, + deps.appUserDataPath, + deps.joinPath(deps.homeDir, '.config', 'SubMiner'), + deps.joinPath(deps.homeDir, '.config', 'subminer'), + deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'), + deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'), + deps.cwd, + ]; +} + +export function createBuildFrequencyDictionaryRootsMainHandler(deps: { + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + cwd: string; + joinPath: (...parts: string[]) => string; +}) { + return () => [ + deps.joinPath(deps.dirname, '..', '..', 'vendor', 'jiten_freq_global'), + deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'), + deps.joinPath(deps.appPath, 'vendor', 'jiten_freq_global'), + deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'), + deps.joinPath(deps.resourcesPath, 'jiten_freq_global'), + deps.joinPath(deps.resourcesPath, 'frequency-dictionary'), + deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'jiten_freq_global'), + deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'), + deps.userDataPath, + deps.appUserDataPath, + deps.joinPath(deps.homeDir, '.config', 'SubMiner'), + deps.joinPath(deps.homeDir, '.config', 'subminer'), + deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'), + deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'), + deps.cwd, + ]; +} + +export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: { + isJlptEnabled: () => boolean; + getDictionaryRoots: () => string[]; + getJlptDictionarySearchPaths: (deps: { getDictionaryRoots: () => string[] }) => string[]; + setJlptLevelLookup: (lookup: JlptLookup) => void; + logInfo: (message: string) => void; +}) { + return () => ({ + isJlptEnabled: () => deps.isJlptEnabled(), + getSearchPaths: () => + deps.getJlptDictionarySearchPaths({ + getDictionaryRoots: () => deps.getDictionaryRoots(), + }), + setJlptLevelLookup: (lookup: JlptLookup) => deps.setJlptLevelLookup(lookup), + log: (message: string) => deps.logInfo(`[JLPT] ${message}`), + }); +} + +export function createBuildFrequencyDictionaryRuntimeMainDepsHandler(deps: { + isFrequencyDictionaryEnabled: () => boolean; + getDictionaryRoots: () => string[]; + getFrequencyDictionarySearchPaths: (deps: { + getDictionaryRoots: () => string[]; + getSourcePath: () => string | undefined; + }) => string[]; + getSourcePath: () => string | undefined; + setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void; + logInfo: (message: string) => void; +}) { + return () => ({ + isFrequencyDictionaryEnabled: () => deps.isFrequencyDictionaryEnabled(), + getSearchPaths: () => + deps.getFrequencyDictionarySearchPaths({ + getDictionaryRoots: () => + deps.getDictionaryRoots().filter((dictionaryRoot) => dictionaryRoot), + getSourcePath: () => deps.getSourcePath(), + }), + setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => + deps.setFrequencyRankLookup(lookup), + log: (message: string) => deps.logInfo(`[Frequency] ${message}`), + }); +} diff --git a/src/main/runtime/domains/anilist.ts b/src/main/runtime/domains/anilist.ts new file mode 100644 index 0000000..6650c4c --- /dev/null +++ b/src/main/runtime/domains/anilist.ts @@ -0,0 +1,16 @@ +export * from '../anilist-media-guess'; +export * from '../anilist-media-guess-main-deps'; +export * from '../anilist-media-state'; +export * from '../anilist-media-state-main-deps'; +export * from '../anilist-post-watch'; +export * from '../anilist-post-watch-main-deps'; +export * from '../anilist-setup'; +export * from '../anilist-setup-protocol'; +export * from '../anilist-setup-protocol-main-deps'; +export * from '../anilist-setup-window'; +export * from '../anilist-setup-window-main-deps'; +export * from '../anilist-state'; +export * from '../anilist-token-refresh'; +export * from '../anilist-token-refresh-main-deps'; +export * from '../protocol-url-handlers'; +export * from '../protocol-url-handlers-main-deps'; diff --git a/src/main/runtime/domains/index.ts b/src/main/runtime/domains/index.ts new file mode 100644 index 0000000..522dd8b --- /dev/null +++ b/src/main/runtime/domains/index.ts @@ -0,0 +1,8 @@ +export * as anilist from './anilist'; +export * as jellyfin from './jellyfin'; +export * as ipc from './ipc'; +export * as mining from './mining'; +export * as mpv from './mpv'; +export * as overlay from './overlay'; +export * as shortcuts from './shortcuts'; +export * as startup from './startup'; diff --git a/src/main/runtime/domains/ipc.ts b/src/main/runtime/domains/ipc.ts new file mode 100644 index 0000000..eebc266 --- /dev/null +++ b/src/main/runtime/domains/ipc.ts @@ -0,0 +1,5 @@ +export * from '../cli-command-context-factory'; +export * from '../cli-command-runtime-handler'; +export * from '../initial-args-runtime-handler'; +export * from '../ipc-mpv-command-main-deps'; +export * from '../ipc-runtime-handlers'; diff --git a/src/main/runtime/domains/jellyfin.ts b/src/main/runtime/domains/jellyfin.ts new file mode 100644 index 0000000..db75837 --- /dev/null +++ b/src/main/runtime/domains/jellyfin.ts @@ -0,0 +1,24 @@ +export * from '../jellyfin-cli-auth'; +export * from '../jellyfin-cli-list'; +export * from '../jellyfin-cli-main-deps'; +export * from '../jellyfin-cli-play'; +export * from '../jellyfin-cli-remote-announce'; +export * from '../jellyfin-client-info'; +export * from '../jellyfin-client-info-main-deps'; +export * from '../jellyfin-command-dispatch'; +export * from '../jellyfin-command-dispatch-main-deps'; +export * from '../jellyfin-playback-launch'; +export * from '../jellyfin-playback-launch-main-deps'; +export * from '../jellyfin-remote-commands'; +export * from '../jellyfin-remote-connection'; +export * from '../jellyfin-remote-connection-main-deps'; +export * from '../jellyfin-remote-main-deps'; +export * from '../jellyfin-remote-playback'; +export * from '../jellyfin-remote-session-lifecycle'; +export * from '../jellyfin-remote-session-main-deps'; +export * from '../jellyfin-setup-window'; +export * from '../jellyfin-setup-window-main-deps'; +export * from '../jellyfin-subtitle-preload'; +export * from '../jellyfin-subtitle-preload-main-deps'; +export * from '../mpv-jellyfin-defaults'; +export * from '../mpv-jellyfin-defaults-main-deps'; diff --git a/src/main/runtime/domains/mining.ts b/src/main/runtime/domains/mining.ts new file mode 100644 index 0000000..de5644a --- /dev/null +++ b/src/main/runtime/domains/mining.ts @@ -0,0 +1,4 @@ +export * from '../anki-actions'; +export * from '../anki-actions-main-deps'; +export * from '../mining-actions'; +export * from '../mining-actions-main-deps'; diff --git a/src/main/runtime/domains/mpv.ts b/src/main/runtime/domains/mpv.ts new file mode 100644 index 0000000..b343055 --- /dev/null +++ b/src/main/runtime/domains/mpv.ts @@ -0,0 +1,9 @@ +export * from '../mpv-client-runtime-service'; +export * from '../mpv-client-runtime-service-main-deps'; +export * from '../mpv-main-event-bindings'; +export * from '../mpv-main-event-main-deps'; +export * from '../mpv-osd-runtime-handlers'; +export * from '../mpv-subtitle-render-metrics'; +export * from '../mpv-subtitle-render-metrics-main-deps'; +export * from '../secondary-sub-mode-runtime-handler'; +export * from '../subtitle-tokenization-main-deps'; diff --git a/src/main/runtime/domains/overlay.ts b/src/main/runtime/domains/overlay.ts new file mode 100644 index 0000000..0869a2c --- /dev/null +++ b/src/main/runtime/domains/overlay.ts @@ -0,0 +1,22 @@ +export * from '../config-hot-reload-handlers'; +export * from '../config-hot-reload-main-deps'; +export * from '../field-grouping-overlay-main-deps'; +export * from '../field-grouping-resolver'; +export * from '../field-grouping-resolver-main-deps'; +export * from '../overlay-bootstrap-main-deps'; +export * from '../overlay-main-actions'; +export * from '../overlay-main-actions-main-deps'; +export * from '../overlay-runtime-bootstrap-handlers'; +export * from '../overlay-runtime-main-actions'; +export * from '../overlay-runtime-main-actions-main-deps'; +export * from '../overlay-visibility-runtime'; +export * from '../overlay-visibility-runtime-main-deps'; +export * from '../overlay-window-layout'; +export * from '../overlay-window-layout-main-deps'; +export * from '../overlay-window-runtime-handlers'; +export * from '../subtitle-position'; +export * from '../subtitle-position-main-deps'; +export * from '../tray-runtime'; +export * from '../tray-runtime-handlers'; +export * from '../yomitan-extension-runtime'; +export * from '../yomitan-settings-runtime'; diff --git a/src/main/runtime/domains/shortcuts.ts b/src/main/runtime/domains/shortcuts.ts new file mode 100644 index 0000000..30048f4 --- /dev/null +++ b/src/main/runtime/domains/shortcuts.ts @@ -0,0 +1,5 @@ +export * from '../global-shortcuts-runtime-handlers'; +export * from '../numeric-shortcut-runtime-main-deps'; +export * from '../numeric-shortcut-session-runtime-handlers'; +export * from '../overlay-shortcuts-runtime-handlers'; +export * from '../overlay-shortcuts-runtime-main-deps'; diff --git a/src/main/runtime/domains/startup.ts b/src/main/runtime/domains/startup.ts new file mode 100644 index 0000000..599d063 --- /dev/null +++ b/src/main/runtime/domains/startup.ts @@ -0,0 +1,19 @@ +export * from '../app-lifecycle-actions'; +export * from '../app-lifecycle-main-activate'; +export * from '../app-lifecycle-main-cleanup'; +export * from '../app-ready-main-deps'; +export * from '../clipboard-queue'; +export * from '../config-derived'; +export * from '../dictionary-runtime-main-deps'; +export * from '../immersion-media'; +export * from '../immersion-startup'; +export * from '../immersion-startup-main-deps'; +export * from '../media-runtime-main-deps'; +export * from '../runtime-bootstrap-main-deps'; +export * from '../startup-config'; +export * from '../startup-config-main-deps'; +export * from '../startup-runtime-handlers'; +export * from '../startup-warmups'; +export * from '../startup-warmups-main-deps'; +export * from '../subtitle-processing-main-deps'; +export * from '../subsync-runtime'; diff --git a/src/main/runtime/field-grouping-overlay-main-deps.test.ts b/src/main/runtime/field-grouping-overlay-main-deps.test.ts new file mode 100644 index 0000000..4fac2e6 --- /dev/null +++ b/src/main/runtime/field-grouping-overlay-main-deps.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildFieldGroupingOverlayMainDepsHandler } from './field-grouping-overlay-main-deps'; + +test('field grouping overlay main deps builder maps window visibility and resolver wiring', () => { + const calls: string[] = []; + const modalSet = new Set<'runtime-options'>(); + const resolver = (choice: unknown) => calls.push(`resolver:${choice}`); + + const deps = createBuildFieldGroupingOverlayMainDepsHandler({ + getMainWindow: () => ({ + isDestroyed: () => false, + webContents: { + send: () => {}, + }, + }), + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + setInvisibleOverlayVisible: (visible) => calls.push(`invisible:${visible}`), + getResolver: () => resolver, + setResolver: (nextResolver) => { + calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`); + }, + getRestoreVisibleOverlayOnModalClose: () => modalSet, + sendToActiveOverlayWindow: (channel, payload) => { + calls.push(`send:${channel}:${String(payload)}`); + return true; + }, + })(); + + assert.equal(deps.getMainWindow()?.isDestroyed(), false); + assert.equal(deps.getVisibleOverlayVisible(), true); + assert.equal(deps.getInvisibleOverlayVisible(), false); + assert.equal(deps.getResolver(), resolver); + assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet); + deps.setVisibleOverlayVisible(true); + deps.setInvisibleOverlayVisible(false); + deps.setResolver(null); + assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true); + assert.deepEqual(calls, [ + 'visible:true', + 'invisible:false', + 'set-resolver:null', + 'send:kiku:open:1', + ]); +}); diff --git a/src/main/runtime/field-grouping-overlay-main-deps.ts b/src/main/runtime/field-grouping-overlay-main-deps.ts new file mode 100644 index 0000000..9f3de0a --- /dev/null +++ b/src/main/runtime/field-grouping-overlay-main-deps.ts @@ -0,0 +1,39 @@ +import type { FieldGroupingOverlayRuntimeOptions } from '../../core/services/field-grouping-overlay'; + +type FieldGroupingOverlayMainDeps = Omit< + FieldGroupingOverlayRuntimeOptions, + 'sendToVisibleOverlay' +> & { + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: TModal }, + ) => boolean; +}; + +type BuiltFieldGroupingOverlayMainDeps = + FieldGroupingOverlayRuntimeOptions & { + sendToVisibleOverlay: NonNullable< + FieldGroupingOverlayRuntimeOptions['sendToVisibleOverlay'] + >; + }; + +export function createBuildFieldGroupingOverlayMainDepsHandler( + deps: FieldGroupingOverlayMainDeps, +) { + return (): BuiltFieldGroupingOverlayMainDeps => ({ + getMainWindow: () => deps.getMainWindow(), + getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(), + setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible), + setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible), + getResolver: () => deps.getResolver(), + setResolver: (resolver) => deps.setResolver(resolver), + getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(), + sendToVisibleOverlay: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: TModal }, + ) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }); +} diff --git a/src/main/runtime/field-grouping-resolver-main-deps.test.ts b/src/main/runtime/field-grouping-resolver-main-deps.test.ts new file mode 100644 index 0000000..cdfd334 --- /dev/null +++ b/src/main/runtime/field-grouping-resolver-main-deps.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildGetFieldGroupingResolverMainDepsHandler, + createBuildSetFieldGroupingResolverMainDepsHandler, +} from './field-grouping-resolver-main-deps'; + +test('get field grouping resolver main deps builder maps callbacks', () => { + const resolver = () => undefined; + const deps = createBuildGetFieldGroupingResolverMainDepsHandler({ + getResolver: () => resolver, + })(); + assert.equal(deps.getResolver(), resolver); +}); + +test('set field grouping resolver main deps builder maps callbacks', () => { + const calls: string[] = []; + const wrapped = (choice: unknown) => calls.push(String(choice)); + const deps = createBuildSetFieldGroupingResolverMainDepsHandler({ + setResolver: (resolver) => { + if (resolver) { + resolver('x' as never); + } + }, + nextSequence: () => 2, + getSequence: () => 2, + })(); + + assert.equal(deps.nextSequence(), 2); + assert.equal(deps.getSequence(), 2); + deps.setResolver(wrapped as never); + assert.deepEqual(calls, ['x']); +}); diff --git a/src/main/runtime/field-grouping-resolver-main-deps.ts b/src/main/runtime/field-grouping-resolver-main-deps.ts new file mode 100644 index 0000000..73d54cf --- /dev/null +++ b/src/main/runtime/field-grouping-resolver-main-deps.ts @@ -0,0 +1,25 @@ +import type { + createGetFieldGroupingResolverHandler, + createSetFieldGroupingResolverHandler, +} from './field-grouping-resolver'; + +type GetFieldGroupingResolverMainDeps = Parameters[0]; +type SetFieldGroupingResolverMainDeps = Parameters[0]; + +export function createBuildGetFieldGroupingResolverMainDepsHandler( + deps: GetFieldGroupingResolverMainDeps, +) { + return (): GetFieldGroupingResolverMainDeps => ({ + getResolver: () => deps.getResolver(), + }); +} + +export function createBuildSetFieldGroupingResolverMainDepsHandler( + deps: SetFieldGroupingResolverMainDeps, +) { + return (): SetFieldGroupingResolverMainDeps => ({ + setResolver: (resolver) => deps.setResolver(resolver), + nextSequence: () => deps.nextSequence(), + getSequence: () => deps.getSequence(), + }); +} diff --git a/src/main/runtime/field-grouping-resolver.test.ts b/src/main/runtime/field-grouping-resolver.test.ts new file mode 100644 index 0000000..9bdca07 --- /dev/null +++ b/src/main/runtime/field-grouping-resolver.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createGetFieldGroupingResolverHandler, + createSetFieldGroupingResolverHandler, +} from './field-grouping-resolver'; + +test('get field grouping resolver returns current resolver', () => { + const resolver = () => undefined; + const getResolver = createGetFieldGroupingResolverHandler({ + getResolver: () => resolver, + }); + + assert.equal(getResolver(), resolver); +}); + +test('set field grouping resolver clears resolver when null is provided', () => { + let current: ((choice: unknown) => void) | null = () => undefined; + const setResolver = createSetFieldGroupingResolverHandler({ + setResolver: (resolver) => { + current = resolver as never; + }, + nextSequence: () => 1, + getSequence: () => 1, + }); + + setResolver(null); + assert.equal(current, null); +}); + +test('set field grouping resolver wraps resolver and ignores stale sequence', () => { + const calls: string[] = []; + let current: ((choice: unknown) => void) | null = null; + let sequence = 0; + + const setResolver = createSetFieldGroupingResolverHandler({ + setResolver: (resolver) => { + current = resolver as never; + }, + nextSequence: () => { + sequence += 1; + return sequence; + }, + getSequence: () => sequence, + }); + + setResolver((choice) => calls.push(`new:${choice}`)); + const firstWrapped = current!; + setResolver((choice) => calls.push(`latest:${choice}`)); + const latestWrapped = current!; + + firstWrapped('A'); + latestWrapped('B'); + + assert.deepEqual(calls, ['latest:B']); +}); diff --git a/src/main/runtime/field-grouping-resolver.ts b/src/main/runtime/field-grouping-resolver.ts new file mode 100644 index 0000000..3fcba4f --- /dev/null +++ b/src/main/runtime/field-grouping-resolver.ts @@ -0,0 +1,29 @@ +import type { KikuFieldGroupingChoice } from '../../types'; + +type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null; + +export function createGetFieldGroupingResolverHandler(deps: { + getResolver: () => FieldGroupingResolver; +}) { + return (): FieldGroupingResolver => deps.getResolver(); +} + +export function createSetFieldGroupingResolverHandler(deps: { + setResolver: (resolver: FieldGroupingResolver) => void; + nextSequence: () => number; + getSequence: () => number; +}) { + return (resolver: FieldGroupingResolver): void => { + if (!resolver) { + deps.setResolver(null); + return; + } + + const sequence = deps.nextSequence(); + const wrappedResolver = (choice: KikuFieldGroupingChoice): void => { + if (sequence !== deps.getSequence()) return; + resolver(choice); + }; + deps.setResolver(wrappedResolver); + }; +} diff --git a/src/main/runtime/global-shortcuts-main-deps.test.ts b/src/main/runtime/global-shortcuts-main-deps.test.ts new file mode 100644 index 0000000..4a9cb00 --- /dev/null +++ b/src/main/runtime/global-shortcuts-main-deps.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildGetConfiguredShortcutsMainDepsHandler, + createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler, + createBuildRegisterGlobalShortcutsMainDepsHandler, +} from './global-shortcuts-main-deps'; + +test('get configured shortcuts main deps map config resolver inputs', () => { + const config = { shortcuts: { copySubtitle: 's' } } as never; + const defaults = { shortcuts: { copySubtitle: 'c' } } as never; + const build = createBuildGetConfiguredShortcutsMainDepsHandler({ + getResolvedConfig: () => config, + defaultConfig: defaults, + resolveConfiguredShortcuts: (nextConfig, nextDefaults) => ({ nextConfig, nextDefaults }) as never, + }); + + const deps = build(); + assert.equal(deps.getResolvedConfig(), config); + assert.equal(deps.defaultConfig, defaults); + assert.deepEqual(deps.resolveConfiguredShortcuts(config, defaults), { nextConfig: config, nextDefaults: defaults }); +}); + +test('register global shortcuts main deps map callbacks and flags', () => { + const calls: string[] = []; + const mainWindow = { id: 'main' }; + const build = createBuildRegisterGlobalShortcutsMainDepsHandler({ + getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never), + registerGlobalShortcutsCore: () => calls.push('register'), + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + openYomitanSettings: () => calls.push('open-yomitan'), + isDev: true, + getMainWindow: () => mainWindow as never, + }); + + const deps = build(); + deps.registerGlobalShortcutsCore({ + shortcuts: deps.getConfiguredShortcuts(), + onToggleVisibleOverlay: () => undefined, + onToggleInvisibleOverlay: () => undefined, + onOpenYomitanSettings: () => undefined, + isDev: deps.isDev, + getMainWindow: deps.getMainWindow, + }); + deps.onToggleVisibleOverlay(); + deps.onToggleInvisibleOverlay(); + deps.onOpenYomitanSettings(); + assert.equal(deps.isDev, true); + assert.deepEqual(deps.getMainWindow(), mainWindow); + assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']); +}); + +test('refresh global shortcuts main deps map passthrough handlers', () => { + const calls: string[] = []; + const deps = createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler({ + unregisterAllGlobalShortcuts: () => calls.push('unregister'), + registerGlobalShortcuts: () => calls.push('register'), + syncOverlayShortcuts: () => calls.push('sync'), + })(); + + deps.unregisterAllGlobalShortcuts(); + deps.registerGlobalShortcuts(); + deps.syncOverlayShortcuts(); + assert.deepEqual(calls, ['unregister', 'register', 'sync']); +}); diff --git a/src/main/runtime/global-shortcuts-main-deps.ts b/src/main/runtime/global-shortcuts-main-deps.ts new file mode 100644 index 0000000..779950f --- /dev/null +++ b/src/main/runtime/global-shortcuts-main-deps.ts @@ -0,0 +1,49 @@ +import type { Config } from '../../types'; +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut'; + +export function createBuildGetConfiguredShortcutsMainDepsHandler(deps: { + getResolvedConfig: () => Config; + defaultConfig: Config; + resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) => ConfiguredShortcuts; +}) { + return () => ({ + getResolvedConfig: () => deps.getResolvedConfig(), + defaultConfig: deps.defaultConfig, + resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) => + deps.resolveConfiguredShortcuts(config, defaultConfig), + }); +} + +export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: { + getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts']; + registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + openYomitanSettings: () => void; + isDev: boolean; + getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow']; +}) { + return () => ({ + getConfiguredShortcuts: () => deps.getConfiguredShortcuts(), + registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => + deps.registerGlobalShortcutsCore(options), + onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + onToggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(), + onOpenYomitanSettings: () => deps.openYomitanSettings(), + isDev: deps.isDev, + getMainWindow: deps.getMainWindow, + }); +} + +export function createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler(deps: { + unregisterAllGlobalShortcuts: () => void; + registerGlobalShortcuts: () => void; + syncOverlayShortcuts: () => void; +}) { + return () => ({ + unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), + registerGlobalShortcuts: () => deps.registerGlobalShortcuts(), + syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), + }); +} diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts new file mode 100644 index 0000000..2665177 --- /dev/null +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import { createGlobalShortcutsRuntimeHandlers } from './global-shortcuts-runtime-handlers'; + +function createShortcuts(): ConfiguredShortcuts { + return { + toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O', + toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I', + copySubtitle: 's', + copySubtitleMultiple: 'CommandOrControl+s', + updateLastCardFromClipboard: 'c', + triggerFieldGrouping: null, + triggerSubsync: null, + mineSentence: 'q', + mineSentenceMultiple: 'w', + multiCopyTimeoutMs: 5000, + toggleSecondarySub: null, + markAudioCard: null, + openRuntimeOptions: null, + openJimaku: null, + }; +} + +test('global shortcuts runtime handlers compose get/register/refresh flow', () => { + const calls: string[] = []; + const shortcuts = createShortcuts(); + const runtime = createGlobalShortcutsRuntimeHandlers({ + getConfiguredShortcutsMainDeps: { + getResolvedConfig: () => ({}) as never, + defaultConfig: {} as never, + resolveConfiguredShortcuts: () => shortcuts, + }, + buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcuts) => ({ + getConfiguredShortcuts, + registerGlobalShortcutsCore: (options) => { + calls.push('register'); + assert.equal(options.shortcuts, shortcuts); + }, + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + openYomitanSettings: () => calls.push('open-yomitan'), + isDev: false, + getMainWindow: () => null, + }), + buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcuts) => ({ + unregisterAllGlobalShortcuts: () => calls.push('unregister'), + registerGlobalShortcuts: () => registerGlobalShortcuts(), + syncOverlayShortcuts: () => calls.push('sync-overlay'), + }), + }); + + assert.equal(runtime.getConfiguredShortcuts(), shortcuts); + runtime.registerGlobalShortcuts(); + runtime.refreshGlobalAndOverlayShortcuts(); + assert.deepEqual(calls, ['register', 'unregister', 'register', 'sync-overlay']); +}); diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.ts b/src/main/runtime/global-shortcuts-runtime-handlers.ts new file mode 100644 index 0000000..00d6013 --- /dev/null +++ b/src/main/runtime/global-shortcuts-runtime-handlers.ts @@ -0,0 +1,60 @@ +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import { + createGetConfiguredShortcutsHandler, + createRefreshGlobalAndOverlayShortcutsHandler, + createRegisterGlobalShortcutsHandler, +} from './global-shortcuts'; +import { + createBuildGetConfiguredShortcutsMainDepsHandler, + createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler, + createBuildRegisterGlobalShortcutsMainDepsHandler, +} from './global-shortcuts-main-deps'; + +type GetConfiguredShortcutsMainDeps = Parameters< + typeof createBuildGetConfiguredShortcutsMainDepsHandler +>[0]; +type RegisterGlobalShortcutsMainDeps = Parameters< + typeof createBuildRegisterGlobalShortcutsMainDepsHandler +>[0]; +type RefreshGlobalAndOverlayShortcutsMainDeps = Parameters< + typeof createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler +>[0]; + +export function createGlobalShortcutsRuntimeHandlers(deps: { + getConfiguredShortcutsMainDeps: GetConfiguredShortcutsMainDeps; + buildRegisterGlobalShortcutsMainDeps: ( + getConfiguredShortcuts: () => ConfiguredShortcuts, + ) => RegisterGlobalShortcutsMainDeps; + buildRefreshGlobalAndOverlayShortcutsMainDeps: ( + registerGlobalShortcuts: () => void, + ) => RefreshGlobalAndOverlayShortcutsMainDeps; +}) { + const getConfiguredShortcutsMainDeps = createBuildGetConfiguredShortcutsMainDepsHandler( + deps.getConfiguredShortcutsMainDeps, + )(); + const getConfiguredShortcutsHandler = + createGetConfiguredShortcutsHandler(getConfiguredShortcutsMainDeps); + const getConfiguredShortcuts = () => getConfiguredShortcutsHandler(); + + const registerGlobalShortcutsMainDeps = createBuildRegisterGlobalShortcutsMainDepsHandler( + deps.buildRegisterGlobalShortcutsMainDeps(getConfiguredShortcuts), + )(); + const registerGlobalShortcutsHandler = + createRegisterGlobalShortcutsHandler(registerGlobalShortcutsMainDeps); + const registerGlobalShortcuts = () => registerGlobalShortcutsHandler(); + + const refreshGlobalAndOverlayShortcutsMainDeps = + createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler( + deps.buildRefreshGlobalAndOverlayShortcutsMainDeps(registerGlobalShortcuts), + )(); + const refreshGlobalAndOverlayShortcutsHandler = createRefreshGlobalAndOverlayShortcutsHandler( + refreshGlobalAndOverlayShortcutsMainDeps, + ); + const refreshGlobalAndOverlayShortcuts = () => refreshGlobalAndOverlayShortcutsHandler(); + + return { + getConfiguredShortcuts, + registerGlobalShortcuts, + refreshGlobalAndOverlayShortcuts, + }; +} diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts new file mode 100644 index 0000000..54cee83 --- /dev/null +++ b/src/main/runtime/global-shortcuts.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createGetConfiguredShortcutsHandler, + createRefreshGlobalAndOverlayShortcutsHandler, + createRegisterGlobalShortcutsHandler, +} from './global-shortcuts'; +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; + +function createShortcuts(): ConfiguredShortcuts { + return { + toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O', + toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I', + copySubtitle: 's', + copySubtitleMultiple: 'CommandOrControl+s', + updateLastCardFromClipboard: 'c', + triggerFieldGrouping: null, + triggerSubsync: null, + mineSentence: 'q', + mineSentenceMultiple: 'w', + multiCopyTimeoutMs: 5000, + toggleSecondarySub: null, + markAudioCard: null, + openRuntimeOptions: null, + openJimaku: null, + }; +} + +test('configured shortcuts handler resolves from current config', () => { + const calls: string[] = []; + const config = {} as never; + const defaultConfig = {} as never; + const shortcuts = createShortcuts(); + const getConfiguredShortcuts = createGetConfiguredShortcutsHandler({ + getResolvedConfig: () => config, + defaultConfig, + resolveConfiguredShortcuts: (nextConfig, nextDefaultConfig) => { + calls.push('resolve'); + assert.equal(nextConfig, config); + assert.equal(nextDefaultConfig, defaultConfig); + return shortcuts; + }, + }); + + assert.equal(getConfiguredShortcuts(), shortcuts); + assert.deepEqual(calls, ['resolve']); +}); + +test('register global shortcuts handler passes through callbacks and shortcuts', () => { + const calls: string[] = []; + const shortcuts = createShortcuts(); + const mainWindow = {} as never; + const registerGlobalShortcuts = createRegisterGlobalShortcutsHandler({ + getConfiguredShortcuts: () => shortcuts, + registerGlobalShortcutsCore: (options) => { + calls.push('register'); + assert.equal(options.shortcuts, shortcuts); + assert.equal(options.isDev, true); + assert.equal(options.getMainWindow(), mainWindow); + options.onToggleVisibleOverlay(); + options.onToggleInvisibleOverlay(); + options.onOpenYomitanSettings(); + }, + onToggleVisibleOverlay: () => calls.push('toggle-visible'), + onToggleInvisibleOverlay: () => calls.push('toggle-invisible'), + onOpenYomitanSettings: () => calls.push('open-yomitan'), + isDev: true, + getMainWindow: () => mainWindow, + }); + + registerGlobalShortcuts(); + assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']); +}); + +test('refresh global and overlay shortcuts unregisters then re-registers', () => { + const calls: string[] = []; + const refresh = createRefreshGlobalAndOverlayShortcutsHandler({ + unregisterAllGlobalShortcuts: () => calls.push('unregister'), + registerGlobalShortcuts: () => calls.push('register'), + syncOverlayShortcuts: () => calls.push('sync-overlay'), + }); + + refresh(); + assert.deepEqual(calls, ['unregister', 'register', 'sync-overlay']); +}); diff --git a/src/main/runtime/global-shortcuts.ts b/src/main/runtime/global-shortcuts.ts new file mode 100644 index 0000000..4268c76 --- /dev/null +++ b/src/main/runtime/global-shortcuts.ts @@ -0,0 +1,48 @@ +import type { Config } from '../../types'; +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut'; + +export function createGetConfiguredShortcutsHandler(deps: { + getResolvedConfig: () => Config; + defaultConfig: Config; + resolveConfiguredShortcuts: ( + config: Config, + defaultConfig: Config, + ) => ConfiguredShortcuts; +}) { + return (): ConfiguredShortcuts => + deps.resolveConfiguredShortcuts(deps.getResolvedConfig(), deps.defaultConfig); +} + +export function createRegisterGlobalShortcutsHandler(deps: { + getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts']; + registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void; + onToggleVisibleOverlay: () => void; + onToggleInvisibleOverlay: () => void; + onOpenYomitanSettings: () => void; + isDev: boolean; + getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow']; +}) { + return (): void => { + deps.registerGlobalShortcutsCore({ + shortcuts: deps.getConfiguredShortcuts(), + onToggleVisibleOverlay: deps.onToggleVisibleOverlay, + onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay, + onOpenYomitanSettings: deps.onOpenYomitanSettings, + isDev: deps.isDev, + getMainWindow: deps.getMainWindow, + }); + }; +} + +export function createRefreshGlobalAndOverlayShortcutsHandler(deps: { + unregisterAllGlobalShortcuts: () => void; + registerGlobalShortcuts: () => void; + syncOverlayShortcuts: () => void; +}) { + return (): void => { + deps.unregisterAllGlobalShortcuts(); + deps.registerGlobalShortcuts(); + deps.syncOverlayShortcuts(); + }; +} diff --git a/src/main/runtime/immersion-media.test.ts b/src/main/runtime/immersion-media.test.ts new file mode 100644 index 0000000..14f1fc5 --- /dev/null +++ b/src/main/runtime/immersion-media.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createImmersionMediaRuntime } from './immersion-media'; + +test('getConfiguredDbPath uses trimmed configured path with fallback', () => { + const runtime = createImmersionMediaRuntime({ + getResolvedConfig: () => ({ immersionTracking: { dbPath: ' /tmp/custom.db ' } }), + defaultImmersionDbPath: '/tmp/default.db', + getTracker: () => null, + getMpvClient: () => null, + getCurrentMediaPath: () => null, + getCurrentMediaTitle: () => null, + logDebug: () => {}, + logInfo: () => {}, + }); + assert.equal(runtime.getConfiguredDbPath(), '/tmp/custom.db'); + + const fallbackRuntime = createImmersionMediaRuntime({ + getResolvedConfig: () => ({ immersionTracking: { dbPath: ' ' } }), + defaultImmersionDbPath: '/tmp/default.db', + getTracker: () => null, + getMpvClient: () => null, + getCurrentMediaPath: () => null, + getCurrentMediaTitle: () => null, + logDebug: () => {}, + logInfo: () => {}, + }); + assert.equal(fallbackRuntime.getConfiguredDbPath(), '/tmp/default.db'); +}); + +test('syncFromCurrentMediaState uses current media path directly', () => { + const calls: Array<{ path: string; title: string | null }> = []; + const runtime = createImmersionMediaRuntime({ + getResolvedConfig: () => ({}), + defaultImmersionDbPath: '/tmp/default.db', + getTracker: () => ({ + handleMediaChange: (path, title) => calls.push({ path, title }), + }), + getMpvClient: () => ({ connected: true, currentVideoPath: '/tmp/video.mkv' }), + getCurrentMediaPath: () => ' /tmp/current.mkv ', + getCurrentMediaTitle: () => ' Current Title ', + logDebug: () => {}, + logInfo: () => {}, + }); + + runtime.syncFromCurrentMediaState(); + assert.deepEqual(calls, [{ path: '/tmp/current.mkv', title: 'Current Title' }]); +}); + +test('seedFromCurrentMedia resolves media path from mpv properties', async () => { + const calls: Array<{ path: string; title: string | null }> = []; + const runtime = createImmersionMediaRuntime({ + getResolvedConfig: () => ({}), + defaultImmersionDbPath: '/tmp/default.db', + getTracker: () => ({ + handleMediaChange: (path, title) => calls.push({ path, title }), + }), + getMpvClient: () => ({ + connected: true, + requestProperty: async (name: string) => { + if (name === 'path') return '/tmp/from-property.mkv'; + if (name === 'media-title') return 'Property Title'; + return null; + }, + }), + getCurrentMediaPath: () => null, + getCurrentMediaTitle: () => null, + sleep: async () => {}, + seedAttempts: 2, + logDebug: () => {}, + logInfo: () => {}, + }); + + await runtime.seedFromCurrentMedia(); + assert.deepEqual(calls, [{ path: '/tmp/from-property.mkv', title: 'Property Title' }]); +}); diff --git a/src/main/runtime/immersion-media.ts b/src/main/runtime/immersion-media.ts new file mode 100644 index 0000000..35ddd27 --- /dev/null +++ b/src/main/runtime/immersion-media.ts @@ -0,0 +1,174 @@ +type ResolvedConfigLike = { + immersionTracking?: { + dbPath?: string | null; + }; +}; + +type ImmersionTrackerLike = { + handleMediaChange: (path: string, title: string | null) => void; +}; + +type MpvClientLike = { + currentVideoPath?: string | null; + connected?: boolean; + requestProperty?: (name: string) => Promise; +}; + +type ImmersionMediaState = { + path: string | null; + title: string | null; +}; + +export type ImmersionMediaRuntimeDeps = { + getResolvedConfig: () => ResolvedConfigLike; + defaultImmersionDbPath: string; + getTracker: () => ImmersionTrackerLike | null; + getMpvClient: () => MpvClientLike | null; + getCurrentMediaPath: () => string | null | undefined; + getCurrentMediaTitle: () => string | null | undefined; + sleep?: (ms: number) => Promise; + seedWaitMs?: number; + seedAttempts?: number; + logDebug: (message: string) => void; + logInfo: (message: string) => void; +}; + +function trimToNull(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function readMpvPropertyAsString( + mpvClient: MpvClientLike | null | undefined, + propertyName: string, +): Promise { + const requestProperty = mpvClient?.requestProperty; + if (!requestProperty) { + return null; + } + try { + const value = await requestProperty(propertyName); + return typeof value === 'string' ? trimToNull(value) : null; + } catch { + return null; + } +} + +export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): { + getConfiguredDbPath: () => string; + seedFromCurrentMedia: () => Promise; + syncFromCurrentMediaState: () => void; +} { + const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const waitMs = deps.seedWaitMs ?? 250; + const attempts = deps.seedAttempts ?? 120; + let isSeedInProgress = false; + + const getConfiguredDbPath = (): string => { + const configuredDbPath = trimToNull(deps.getResolvedConfig().immersionTracking?.dbPath); + return configuredDbPath ?? deps.defaultImmersionDbPath; + }; + + const getCurrentMpvMediaStateForTracker = async (): Promise => { + const statePath = trimToNull(deps.getCurrentMediaPath()); + const stateTitle = trimToNull(deps.getCurrentMediaTitle()); + if (statePath) { + return { + path: statePath, + title: stateTitle, + }; + } + + const mpvClient = deps.getMpvClient(); + const trackedPath = trimToNull(mpvClient?.currentVideoPath); + if (trackedPath) { + return { + path: trackedPath, + title: stateTitle, + }; + } + + const [pathFromProperty, filenameFromProperty, titleFromProperty] = await Promise.all([ + readMpvPropertyAsString(mpvClient, 'path'), + readMpvPropertyAsString(mpvClient, 'filename'), + readMpvPropertyAsString(mpvClient, 'media-title'), + ]); + + return { + path: pathFromProperty || filenameFromProperty || null, + title: stateTitle || titleFromProperty || null, + }; + }; + + const seedFromCurrentMedia = async (): Promise => { + const tracker = deps.getTracker(); + if (!tracker) { + deps.logDebug('Immersion tracker seeding skipped: tracker not initialized.'); + return; + } + if (isSeedInProgress) { + deps.logDebug('Immersion tracker seeding already in progress; skipping duplicate call.'); + return; + } + deps.logDebug('Starting immersion tracker media-state seed loop.'); + isSeedInProgress = true; + + try { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const mediaState = await getCurrentMpvMediaStateForTracker(); + if (mediaState.path) { + deps.logInfo( + `Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`, + ); + tracker.handleMediaChange(mediaState.path, mediaState.title); + return; + } + + const mpvClient = deps.getMpvClient(); + if (!mpvClient || !mpvClient.connected) { + await sleep(waitMs); + continue; + } + if (attempt < attempts - 1) { + await sleep(waitMs); + } + } + + deps.logInfo( + 'Immersion tracker seed failed: media path still unavailable after startup warmup', + ); + } finally { + isSeedInProgress = false; + } + }; + + const syncFromCurrentMediaState = (): void => { + const tracker = deps.getTracker(); + if (!tracker) { + deps.logDebug('Immersion tracker sync skipped: tracker not initialized yet.'); + return; + } + + const pathFromState = + trimToNull(deps.getCurrentMediaPath()) || trimToNull(deps.getMpvClient()?.currentVideoPath); + if (pathFromState) { + deps.logDebug('Immersion tracker sync using path from current media state.'); + tracker.handleMediaChange(pathFromState, trimToNull(deps.getCurrentMediaTitle())); + return; + } + + if (!isSeedInProgress) { + deps.logDebug('Immersion tracker sync did not find media path; starting seed loop.'); + void seedFromCurrentMedia(); + } else { + deps.logDebug('Immersion tracker sync found seed loop already running.'); + } + }; + + return { + getConfiguredDbPath, + seedFromCurrentMedia, + syncFromCurrentMediaState, + }; +} diff --git a/src/main/runtime/immersion-startup-main-deps.test.ts b/src/main/runtime/immersion-startup-main-deps.test.ts new file mode 100644 index 0000000..45ffb05 --- /dev/null +++ b/src/main/runtime/immersion-startup-main-deps.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildImmersionTrackerStartupMainDepsHandler } from './immersion-startup-main-deps'; + +test('immersion tracker startup main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildImmersionTrackerStartupMainDepsHandler({ + getResolvedConfig: () => ({ immersionTracking: { enabled: true } } as never), + getConfiguredDbPath: () => '/tmp/immersion.db', + createTrackerService: () => { + calls.push('create'); + return { id: 'tracker' }; + }, + setTracker: () => calls.push('set'), + getMpvClient: () => ({ connected: true, connect: () => {} }), + seedTrackerFromCurrentMedia: () => calls.push('seed'), + logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + assert.deepEqual(deps.getResolvedConfig(), { immersionTracking: { enabled: true } }); + assert.equal(deps.getConfiguredDbPath(), '/tmp/immersion.db'); + assert.deepEqual(deps.createTrackerService({ dbPath: '/tmp/immersion.db', policy: {} as never }), { + id: 'tracker', + }); + deps.setTracker(null); + assert.equal(deps.getMpvClient()?.connected, true); + deps.seedTrackerFromCurrentMedia(); + deps.logInfo('i'); + deps.logDebug('d'); + deps.logWarn('w', null); + + assert.deepEqual(calls, ['create', 'set', 'seed', 'info:i', 'debug:d', 'warn:w']); +}); diff --git a/src/main/runtime/immersion-startup-main-deps.ts b/src/main/runtime/immersion-startup-main-deps.ts new file mode 100644 index 0000000..7df1844 --- /dev/null +++ b/src/main/runtime/immersion-startup-main-deps.ts @@ -0,0 +1,22 @@ +import type { + ImmersionTrackerStartupDeps, + createImmersionTrackerStartupHandler, +} from './immersion-startup'; + +type ImmersionTrackerStartupMainDeps = Parameters[0]; + +export function createBuildImmersionTrackerStartupMainDepsHandler( + deps: ImmersionTrackerStartupMainDeps, +) { + return (): ImmersionTrackerStartupDeps => ({ + getResolvedConfig: () => deps.getResolvedConfig(), + getConfiguredDbPath: () => deps.getConfiguredDbPath(), + createTrackerService: (params) => deps.createTrackerService(params), + setTracker: (tracker) => deps.setTracker(tracker), + getMpvClient: () => deps.getMpvClient(), + seedTrackerFromCurrentMedia: () => deps.seedTrackerFromCurrentMedia(), + logInfo: (message: string) => deps.logInfo(message), + logDebug: (message: string) => deps.logDebug(message), + logWarn: (message: string, details: unknown) => deps.logWarn(message, details), + }); +} diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts new file mode 100644 index 0000000..9e3b6ce --- /dev/null +++ b/src/main/runtime/immersion-startup.test.ts @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createImmersionTrackerStartupHandler } from './immersion-startup'; + +function makeConfig() { + return { + immersionTracking: { + enabled: true, + batchSize: 40, + flushIntervalMs: 1500, + queueCap: 500, + payloadCapBytes: 16000, + maintenanceIntervalMs: 3600000, + retention: { + eventsDays: 14, + telemetryDays: 30, + dailyRollupsDays: 180, + monthlyRollupsDays: 730, + vacuumIntervalDays: 7, + }, + }, + }; +} + +test('createImmersionTrackerStartupHandler skips when disabled', () => { + const calls: string[] = []; + let tracker: unknown = 'unchanged'; + const handler = createImmersionTrackerStartupHandler({ + getResolvedConfig: () => ({ + immersionTracking: { + ...makeConfig().immersionTracking, + enabled: false, + }, + }), + getConfiguredDbPath: () => '/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.ok(calls.includes('info:Immersion tracking disabled in config')); + assert.equal(calls.includes('createTrackerService'), false); + assert.equal(calls.includes('seedTracker'), false); + assert.equal(tracker, 'unchanged'); +}); + +test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => { + const calls: string[] = []; + const trackerInstance = { kind: 'tracker' }; + let assignedTracker: unknown = null; + let receivedDbPath = ''; + let receivedPolicy: unknown; + let connectCalls = 0; + const handler = createImmersionTrackerStartupHandler({ + getResolvedConfig: () => makeConfig(), + getConfiguredDbPath: () => '/tmp/subminer.db', + createTrackerService: (params) => { + receivedDbPath = params.dbPath; + receivedPolicy = params.policy; + return trackerInstance; + }, + setTracker: (nextTracker) => { + assignedTracker = nextTracker; + }, + getMpvClient: () => ({ + connected: false, + connect: () => { + connectCalls += 1; + }, + }), + 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(receivedDbPath, '/tmp/subminer.db'); + assert.deepEqual(receivedPolicy, { + batchSize: 40, + flushIntervalMs: 1500, + queueCap: 500, + payloadCapBytes: 16000, + maintenanceIntervalMs: 3600000, + retention: { + eventsDays: 14, + telemetryDays: 30, + dailyRollupsDays: 180, + monthlyRollupsDays: 730, + vacuumIntervalDays: 7, + }, + }); + assert.equal(assignedTracker, trackerInstance); + assert.equal(connectCalls, 1); + assert.ok(calls.includes('seedTracker')); + assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking')); +}); + +test('createImmersionTrackerStartupHandler disables tracker on failure', () => { + const calls: string[] = []; + let assignedTracker: unknown = 'initial'; + const handler = createImmersionTrackerStartupHandler({ + getResolvedConfig: () => makeConfig(), + getConfiguredDbPath: () => '/tmp/subminer.db', + createTrackerService: () => { + throw new Error('db unavailable'); + }, + setTracker: (nextTracker) => { + assignedTracker = nextTracker; + }, + getMpvClient: () => null, + seedTrackerFromCurrentMedia: () => calls.push('seedTracker'), + logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), + logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`), + }); + + handler(); + + assert.equal(assignedTracker, null); + assert.equal(calls.includes('seedTracker'), false); + assert.ok( + calls.includes('warn:Immersion tracker startup failed; disabling tracking.:db unavailable'), + ); +}); diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts new file mode 100644 index 0000000..cda2fc2 --- /dev/null +++ b/src/main/runtime/immersion-startup.ts @@ -0,0 +1,99 @@ +type ImmersionRetentionPolicy = { + eventsDays: number; + telemetryDays: number; + dailyRollupsDays: number; + monthlyRollupsDays: number; + vacuumIntervalDays: number; +}; + +type ImmersionTrackingPolicy = { + enabled?: boolean; + batchSize: number; + flushIntervalMs: number; + queueCap: number; + payloadCapBytes: number; + maintenanceIntervalMs: number; + retention: ImmersionRetentionPolicy; +}; + +type ImmersionTrackingConfig = { + immersionTracking?: ImmersionTrackingPolicy; +}; + +type ImmersionTrackerPolicy = Omit; + +type ImmersionTrackerServiceParams = { + dbPath: string; + policy: ImmersionTrackerPolicy; +}; + +type MpvClientLike = { + connected: boolean; + connect: () => void; +}; + +export type ImmersionTrackerStartupDeps = { + getResolvedConfig: () => ImmersionTrackingConfig; + getConfiguredDbPath: () => string; + createTrackerService: (params: ImmersionTrackerServiceParams) => unknown; + setTracker: (tracker: unknown | null) => void; + getMpvClient: () => MpvClientLike | null; + seedTrackerFromCurrentMedia: () => void; + logInfo: (message: string) => void; + logDebug: (message: string) => void; + logWarn: (message: string, details: unknown) => void; +}; + +export function createImmersionTrackerStartupHandler( + deps: ImmersionTrackerStartupDeps, +): () => void { + return () => { + const config = deps.getResolvedConfig(); + if (config.immersionTracking?.enabled === false) { + deps.logInfo('Immersion tracking disabled in config'); + return; + } + + try { + deps.logDebug('Immersion tracker startup requested: creating tracker service.'); + const dbPath = deps.getConfiguredDbPath(); + deps.logInfo(`Creating immersion tracker with dbPath=${dbPath}`); + + const policy = config.immersionTracking; + if (!policy) { + throw new Error('Immersion tracking policy missing'); + } + + deps.setTracker( + deps.createTrackerService({ + dbPath, + policy: { + batchSize: policy.batchSize, + flushIntervalMs: policy.flushIntervalMs, + queueCap: policy.queueCap, + payloadCapBytes: policy.payloadCapBytes, + maintenanceIntervalMs: policy.maintenanceIntervalMs, + retention: { + eventsDays: policy.retention.eventsDays, + telemetryDays: policy.retention.telemetryDays, + dailyRollupsDays: policy.retention.dailyRollupsDays, + monthlyRollupsDays: policy.retention.monthlyRollupsDays, + vacuumIntervalDays: policy.retention.vacuumIntervalDays, + }, + }, + }), + ); + deps.logDebug('Immersion tracker initialized successfully.'); + + const mpvClient = deps.getMpvClient(); + if (mpvClient && !mpvClient.connected) { + deps.logInfo('Auto-connecting MPV client for immersion tracking'); + mpvClient.connect(); + } + deps.seedTrackerFromCurrentMedia(); + } catch (error) { + deps.logWarn('Immersion tracker startup failed; disabling tracking.', error); + deps.setTracker(null); + } + }; +} diff --git a/src/main/runtime/initial-args-handler.test.ts b/src/main/runtime/initial-args-handler.test.ts new file mode 100644 index 0000000..d01d648 --- /dev/null +++ b/src/main/runtime/initial-args-handler.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleInitialArgsHandler } from './initial-args-handler'; + +test('initial args handler no-ops without initial args', () => { + let handled = false; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => null, + isBackgroundMode: () => false, + ensureTray: () => {}, + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => false, + getMpvClient: () => null, + logInfo: () => {}, + handleCliCommand: () => { + handled = true; + }, + }); + + handleInitialArgs(); + assert.equal(handled, false); +}); + +test('initial args handler ensures tray in background mode', () => { + let ensuredTray = false; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => ({ start: true } as never), + isBackgroundMode: () => true, + ensureTray: () => { + ensuredTray = true; + }, + isTexthookerOnlyMode: () => true, + hasImmersionTracker: () => false, + getMpvClient: () => null, + logInfo: () => {}, + handleCliCommand: () => {}, + }); + + handleInitialArgs(); + assert.equal(ensuredTray, true); +}); + +test('initial args handler auto-connects mpv when needed', () => { + let connectCalls = 0; + let logged = false; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => ({ start: true } as never), + isBackgroundMode: () => false, + ensureTray: () => {}, + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => true, + getMpvClient: () => ({ + connected: false, + connect: () => { + connectCalls += 1; + }, + }), + logInfo: () => { + logged = true; + }, + handleCliCommand: () => {}, + }); + + handleInitialArgs(); + assert.equal(connectCalls, 1); + assert.equal(logged, true); +}); + +test('initial args handler forwards args to cli handler', () => { + const seenSources: string[] = []; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => ({ start: true } as never), + isBackgroundMode: () => false, + ensureTray: () => {}, + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => false, + getMpvClient: () => null, + logInfo: () => {}, + handleCliCommand: (_args, source) => { + seenSources.push(source); + }, + }); + + handleInitialArgs(); + assert.deepEqual(seenSources, ['initial']); +}); diff --git a/src/main/runtime/initial-args-handler.ts b/src/main/runtime/initial-args-handler.ts new file mode 100644 index 0000000..2dcc02e --- /dev/null +++ b/src/main/runtime/initial-args-handler.ts @@ -0,0 +1,39 @@ +import type { CliArgs } from '../../cli/args'; + +type MpvClientLike = { + connected: boolean; + connect: () => void; +}; + +export function createHandleInitialArgsHandler(deps: { + getInitialArgs: () => CliArgs | null; + isBackgroundMode: () => boolean; + ensureTray: () => void; + isTexthookerOnlyMode: () => boolean; + hasImmersionTracker: () => boolean; + getMpvClient: () => MpvClientLike | null; + logInfo: (message: string) => void; + handleCliCommand: (args: CliArgs, source: 'initial') => void; +}) { + return (): void => { + const initialArgs = deps.getInitialArgs(); + if (!initialArgs) return; + + if (deps.isBackgroundMode()) { + deps.ensureTray(); + } + + const mpvClient = deps.getMpvClient(); + if ( + !deps.isTexthookerOnlyMode() && + deps.hasImmersionTracker() && + mpvClient && + !mpvClient.connected + ) { + deps.logInfo('Auto-connecting MPV client for immersion tracking'); + mpvClient.connect(); + } + + deps.handleCliCommand(initialArgs, 'initial'); + }; +} diff --git a/src/main/runtime/initial-args-main-deps.test.ts b/src/main/runtime/initial-args-main-deps.test.ts new file mode 100644 index 0000000..efd3fc3 --- /dev/null +++ b/src/main/runtime/initial-args-main-deps.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildHandleInitialArgsMainDepsHandler } from './initial-args-main-deps'; + +test('initial args main deps builder maps runtime callbacks and state readers', () => { + const calls: string[] = []; + const args = { start: true } as never; + const mpvClient = { connected: false, connect: () => calls.push('connect') }; + const deps = createBuildHandleInitialArgsMainDepsHandler({ + getInitialArgs: () => args, + isBackgroundMode: () => true, + ensureTray: () => calls.push('ensure-tray'), + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => true, + getMpvClient: () => mpvClient, + logInfo: (message) => calls.push(`info:${message}`), + handleCliCommand: (_args, source) => calls.push(`cli:${source}`), + })(); + + assert.equal(deps.getInitialArgs(), args); + assert.equal(deps.isBackgroundMode(), true); + assert.equal(deps.isTexthookerOnlyMode(), false); + assert.equal(deps.hasImmersionTracker(), true); + assert.equal(deps.getMpvClient(), mpvClient); + + deps.ensureTray(); + deps.logInfo('x'); + deps.handleCliCommand(args, 'initial'); + assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']); +}); diff --git a/src/main/runtime/initial-args-main-deps.ts b/src/main/runtime/initial-args-main-deps.ts new file mode 100644 index 0000000..f0b7a64 --- /dev/null +++ b/src/main/runtime/initial-args-main-deps.ts @@ -0,0 +1,23 @@ +import type { CliArgs } from '../../cli/args'; + +export function createBuildHandleInitialArgsMainDepsHandler(deps: { + getInitialArgs: () => CliArgs | null; + isBackgroundMode: () => boolean; + ensureTray: () => void; + isTexthookerOnlyMode: () => boolean; + hasImmersionTracker: () => boolean; + getMpvClient: () => { connected: boolean; connect: () => void } | null; + logInfo: (message: string) => void; + handleCliCommand: (args: CliArgs, source: 'initial') => void; +}) { + return () => ({ + getInitialArgs: () => deps.getInitialArgs(), + isBackgroundMode: () => deps.isBackgroundMode(), + ensureTray: () => deps.ensureTray(), + isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(), + hasImmersionTracker: () => deps.hasImmersionTracker(), + getMpvClient: () => deps.getMpvClient(), + logInfo: (message: string) => deps.logInfo(message), + handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source), + }); +} diff --git a/src/main/runtime/initial-args-runtime-handler.test.ts b/src/main/runtime/initial-args-runtime-handler.test.ts new file mode 100644 index 0000000..6b764c8 --- /dev/null +++ b/src/main/runtime/initial-args-runtime-handler.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createInitialArgsRuntimeHandler } from './initial-args-runtime-handler'; + +test('initial args runtime handler composes main deps and runs initial command flow', () => { + const calls: string[] = []; + const handleInitialArgs = createInitialArgsRuntimeHandler({ + getInitialArgs: () => ({ start: true } as never), + isBackgroundMode: () => true, + ensureTray: () => calls.push('tray'), + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => true, + getMpvClient: () => ({ + connected: false, + connect: () => calls.push('connect'), + }), + logInfo: (message) => calls.push(`log:${message}`), + handleCliCommand: (_args, source) => calls.push(`cli:${source}`), + }); + + handleInitialArgs(); + + assert.deepEqual(calls, ['tray', 'log:Auto-connecting MPV client for immersion tracking', 'connect', 'cli:initial']); +}); diff --git a/src/main/runtime/initial-args-runtime-handler.ts b/src/main/runtime/initial-args-runtime-handler.ts new file mode 100644 index 0000000..dc2da8a --- /dev/null +++ b/src/main/runtime/initial-args-runtime-handler.ts @@ -0,0 +1,10 @@ +import { createHandleInitialArgsHandler } from './initial-args-handler'; +import { createBuildHandleInitialArgsMainDepsHandler } from './initial-args-main-deps'; + +type InitialArgsMainDeps = Parameters[0]; + +export function createInitialArgsRuntimeHandler(deps: InitialArgsMainDeps) { + const buildHandleInitialArgsMainDepsHandler = createBuildHandleInitialArgsMainDepsHandler(deps); + const handleInitialArgsMainDeps = buildHandleInitialArgsMainDepsHandler(); + return createHandleInitialArgsHandler(handleInitialArgsMainDeps); +} diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts new file mode 100644 index 0000000..20615fe --- /dev/null +++ b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildHandleMpvCommandFromIpcMainDepsHandler, + createBuildRunSubsyncManualFromIpcMainDepsHandler, +} from './ipc-bridge-actions-main-deps'; + +test('ipc bridge action main deps builders map callbacks', async () => { + const calls: string[] = []; + + const handleMpv = createBuildHandleMpvCommandFromIpcMainDepsHandler({ + handleMpvCommandFromIpcRuntime: (command) => calls.push(`mpv:${command.join(':')}`), + buildMpvCommandDeps: () => ({ + triggerSubsyncFromConfig: async () => {}, + openRuntimeOptionsPalette: () => {}, + cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), + showMpvOsd: () => {}, + replayCurrentSubtitle: () => {}, + playNextSubtitle: () => {}, + sendMpvCommand: () => {}, + isMpvConnected: () => true, + hasRuntimeOptionsManager: () => true, + }), + })(); + handleMpv.handleMpvCommandFromIpcRuntime(['show-text', 'hello'], handleMpv.buildMpvCommandDeps()); + assert.equal(handleMpv.buildMpvCommandDeps().isMpvConnected(), true); + + const runSubsync = createBuildRunSubsyncManualFromIpcMainDepsHandler({ + runManualFromIpc: async (request: { id: string }) => { + calls.push(`subsync:${request.id}`); + return { ok: true as const }; + }, + })(); + assert.deepEqual(await runSubsync.runManualFromIpc({ id: 'job-1' }), { ok: true }); + + assert.deepEqual(calls, ['mpv:show-text:hello', 'subsync:job-1']); +}); diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.ts b/src/main/runtime/ipc-bridge-actions-main-deps.ts new file mode 100644 index 0000000..256455c --- /dev/null +++ b/src/main/runtime/ipc-bridge-actions-main-deps.ts @@ -0,0 +1,21 @@ +import type { createHandleMpvCommandFromIpcHandler } from './ipc-bridge-actions'; + +type HandleMpvCommandFromIpcMainDeps = Parameters[0]; + +export function createBuildHandleMpvCommandFromIpcMainDepsHandler( + deps: HandleMpvCommandFromIpcMainDeps, +) { + return (): HandleMpvCommandFromIpcMainDeps => ({ + handleMpvCommandFromIpcRuntime: (command, options) => + deps.handleMpvCommandFromIpcRuntime(command, options), + buildMpvCommandDeps: () => deps.buildMpvCommandDeps(), + }); +} + +export function createBuildRunSubsyncManualFromIpcMainDepsHandler(deps: { + runManualFromIpc: (request: TRequest) => Promise; +}) { + return () => ({ + runManualFromIpc: (request: TRequest) => deps.runManualFromIpc(request), + }); +} diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts new file mode 100644 index 0000000..4c2b82f --- /dev/null +++ b/src/main/runtime/ipc-bridge-actions.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createHandleMpvCommandFromIpcHandler, + createRunSubsyncManualFromIpcHandler, +} from './ipc-bridge-actions'; + +test('handle mpv command handler forwards command and built deps', () => { + const calls: string[] = []; + const deps = { + triggerSubsyncFromConfig: () => {}, + openRuntimeOptionsPalette: () => {}, + cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), + showMpvOsd: () => {}, + replayCurrentSubtitle: () => {}, + playNextSubtitle: () => {}, + sendMpvCommand: () => {}, + isMpvConnected: () => true, + hasRuntimeOptionsManager: () => true, + }; + const handle = createHandleMpvCommandFromIpcHandler({ + handleMpvCommandFromIpcRuntime: (command, nextDeps) => { + calls.push(`command:${command.join(':')}`); + assert.equal(nextDeps, deps); + }, + buildMpvCommandDeps: () => deps, + }); + + handle(['show-text', 'hello']); + assert.deepEqual(calls, ['command:show-text:hello']); +}); + +test('run subsync manual handler forwards request and result', async () => { + const calls: string[] = []; + const run = createRunSubsyncManualFromIpcHandler({ + runManualFromIpc: async (request: { id: string }) => { + calls.push(`request:${request.id}`); + return { ok: true as const }; + }, + }); + + const result = await run({ id: 'job-1' }); + assert.deepEqual(result, { ok: true }); + assert.deepEqual(calls, ['request:job-1']); +}); diff --git a/src/main/runtime/ipc-bridge-actions.ts b/src/main/runtime/ipc-bridge-actions.ts new file mode 100644 index 0000000..9fcb82c --- /dev/null +++ b/src/main/runtime/ipc-bridge-actions.ts @@ -0,0 +1,21 @@ +import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command'; + +export function createHandleMpvCommandFromIpcHandler(deps: { + handleMpvCommandFromIpcRuntime: ( + command: (string | number)[], + options: MpvCommandFromIpcRuntimeDeps, + ) => void; + buildMpvCommandDeps: () => MpvCommandFromIpcRuntimeDeps; +}) { + return (command: (string | number)[]): void => { + deps.handleMpvCommandFromIpcRuntime(command, deps.buildMpvCommandDeps()); + }; +} + +export function createRunSubsyncManualFromIpcHandler(deps: { + runManualFromIpc: (request: TRequest) => Promise; +}) { + return async (request: TRequest): Promise => { + return deps.runManualFromIpc(request); + }; +} diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts new file mode 100644 index 0000000..7a670d6 --- /dev/null +++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildMpvCommandFromIpcRuntimeMainDepsHandler } from './ipc-mpv-command-main-deps'; + +test('ipc mpv command main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({ + triggerSubsyncFromConfig: () => calls.push('subsync'), + openRuntimeOptionsPalette: () => calls.push('palette'), + cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), + showMpvOsd: (text) => calls.push(`osd:${text}`), + replayCurrentSubtitle: () => calls.push('replay'), + playNextSubtitle: () => calls.push('next'), + sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`), + isMpvConnected: () => true, + hasRuntimeOptionsManager: () => false, + })(); + + deps.triggerSubsyncFromConfig(); + deps.openRuntimeOptionsPalette(); + assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); + deps.showMpvOsd('hello'); + deps.replayCurrentSubtitle(); + deps.playNextSubtitle(); + deps.sendMpvCommand(['show-text', 'ok']); + assert.equal(deps.isMpvConnected(), true); + assert.equal(deps.hasRuntimeOptionsManager(), false); + assert.deepEqual(calls, [ + 'subsync', + 'palette', + 'osd:hello', + 'replay', + 'next', + 'cmd:show-text:ok', + ]); +}); diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts new file mode 100644 index 0000000..aed6a2b --- /dev/null +++ b/src/main/runtime/ipc-mpv-command-main-deps.ts @@ -0,0 +1,15 @@ +import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command'; + +export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(deps: MpvCommandFromIpcRuntimeDeps) { + return (): MpvCommandFromIpcRuntimeDeps => ({ + triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), + openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), + playNextSubtitle: () => deps.playNextSubtitle(), + sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), + isMpvConnected: () => deps.isMpvConnected(), + hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(), + }); +} diff --git a/src/main/runtime/ipc-runtime-handlers.test.ts b/src/main/runtime/ipc-runtime-handlers.test.ts new file mode 100644 index 0000000..c1e687a --- /dev/null +++ b/src/main/runtime/ipc-runtime-handlers.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createIpcRuntimeHandlers } from './ipc-runtime-handlers'; + +test('ipc runtime handlers wire command and subsync handlers through built deps', async () => { + let receivedCommand: (string | number)[] | null = null; + let receivedCommandDeps: { tag: string } | null = null; + let buildMpvCommandDepsCalls = 0; + let receivedSubsyncRequest: { id: string } | null = null; + + const runtime = createIpcRuntimeHandlers({ + handleMpvCommandFromIpcDeps: { + handleMpvCommandFromIpcRuntime: (command, deps) => { + receivedCommand = command; + receivedCommandDeps = deps as unknown as { tag: string }; + }, + buildMpvCommandDeps: () => { + buildMpvCommandDepsCalls += 1; + return { tag: 'mpv-deps' } as never; + }, + }, + runSubsyncManualFromIpcDeps: { + runManualFromIpc: async (request: { id: string }) => { + receivedSubsyncRequest = request; + return { ok: true, id: request.id }; + }, + }, + }); + + runtime.handleMpvCommandFromIpc(['set_property', 'pause', 'yes']); + assert.deepEqual(receivedCommand, ['set_property', 'pause', 'yes']); + assert.deepEqual(receivedCommandDeps, { tag: 'mpv-deps' }); + assert.equal(buildMpvCommandDepsCalls, 1); + + const response = await runtime.runSubsyncManualFromIpc({ id: 'abc' }); + assert.deepEqual(receivedSubsyncRequest, { id: 'abc' }); + assert.deepEqual(response, { ok: true, id: 'abc' }); +}); diff --git a/src/main/runtime/ipc-runtime-handlers.ts b/src/main/runtime/ipc-runtime-handlers.ts new file mode 100644 index 0000000..74c50a2 --- /dev/null +++ b/src/main/runtime/ipc-runtime-handlers.ts @@ -0,0 +1,37 @@ +import { createHandleMpvCommandFromIpcHandler, createRunSubsyncManualFromIpcHandler } from './ipc-bridge-actions'; +import { + createBuildHandleMpvCommandFromIpcMainDepsHandler, + createBuildRunSubsyncManualFromIpcMainDepsHandler, +} from './ipc-bridge-actions-main-deps'; + +type HandleMpvCommandFromIpcMainDeps = Parameters< + typeof createBuildHandleMpvCommandFromIpcMainDepsHandler +>[0]; +type RunSubsyncManualFromIpcMainDeps = Parameters< + typeof createBuildRunSubsyncManualFromIpcMainDepsHandler +>[0]; + +export function createIpcRuntimeHandlers(deps: { + handleMpvCommandFromIpcDeps: HandleMpvCommandFromIpcMainDeps; + runSubsyncManualFromIpcDeps: RunSubsyncManualFromIpcMainDeps; +}) { + const handleMpvCommandFromIpcMainDeps = createBuildHandleMpvCommandFromIpcMainDepsHandler( + deps.handleMpvCommandFromIpcDeps, + )(); + const handleMpvCommandFromIpc = createHandleMpvCommandFromIpcHandler( + handleMpvCommandFromIpcMainDeps, + ); + + const runSubsyncManualFromIpcMainDeps = + createBuildRunSubsyncManualFromIpcMainDepsHandler( + deps.runSubsyncManualFromIpcDeps, + )(); + const runSubsyncManualFromIpc = createRunSubsyncManualFromIpcHandler( + runSubsyncManualFromIpcMainDeps, + ); + + return { + handleMpvCommandFromIpc, + runSubsyncManualFromIpc, + }; +} diff --git a/src/main/runtime/jellyfin-cli-auth.test.ts b/src/main/runtime/jellyfin-cli-auth.test.ts new file mode 100644 index 0000000..6a59c7c --- /dev/null +++ b/src/main/runtime/jellyfin-cli-auth.test.ts @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinAuthCommands } from './jellyfin-cli-auth'; + +test('jellyfin auth handler processes logout', async () => { + const calls: string[] = []; + const handleAuth = createHandleJellyfinAuthCommands({ + patchRawConfig: () => calls.push('patch'), + saveStoredSession: () => calls.push('save'), + clearStoredSession: () => calls.push('clear'), + authenticateWithPassword: async () => { + throw new Error('should not authenticate'); + }, + logInfo: (message) => calls.push(message), + }); + + const handled = await handleAuth({ + args: { + jellyfinLogout: true, + jellyfinLogin: false, + jellyfinUsername: undefined, + jellyfinPassword: undefined, + } as never, + jellyfinConfig: { + serverUrl: '', + username: '', + }, + serverUrl: 'http://localhost', + clientInfo: { + deviceId: 'd1', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + }); + + assert.equal(handled, true); + assert.deepEqual(calls.slice(0, 2), ['clear', 'patch']); +}); + +test('jellyfin auth handler processes login', async () => { + const calls: string[] = []; + let patchPayload: unknown = null; + let storedSession: unknown = null; + const handleAuth = createHandleJellyfinAuthCommands({ + patchRawConfig: (patch) => { + patchPayload = patch; + calls.push('patch'); + }, + saveStoredSession: (session) => { + storedSession = session; + calls.push('save'); + }, + clearStoredSession: () => calls.push('clear'), + authenticateWithPassword: async () => ({ + serverUrl: 'http://localhost', + username: 'user', + accessToken: 'token', + userId: 'uid', + }), + logInfo: (message) => calls.push(message), + }); + + const handled = await handleAuth({ + args: { + jellyfinLogout: false, + jellyfinLogin: true, + jellyfinUsername: 'user', + jellyfinPassword: 'pw', + } as never, + jellyfinConfig: { + serverUrl: '', + username: '', + }, + serverUrl: 'http://localhost', + clientInfo: { + deviceId: 'd1', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + }); + + assert.equal(handled, true); + assert.ok(calls.includes('save')); + assert.ok(calls.includes('patch')); + assert.deepEqual(storedSession, { accessToken: 'token', userId: 'uid' }); + assert.deepEqual(patchPayload, { + jellyfin: { + enabled: true, + serverUrl: 'http://localhost', + username: 'user', + deviceId: 'd1', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + }); + assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); +}); + +test('jellyfin auth handler no-ops when no auth command', async () => { + const handleAuth = createHandleJellyfinAuthCommands({ + patchRawConfig: () => {}, + saveStoredSession: () => {}, + clearStoredSession: () => {}, + authenticateWithPassword: async () => ({ + serverUrl: '', + username: '', + accessToken: '', + userId: '', + }), + logInfo: () => {}, + }); + + const handled = await handleAuth({ + args: { + jellyfinLogout: false, + jellyfinLogin: false, + jellyfinUsername: undefined, + jellyfinPassword: undefined, + } as never, + jellyfinConfig: { + serverUrl: '', + username: '', + }, + serverUrl: 'http://localhost', + clientInfo: { + deviceId: 'd1', + clientName: 'SubMiner', + clientVersion: '1.0', + }, + }); + + assert.equal(handled, false); +}); diff --git a/src/main/runtime/jellyfin-cli-auth.ts b/src/main/runtime/jellyfin-cli-auth.ts new file mode 100644 index 0000000..5f5f8aa --- /dev/null +++ b/src/main/runtime/jellyfin-cli-auth.ts @@ -0,0 +1,86 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinConfig = { + serverUrl: string; + username: string; +}; + +type JellyfinClientInfo = { + deviceId: string; + clientName: string; + clientVersion: string; +}; + +type JellyfinSession = { + serverUrl: string; + username: string; + accessToken: string; + userId: string; +}; + +export function createHandleJellyfinAuthCommands(deps: { + patchRawConfig: (patch: { + jellyfin: Partial<{ + enabled: boolean; + serverUrl: string; + username: string; + deviceId: string; + clientName: string; + clientVersion: string; + }>; + }) => void; + authenticateWithPassword: ( + serverUrl: string, + username: string, + password: string, + clientInfo: JellyfinClientInfo, + ) => Promise; + saveStoredSession: (session: { accessToken: string; userId: string }) => void; + clearStoredSession: () => void; + logInfo: (message: string) => void; +}) { + return async (params: { + args: CliArgs; + jellyfinConfig: JellyfinConfig; + serverUrl: string; + clientInfo: JellyfinClientInfo; + }): Promise => { + if (params.args.jellyfinLogout) { + deps.clearStoredSession(); + deps.patchRawConfig({ + jellyfin: {}, + }); + deps.logInfo('Cleared stored Jellyfin auth session.'); + return true; + } + + if (!params.args.jellyfinLogin) { + return false; + } + + const username = (params.args.jellyfinUsername || params.jellyfinConfig.username).trim(); + const password = params.args.jellyfinPassword || ''; + const session = await deps.authenticateWithPassword( + params.serverUrl, + username, + password, + params.clientInfo, + ); + deps.saveStoredSession({ + accessToken: session.accessToken, + userId: session.userId, + }); + deps.patchRawConfig({ + jellyfin: { + enabled: true, + serverUrl: session.serverUrl, + username: session.username, + deviceId: params.clientInfo.deviceId, + clientName: params.clientInfo.clientName, + clientVersion: params.clientInfo.clientVersion, + }, + }); + deps.logInfo(`Jellyfin login succeeded for ${session.username}.`); + return true; + }; +} diff --git a/src/main/runtime/jellyfin-cli-list.test.ts b/src/main/runtime/jellyfin-cli-list.test.ts new file mode 100644 index 0000000..591bdf8 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-list.test.ts @@ -0,0 +1,176 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinListCommands } from './jellyfin-cli-list'; + +const baseSession = { + serverUrl: 'http://localhost', + accessToken: 'token', + userId: 'user-id', + username: 'user', +}; + +const baseClientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'device-id', +}; + +const baseConfig = { + defaultLibraryId: '', +}; + +test('list handler no-ops when no list command is set', async () => { + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: () => {}, + }); + + const handled = await handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, false); +}); + +test('list handler logs libraries', async () => { + const logs: string[] = []; + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: (message) => logs.push(message), + }); + + const handled = await handler({ + args: { + jellyfinLibraries: true, + jellyfinItems: false, + jellyfinSubtitles: false, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.ok(logs.some((line) => line.includes('Jellyfin library: Anime [lib1] (tvshows)'))); +}); + +test('list handler resolves items using default library id', async () => { + let usedLibraryId = ''; + const logs: string[] = []; + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async (_session, _clientInfo, params) => { + usedLibraryId = params.libraryId; + return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }]; + }, + listJellyfinSubtitleTracks: async () => [], + logInfo: (message) => logs.push(message), + }); + + const handled = await handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: true, + jellyfinSubtitles: false, + jellyfinLibraryId: '', + jellyfinSearch: 'episode', + jellyfinLimit: 10, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: { + defaultLibraryId: 'default-lib', + }, + }); + + assert.equal(handled, true); + assert.equal(usedLibraryId, 'default-lib'); + assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)'))); +}); + +test('list handler throws when items command has no library id', async () => { + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: () => {}, + }); + + await assert.rejects( + handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: true, + jellyfinSubtitles: false, + jellyfinLibraryId: '', + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }), + /Missing Jellyfin library id/, + ); +}); + +test('list handler logs subtitle urls only when requested', async () => { + const logs: string[] = []; + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [ + { index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' }, + { index: 2, language: 'jpn' }, + ], + logInfo: (message) => logs.push(message), + }); + + const handled = await handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: true, + jellyfinItemId: 'item1', + jellyfinSubtitleUrlsOnly: true, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.deepEqual(logs, ['http://localhost/sub1.srt']); +}); + +test('list handler throws when subtitle command has no item id', async () => { + const handler = createHandleJellyfinListCommands({ + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + logInfo: () => {}, + }); + + await assert.rejects( + handler({ + args: { + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: true, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }), + /Missing --jellyfin-item-id/, + ); +}); diff --git a/src/main/runtime/jellyfin-cli-list.ts b/src/main/runtime/jellyfin-cli-list.ts new file mode 100644 index 0000000..949cd48 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-list.ts @@ -0,0 +1,116 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type JellyfinConfig = { + defaultLibraryId: string; +}; + +export function createHandleJellyfinListCommands(deps: { + listJellyfinLibraries: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + ) => Promise>; + listJellyfinItems: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + params: { libraryId: string; searchTerm?: string; limit: number }, + ) => Promise>; + listJellyfinSubtitleTracks: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + itemId: string, + ) => Promise< + Array<{ + index: number; + language?: string; + title?: string; + deliveryMethod?: string; + codec?: string; + isDefault?: boolean; + isForced?: boolean; + isExternal?: boolean; + deliveryUrl?: string | null; + }> + >; + logInfo: (message: string) => void; +}) { + return async (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: JellyfinConfig; + }): Promise => { + const { args, session, clientInfo, jellyfinConfig } = params; + + if (args.jellyfinLibraries) { + const libraries = await deps.listJellyfinLibraries(session, clientInfo); + if (libraries.length === 0) { + deps.logInfo('No Jellyfin libraries found.'); + return true; + } + for (const library of libraries) { + deps.logInfo( + `Jellyfin library: ${library.name} [${library.id}] (${library.collectionType || library.type || 'unknown'})`, + ); + } + return true; + } + + if (args.jellyfinItems) { + const libraryId = args.jellyfinLibraryId || jellyfinConfig.defaultLibraryId; + if (!libraryId) { + throw new Error( + 'Missing Jellyfin library id. Use --jellyfin-library-id or set jellyfin.defaultLibraryId.', + ); + } + const items = await deps.listJellyfinItems(session, clientInfo, { + libraryId, + searchTerm: args.jellyfinSearch, + limit: args.jellyfinLimit ?? 100, + }); + if (items.length === 0) { + deps.logInfo('No Jellyfin items found for the selected library/search.'); + return true; + } + for (const item of items) { + deps.logInfo(`Jellyfin item: ${item.title} [${item.id}] (${item.type})`); + } + return true; + } + + if (args.jellyfinSubtitles) { + if (!args.jellyfinItemId) { + throw new Error('Missing --jellyfin-item-id for --jellyfin-subtitles.'); + } + const tracks = await deps.listJellyfinSubtitleTracks(session, clientInfo, args.jellyfinItemId); + if (tracks.length === 0) { + deps.logInfo('No Jellyfin subtitle tracks found for item.'); + return true; + } + for (const track of tracks) { + if (args.jellyfinSubtitleUrlsOnly) { + if (track.deliveryUrl) deps.logInfo(track.deliveryUrl); + continue; + } + deps.logInfo( + `Jellyfin subtitle: index=${track.index} lang=${track.language || 'unknown'} title="${track.title || '-'}" method=${track.deliveryMethod || 'unknown'} codec=${track.codec || 'unknown'} default=${track.isDefault ? 'yes' : 'no'} forced=${track.isForced ? 'yes' : 'no'} external=${track.isExternal ? 'yes' : 'no'} url=${track.deliveryUrl || '-'}`, + ); + } + return true; + } + + return false; + }; +} diff --git a/src/main/runtime/jellyfin-cli-main-deps.test.ts b/src/main/runtime/jellyfin-cli-main-deps.test.ts new file mode 100644 index 0000000..66f8b08 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-main-deps.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildHandleJellyfinAuthCommandsMainDepsHandler, + createBuildHandleJellyfinListCommandsMainDepsHandler, + createBuildHandleJellyfinPlayCommandMainDepsHandler, + createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler, +} from './jellyfin-cli-main-deps'; + +test('jellyfin auth commands main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildHandleJellyfinAuthCommandsMainDepsHandler({ + patchRawConfig: () => calls.push('patch'), + authenticateWithPassword: async () => ({}) as never, + saveStoredSession: () => calls.push('save'), + clearStoredSession: () => calls.push('clear'), + logInfo: (message) => calls.push(`info:${message}`), + })(); + + deps.patchRawConfig({ jellyfin: {} }); + await deps.authenticateWithPassword('', '', '', { + deviceId: '', + clientName: '', + clientVersion: '', + }); + deps.saveStoredSession({ accessToken: 'token', userId: 'uid' }); + deps.clearStoredSession(); + deps.logInfo('ok'); + assert.deepEqual(calls, ['patch', 'save', 'clear', 'info:ok']); +}); + +test('jellyfin list commands main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({ + listJellyfinLibraries: async () => { + calls.push('libraries'); + return []; + }, + listJellyfinItems: async () => { + calls.push('items'); + return []; + }, + listJellyfinSubtitleTracks: async () => { + calls.push('subtitles'); + return []; + }, + logInfo: (message) => calls.push(`info:${message}`), + })(); + + await deps.listJellyfinLibraries({} as never, {} as never); + await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 }); + await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id'); + deps.logInfo('done'); + assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']); +}); + +test('jellyfin play command main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildHandleJellyfinPlayCommandMainDepsHandler({ + playJellyfinItemInMpv: async () => { + calls.push('play'); + }, + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + await deps.playJellyfinItemInMpv({} as never); + deps.logWarn('missing'); + assert.deepEqual(calls, ['play', 'warn:missing']); +}); + +test('jellyfin remote announce main deps builder maps callbacks', async () => { + const calls: string[] = []; + const session = { advertiseNow: async () => true }; + const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({ + startJellyfinRemoteSession: async () => { + calls.push('start'); + }, + getRemoteSession: () => session, + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + await deps.startJellyfinRemoteSession(); + assert.equal(deps.getRemoteSession(), session); + deps.logInfo('visible'); + deps.logWarn('not-visible'); + assert.deepEqual(calls, ['start', 'info:visible', 'warn:not-visible']); +}); diff --git a/src/main/runtime/jellyfin-cli-main-deps.ts b/src/main/runtime/jellyfin-cli-main-deps.ts new file mode 100644 index 0000000..41042a2 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-main-deps.ts @@ -0,0 +1,65 @@ +import type { + createHandleJellyfinAuthCommands, +} from './jellyfin-cli-auth'; +import type { + createHandleJellyfinListCommands, +} from './jellyfin-cli-list'; +import type { + createHandleJellyfinPlayCommand, +} from './jellyfin-cli-play'; +import type { + createHandleJellyfinRemoteAnnounceCommand, +} from './jellyfin-cli-remote-announce'; + +type HandleJellyfinAuthCommandsMainDeps = Parameters[0]; +type HandleJellyfinListCommandsMainDeps = Parameters[0]; +type HandleJellyfinPlayCommandMainDeps = Parameters[0]; +type HandleJellyfinRemoteAnnounceCommandMainDeps = Parameters< + typeof createHandleJellyfinRemoteAnnounceCommand +>[0]; + +export function createBuildHandleJellyfinAuthCommandsMainDepsHandler( + deps: HandleJellyfinAuthCommandsMainDeps, +) { + return (): HandleJellyfinAuthCommandsMainDeps => ({ + patchRawConfig: (patch) => deps.patchRawConfig(patch), + authenticateWithPassword: (serverUrl, username, password, clientInfo) => + deps.authenticateWithPassword(serverUrl, username, password, clientInfo), + saveStoredSession: (session) => deps.saveStoredSession(session), + clearStoredSession: () => deps.clearStoredSession(), + logInfo: (message: string) => deps.logInfo(message), + }); +} + +export function createBuildHandleJellyfinListCommandsMainDepsHandler( + deps: HandleJellyfinListCommandsMainDeps, +) { + return (): HandleJellyfinListCommandsMainDeps => ({ + listJellyfinLibraries: (session, clientInfo) => deps.listJellyfinLibraries(session, clientInfo), + listJellyfinItems: (session, clientInfo, params) => + deps.listJellyfinItems(session, clientInfo, params), + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => + deps.listJellyfinSubtitleTracks(session, clientInfo, itemId), + logInfo: (message: string) => deps.logInfo(message), + }); +} + +export function createBuildHandleJellyfinPlayCommandMainDepsHandler( + deps: HandleJellyfinPlayCommandMainDeps, +) { + return (): HandleJellyfinPlayCommandMainDeps => ({ + playJellyfinItemInMpv: (params) => deps.playJellyfinItemInMpv(params), + logWarn: (message: string) => deps.logWarn(message), + }); +} + +export function createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler( + deps: HandleJellyfinRemoteAnnounceCommandMainDeps, +) { + return (): HandleJellyfinRemoteAnnounceCommandMainDeps => ({ + startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(), + getRemoteSession: () => deps.getRemoteSession(), + logInfo: (message: string) => deps.logInfo(message), + logWarn: (message: string) => deps.logWarn(message), + }); +} diff --git a/src/main/runtime/jellyfin-cli-play.test.ts b/src/main/runtime/jellyfin-cli-play.test.ts new file mode 100644 index 0000000..82494f5 --- /dev/null +++ b/src/main/runtime/jellyfin-cli-play.test.ts @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinPlayCommand } from './jellyfin-cli-play'; + +const baseSession = { + serverUrl: 'http://localhost', + accessToken: 'token', + userId: 'user-id', + username: 'user', +}; + +const baseClientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'device-id', +}; + +const baseConfig = { + defaultLibraryId: '', +}; + +test('play handler no-ops when play flag is disabled', async () => { + let called = false; + const handlePlay = createHandleJellyfinPlayCommand({ + playJellyfinItemInMpv: async () => { + called = true; + }, + logWarn: () => {}, + }); + + const handled = await handlePlay({ + args: { + jellyfinPlay: false, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, false); + assert.equal(called, false); +}); + +test('play handler warns when item id is missing', async () => { + const warnings: string[] = []; + const handlePlay = createHandleJellyfinPlayCommand({ + playJellyfinItemInMpv: async () => { + throw new Error('should not play'); + }, + logWarn: (message) => warnings.push(message), + }); + + const handled = await handlePlay({ + args: { + jellyfinPlay: true, + jellyfinItemId: '', + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.deepEqual(warnings, ['Ignoring --jellyfin-play without --jellyfin-item-id.']); +}); + +test('play handler runs playback with stream overrides', async () => { + let called = false; + const received: { + itemId: string; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + setQuitOnDisconnectArm?: boolean; + } = { + itemId: '', + }; + const handlePlay = createHandleJellyfinPlayCommand({ + playJellyfinItemInMpv: async (params) => { + called = true; + received.itemId = params.itemId; + received.audioStreamIndex = params.audioStreamIndex; + received.subtitleStreamIndex = params.subtitleStreamIndex; + received.setQuitOnDisconnectArm = params.setQuitOnDisconnectArm; + }, + logWarn: () => {}, + }); + + const handled = await handlePlay({ + args: { + jellyfinPlay: true, + jellyfinItemId: 'item-1', + jellyfinAudioStreamIndex: 2, + jellyfinSubtitleStreamIndex: 3, + } as never, + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: baseConfig, + }); + + assert.equal(handled, true); + assert.equal(called, true); + assert.equal(received.itemId, 'item-1'); + assert.equal(received.audioStreamIndex, 2); + assert.equal(received.subtitleStreamIndex, 3); + assert.equal(received.setQuitOnDisconnectArm, true); +}); diff --git a/src/main/runtime/jellyfin-cli-play.ts b/src/main/runtime/jellyfin-cli-play.ts new file mode 100644 index 0000000..277682c --- /dev/null +++ b/src/main/runtime/jellyfin-cli-play.ts @@ -0,0 +1,53 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +export function createHandleJellyfinPlayCommand(deps: { + playJellyfinItemInMpv: (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: unknown; + itemId: string; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + setQuitOnDisconnectArm?: boolean; + }) => Promise; + logWarn: (message: string) => void; +}) { + return async (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: unknown; + }): Promise => { + const { args, session, clientInfo, jellyfinConfig } = params; + if (!args.jellyfinPlay) { + return false; + } + if (!args.jellyfinItemId) { + deps.logWarn('Ignoring --jellyfin-play without --jellyfin-item-id.'); + return true; + } + await deps.playJellyfinItemInMpv({ + session, + clientInfo, + jellyfinConfig, + itemId: args.jellyfinItemId, + audioStreamIndex: args.jellyfinAudioStreamIndex, + subtitleStreamIndex: args.jellyfinSubtitleStreamIndex, + setQuitOnDisconnectArm: true, + }); + return true; + }; +} diff --git a/src/main/runtime/jellyfin-cli-remote-announce.test.ts b/src/main/runtime/jellyfin-cli-remote-announce.test.ts new file mode 100644 index 0000000..d9bebef --- /dev/null +++ b/src/main/runtime/jellyfin-cli-remote-announce.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandleJellyfinRemoteAnnounceCommand } from './jellyfin-cli-remote-announce'; + +test('remote announce handler no-ops when flag is disabled', async () => { + let started = false; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => { + started = true; + }, + getRemoteSession: () => null, + logInfo: () => {}, + logWarn: () => {}, + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: false, + } as never); + + assert.equal(handled, false); + assert.equal(started, false); +}); + +test('remote announce handler warns when session is unavailable', async () => { + const warnings: string[] = []; + let started = false; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => { + started = true; + }, + getRemoteSession: () => null, + logInfo: () => {}, + logWarn: (message) => warnings.push(message), + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: true, + } as never); + + assert.equal(handled, true); + assert.equal(started, true); + assert.deepEqual(warnings, ['Jellyfin remote session is not available.']); +}); + +test('remote announce handler reports visibility result', async () => { + const infos: string[] = []; + const warnings: string[] = []; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => {}, + getRemoteSession: () => ({ + advertiseNow: async () => true, + }), + logInfo: (message) => infos.push(message), + logWarn: (message) => warnings.push(message), + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: true, + } as never); + + assert.equal(handled, true); + assert.deepEqual(infos, ['Jellyfin cast target is visible in server sessions.']); + assert.equal(warnings.length, 0); +}); + +test('remote announce handler warns when visibility is not confirmed', async () => { + const warnings: string[] = []; + const handleRemoteAnnounce = createHandleJellyfinRemoteAnnounceCommand({ + startJellyfinRemoteSession: async () => {}, + getRemoteSession: () => ({ + advertiseNow: async () => false, + }), + logInfo: () => {}, + logWarn: (message) => warnings.push(message), + }); + + const handled = await handleRemoteAnnounce({ + jellyfinRemoteAnnounce: true, + } as never); + + assert.equal(handled, true); + assert.deepEqual(warnings, [ + 'Jellyfin remote announce sent, but cast target is not visible in server sessions yet.', + ]); +}); diff --git a/src/main/runtime/jellyfin-cli-remote-announce.ts b/src/main/runtime/jellyfin-cli-remote-announce.ts new file mode 100644 index 0000000..5417a8e --- /dev/null +++ b/src/main/runtime/jellyfin-cli-remote-announce.ts @@ -0,0 +1,35 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinRemoteSession = { + advertiseNow: () => Promise; +}; + +export function createHandleJellyfinRemoteAnnounceCommand(deps: { + startJellyfinRemoteSession: () => Promise; + getRemoteSession: () => JellyfinRemoteSession | null; + logInfo: (message: string) => void; + logWarn: (message: string) => void; +}) { + return async (args: CliArgs): Promise => { + if (!args.jellyfinRemoteAnnounce) { + return false; + } + + await deps.startJellyfinRemoteSession(); + const remoteSession = deps.getRemoteSession(); + if (!remoteSession) { + deps.logWarn('Jellyfin remote session is not available.'); + return true; + } + + const visible = await remoteSession.advertiseNow(); + if (visible) { + deps.logInfo('Jellyfin cast target is visible in server sessions.'); + } else { + deps.logWarn( + 'Jellyfin remote announce sent, but cast target is not visible in server sessions yet.', + ); + } + return true; + }; +} diff --git a/src/main/runtime/jellyfin-client-info-main-deps.test.ts b/src/main/runtime/jellyfin-client-info-main-deps.test.ts new file mode 100644 index 0000000..8fa9886 --- /dev/null +++ b/src/main/runtime/jellyfin-client-info-main-deps.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildGetJellyfinClientInfoMainDepsHandler, + createBuildGetResolvedJellyfinConfigMainDepsHandler, +} from './jellyfin-client-info-main-deps'; + +test('get resolved jellyfin config main deps builder maps callbacks', () => { + const resolved = { jellyfin: { url: 'https://example.com' } }; + const deps = createBuildGetResolvedJellyfinConfigMainDepsHandler({ + getResolvedConfig: () => resolved as never, + loadStoredSession: () => ({ accessToken: 'stored-token', userId: 'uid' }), + getEnv: (key: string) => (key === 'TEST' ? 'x' : undefined), + })(); + assert.equal(deps.getResolvedConfig(), resolved); + assert.deepEqual(deps.loadStoredSession(), { accessToken: 'stored-token', userId: 'uid' }); + assert.equal(deps.getEnv('TEST'), 'x'); +}); + +test('get jellyfin client info main deps builder maps callbacks', () => { + const configured = { clientName: 'Configured' }; + const defaults = { clientName: 'Default' }; + const deps = createBuildGetJellyfinClientInfoMainDepsHandler({ + getResolvedJellyfinConfig: () => configured as never, + getDefaultJellyfinConfig: () => defaults as never, + })(); + + assert.equal(deps.getResolvedJellyfinConfig(), configured); + assert.equal(deps.getDefaultJellyfinConfig(), defaults); +}); diff --git a/src/main/runtime/jellyfin-client-info-main-deps.ts b/src/main/runtime/jellyfin-client-info-main-deps.ts new file mode 100644 index 0000000..2bc1d1e --- /dev/null +++ b/src/main/runtime/jellyfin-client-info-main-deps.ts @@ -0,0 +1,26 @@ +import type { + createGetJellyfinClientInfoHandler, + createGetResolvedJellyfinConfigHandler, +} from './jellyfin-client-info'; + +type GetResolvedJellyfinConfigMainDeps = Parameters[0]; +type GetJellyfinClientInfoMainDeps = Parameters[0]; + +export function createBuildGetResolvedJellyfinConfigMainDepsHandler( + deps: GetResolvedJellyfinConfigMainDeps, +) { + return (): GetResolvedJellyfinConfigMainDeps => ({ + getResolvedConfig: () => deps.getResolvedConfig(), + loadStoredSession: () => deps.loadStoredSession(), + getEnv: (name: string) => deps.getEnv(name), + }); +} + +export function createBuildGetJellyfinClientInfoMainDepsHandler( + deps: GetJellyfinClientInfoMainDeps, +) { + return (): GetJellyfinClientInfoMainDeps => ({ + getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), + getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(), + }); +} diff --git a/src/main/runtime/jellyfin-client-info.test.ts b/src/main/runtime/jellyfin-client-info.test.ts new file mode 100644 index 0000000..135aa93 --- /dev/null +++ b/src/main/runtime/jellyfin-client-info.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createGetJellyfinClientInfoHandler, + createGetResolvedJellyfinConfigHandler, +} from './jellyfin-client-info'; + +test('get resolved jellyfin config returns jellyfin section from resolved config', () => { + const jellyfin = { url: 'https://jellyfin.local' } as never; + const getConfig = createGetResolvedJellyfinConfigHandler({ + getResolvedConfig: () => ({ jellyfin } as never), + loadStoredSession: () => null, + getEnv: () => undefined, + }); + + assert.equal(getConfig(), jellyfin); +}); + +test('get resolved jellyfin config falls back to stored session when env is unset', () => { + const getConfig = createGetResolvedJellyfinConfigHandler({ + getResolvedConfig: () => + ({ + jellyfin: { + serverUrl: 'http://localhost:8096', + }, + }) as never, + loadStoredSession: () => ({ accessToken: 'stored-token', userId: 'uid-1' }), + getEnv: () => undefined, + }); + + assert.deepEqual(getConfig(), { + serverUrl: 'http://localhost:8096', + accessToken: 'stored-token', + userId: 'uid-1', + }); +}); + +test('get resolved jellyfin config prefers env token and env user id over stored session', () => { + const getConfig = createGetResolvedJellyfinConfigHandler({ + getResolvedConfig: () => + ({ + jellyfin: { + serverUrl: 'http://localhost:8096', + }, + }) as never, + loadStoredSession: () => ({ accessToken: 'stored-token', userId: 'stored-user' }), + getEnv: (key: string) => + key === 'SUBMINER_JELLYFIN_ACCESS_TOKEN' + ? 'env-token' + : key === 'SUBMINER_JELLYFIN_USER_ID' + ? 'env-user' + : undefined, + }); + + assert.deepEqual(getConfig(), { + serverUrl: 'http://localhost:8096', + accessToken: 'env-token', + userId: 'env-user', + }); +}); + +test('get resolved jellyfin config uses stored user id when env token set without env user id', () => { + const getConfig = createGetResolvedJellyfinConfigHandler({ + getResolvedConfig: () => + ({ + jellyfin: { + serverUrl: 'http://localhost:8096', + }, + }) as never, + loadStoredSession: () => ({ accessToken: 'stored-token', userId: 'stored-user' }), + getEnv: (key: string) => + key === 'SUBMINER_JELLYFIN_ACCESS_TOKEN' ? 'env-token' : undefined, + }); + + assert.deepEqual(getConfig(), { + serverUrl: 'http://localhost:8096', + accessToken: 'env-token', + userId: 'stored-user', + }); +}); + +test('jellyfin client info resolves defaults when fields are missing', () => { + const getClientInfo = createGetJellyfinClientInfoHandler({ + getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' } as never), + getDefaultJellyfinConfig: () => + ({ + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'default-device', + }) as never, + }); + + assert.deepEqual(getClientInfo(), { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'default-device', + }); +}); + +test('jellyfin client info keeps explicit config values', () => { + const getClientInfo = createGetJellyfinClientInfoHandler({ + getResolvedJellyfinConfig: () => + ({ + clientName: 'Custom', + clientVersion: '2.3.4', + deviceId: 'custom-device', + }) as never, + getDefaultJellyfinConfig: () => + ({ + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'default-device', + }) as never, + }); + + assert.deepEqual(getClientInfo(), { + clientName: 'Custom', + clientVersion: '2.3.4', + deviceId: 'custom-device', + }); +}); diff --git a/src/main/runtime/jellyfin-client-info.ts b/src/main/runtime/jellyfin-client-info.ts new file mode 100644 index 0000000..24ce367 --- /dev/null +++ b/src/main/runtime/jellyfin-client-info.ts @@ -0,0 +1,66 @@ +import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store'; +import type { ResolvedConfig } from '../../types'; + +type ResolvedJellyfinConfig = ResolvedConfig['jellyfin']; +type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & { + accessToken?: string; + userId?: string; +}; + +export function createGetResolvedJellyfinConfigHandler(deps: { + getResolvedConfig: () => { jellyfin: ResolvedJellyfinConfig }; + loadStoredSession: () => JellyfinStoredSession | null | undefined; + getEnv: (name: string) => string | undefined; +}) { + return (): ResolvedJellyfinConfigWithSession => { + const jellyfin = deps.getResolvedConfig().jellyfin; + + const envToken = deps.getEnv('SUBMINER_JELLYFIN_ACCESS_TOKEN')?.trim() ?? ''; + const envUserId = deps.getEnv('SUBMINER_JELLYFIN_USER_ID')?.trim() ?? ''; + const stored = deps.loadStoredSession(); + const storedToken = stored?.accessToken?.trim() ?? ''; + const storedUserId = stored?.userId?.trim() ?? ''; + + if (envToken.length > 0) { + return { + ...jellyfin, + accessToken: envToken, + userId: envUserId || storedUserId || '', + }; + } + + if (storedToken.length > 0 && storedUserId.length > 0) { + return { + ...jellyfin, + accessToken: storedToken, + userId: storedUserId, + }; + } + + return jellyfin; + }; +} + +export function createGetJellyfinClientInfoHandler(deps: { + getResolvedJellyfinConfig: () => Partial< + Pick + >; + getDefaultJellyfinConfig: () => Partial< + Pick + >; +}) { + return ( + config = deps.getResolvedJellyfinConfig(), + ): { + clientName: string; + clientVersion: string; + deviceId: string; + } => { + const defaults = deps.getDefaultJellyfinConfig(); + return { + clientName: config.clientName || defaults.clientName || '', + clientVersion: config.clientVersion || defaults.clientVersion || '', + deviceId: config.deviceId || defaults.deviceId || '', + }; + }; +} diff --git a/src/main/runtime/jellyfin-command-dispatch-main-deps.test.ts b/src/main/runtime/jellyfin-command-dispatch-main-deps.test.ts new file mode 100644 index 0000000..0a51697 --- /dev/null +++ b/src/main/runtime/jellyfin-command-dispatch-main-deps.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { CliArgs } from '../../cli/args'; +import { createBuildRunJellyfinCommandMainDepsHandler } from './jellyfin-command-dispatch-main-deps'; + +test('run jellyfin command main deps builder maps callbacks', async () => { + const calls: string[] = []; + const args = { raw: [] } as unknown as CliArgs; + const config = { + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', + }; + const clientInfo = { clientName: 'SubMiner' }; + + const deps = createBuildRunJellyfinCommandMainDepsHandler({ + getJellyfinConfig: () => config, + defaultServerUrl: 'http://127.0.0.1:8096', + getJellyfinClientInfo: () => clientInfo, + handleAuthCommands: async () => { + calls.push('auth'); + return false; + }, + handleRemoteAnnounceCommand: async () => { + calls.push('remote'); + return false; + }, + handleListCommands: async () => { + calls.push('list'); + return false; + }, + handlePlayCommand: async () => { + calls.push('play'); + return true; + }, + })(); + + assert.equal(deps.getJellyfinConfig(), config); + assert.equal(deps.defaultServerUrl, 'http://127.0.0.1:8096'); + assert.equal(deps.getJellyfinClientInfo(config), clientInfo); + await deps.handleAuthCommands({ + args, + jellyfinConfig: config, + serverUrl: config.serverUrl, + clientInfo, + }); + await deps.handleRemoteAnnounceCommand(args); + await deps.handleListCommands({ + args, + session: { + serverUrl: config.serverUrl, + accessToken: config.accessToken, + userId: config.userId, + username: config.username, + }, + clientInfo, + jellyfinConfig: config, + }); + await deps.handlePlayCommand({ + args, + session: { + serverUrl: config.serverUrl, + accessToken: config.accessToken, + userId: config.userId, + username: config.username, + }, + clientInfo, + jellyfinConfig: config, + }); + assert.deepEqual(calls, ['auth', 'remote', 'list', 'play']); +}); diff --git a/src/main/runtime/jellyfin-command-dispatch-main-deps.ts b/src/main/runtime/jellyfin-command-dispatch-main-deps.ts new file mode 100644 index 0000000..33a4994 --- /dev/null +++ b/src/main/runtime/jellyfin-command-dispatch-main-deps.ts @@ -0,0 +1,55 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinConfigBase = { + serverUrl?: string; + accessToken?: string; + userId?: string; + username?: string; +}; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +export type RunJellyfinCommandMainDeps = { + getJellyfinConfig: () => TConfig; + defaultServerUrl: string; + getJellyfinClientInfo: (config: TConfig) => TClientInfo; + handleAuthCommands: (params: { + args: CliArgs; + jellyfinConfig: TConfig; + serverUrl: string; + clientInfo: TClientInfo; + }) => Promise; + handleRemoteAnnounceCommand: (args: CliArgs) => Promise; + handleListCommands: (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: TClientInfo; + jellyfinConfig: TConfig; + }) => Promise; + handlePlayCommand: (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: TClientInfo; + jellyfinConfig: TConfig; + }) => Promise; +}; + +export function createBuildRunJellyfinCommandMainDepsHandler< + TClientInfo, + TConfig extends JellyfinConfigBase, +>(deps: RunJellyfinCommandMainDeps) { + return (): RunJellyfinCommandMainDeps => ({ + getJellyfinConfig: () => deps.getJellyfinConfig(), + defaultServerUrl: deps.defaultServerUrl, + getJellyfinClientInfo: (config: TConfig) => deps.getJellyfinClientInfo(config), + handleAuthCommands: (params) => deps.handleAuthCommands(params), + handleRemoteAnnounceCommand: (args: CliArgs) => deps.handleRemoteAnnounceCommand(args), + handleListCommands: (params) => deps.handleListCommands(params), + handlePlayCommand: (params) => deps.handlePlayCommand(params), + }); +} diff --git a/src/main/runtime/jellyfin-command-dispatch.test.ts b/src/main/runtime/jellyfin-command-dispatch.test.ts new file mode 100644 index 0000000..5f0b39b --- /dev/null +++ b/src/main/runtime/jellyfin-command-dispatch.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRunJellyfinCommandHandler } from './jellyfin-command-dispatch'; +import type { CliArgs } from '../../cli/args'; + +function createArgs(overrides: Partial = {}): CliArgs { + return { + raw: [], + target: null, + start: false, + stats: false, + listRecent: false, + listMediaInfo: false, + subs: false, + noAnki: false, + noKnown: false, + noAnilist: false, + anilistStatus: false, + clearAnilistToken: false, + anilistSetup: false, + anilistQueueStatus: false, + anilistQueueRetry: false, + yomitanSettings: false, + toggleOverlay: false, + hideOverlay: false, + showOverlay: false, + toggleInvisibleOverlay: false, + hideInvisibleOverlay: false, + showInvisibleOverlay: false, + copyCurrentSubtitle: false, + multiCopy: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + refreshKnownCache: false, + triggerFieldGrouping: false, + manualSubsync: false, + markAudioCard: false, + cycleSecondarySub: false, + runtimeOptions: false, + debugOverlay: false, + jellyfinSetup: false, + jellyfinLogin: false, + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + jellyfinPlay: false, + jellyfinRemoteAnnounce: false, + help: false, + ...overrides, + } as CliArgs; +} + +test('run jellyfin command returns after auth branch handles command', async () => { + const calls: string[] = []; + const run = createRunJellyfinCommandHandler({ + getJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096' }), + defaultServerUrl: 'http://127.0.0.1:8096', + getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }), + handleAuthCommands: async () => { + calls.push('auth'); + return true; + }, + handleRemoteAnnounceCommand: async () => { + calls.push('remote'); + return false; + }, + handleListCommands: async () => { + calls.push('list'); + return false; + }, + handlePlayCommand: async () => { + calls.push('play'); + return false; + }, + }); + + await run(createArgs()); + assert.deepEqual(calls, ['auth']); +}); + +test('run jellyfin command throws when session missing after auth', async () => { + const run = createRunJellyfinCommandHandler({ + getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }), + defaultServerUrl: '', + getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }), + handleAuthCommands: async () => false, + handleRemoteAnnounceCommand: async () => false, + handleListCommands: async () => false, + handlePlayCommand: async () => false, + }); + + await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/); +}); + +test('run jellyfin command dispatches remote/list/play in order until handled', async () => { + const calls: string[] = []; + const seenServerUrls: string[] = []; + const run = createRunJellyfinCommandHandler({ + getJellyfinConfig: () => ({ + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', + }), + defaultServerUrl: 'http://127.0.0.1:8096', + getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }), + handleAuthCommands: async ({ serverUrl }) => { + calls.push('auth'); + seenServerUrls.push(serverUrl); + return false; + }, + handleRemoteAnnounceCommand: async () => { + calls.push('remote'); + return false; + }, + handleListCommands: async ({ session }) => { + calls.push('list'); + seenServerUrls.push(session.serverUrl); + return true; + }, + handlePlayCommand: async () => { + calls.push('play'); + return false; + }, + }); + + await run(createArgs({ jellyfinServer: 'http://override:8096' })); + + assert.deepEqual(calls, ['auth', 'remote', 'list']); + assert.deepEqual(seenServerUrls, ['http://override:8096', 'http://override:8096']); +}); diff --git a/src/main/runtime/jellyfin-command-dispatch.ts b/src/main/runtime/jellyfin-command-dispatch.ts new file mode 100644 index 0000000..109e661 --- /dev/null +++ b/src/main/runtime/jellyfin-command-dispatch.ts @@ -0,0 +1,100 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinConfigBase = { + serverUrl?: string; + accessToken?: string; + userId?: string; + username?: string; +}; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +export function createRunJellyfinCommandHandler< + TClientInfo, + TConfig extends JellyfinConfigBase, +>(deps: { + getJellyfinConfig: () => TConfig; + defaultServerUrl: string; + getJellyfinClientInfo: (config: TConfig) => TClientInfo; + handleAuthCommands: (params: { + args: CliArgs; + jellyfinConfig: TConfig; + serverUrl: string; + clientInfo: TClientInfo; + }) => Promise; + handleRemoteAnnounceCommand: (args: CliArgs) => Promise; + handleListCommands: (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: TClientInfo; + jellyfinConfig: TConfig; + }) => Promise; + handlePlayCommand: (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: TClientInfo; + jellyfinConfig: TConfig; + }) => Promise; +}) { + return async (args: CliArgs): Promise => { + const jellyfinConfig = deps.getJellyfinConfig(); + const serverUrl = + args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl; + const clientInfo = deps.getJellyfinClientInfo(jellyfinConfig); + + if ( + await deps.handleAuthCommands({ + args, + jellyfinConfig, + serverUrl, + clientInfo, + }) + ) { + return; + } + + const accessToken = jellyfinConfig.accessToken; + const userId = jellyfinConfig.userId; + if (!serverUrl || !accessToken || !userId) { + throw new Error('Missing Jellyfin session. Run --jellyfin-login first.'); + } + + const session: JellyfinSession = { + serverUrl, + accessToken, + userId, + username: jellyfinConfig.username || '', + }; + + if (await deps.handleRemoteAnnounceCommand(args)) { + return; + } + + if ( + await deps.handleListCommands({ + args, + session, + clientInfo, + jellyfinConfig, + }) + ) { + return; + } + + if ( + await deps.handlePlayCommand({ + args, + session, + clientInfo, + jellyfinConfig, + }) + ) { + return; + } + }; +} diff --git a/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts b/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts new file mode 100644 index 0000000..0b1a98e --- /dev/null +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildPlayJellyfinItemInMpvMainDepsHandler } from './jellyfin-playback-launch-main-deps'; + +test('play jellyfin item in mpv main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildPlayJellyfinItemInMpvMainDepsHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'u', + mode: 'direct', + title: 't', + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => calls.push('defaults'), + sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`), + armQuitOnDisconnect: () => calls.push('arm'), + schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`), + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => calls.push('preload'), + setActivePlayback: () => calls.push('active'), + setLastProgressAtMs: () => calls.push('progress'), + reportPlaying: () => calls.push('report'), + showMpvOsd: (text) => calls.push(`osd:${text}`), + })(); + + assert.equal(await deps.ensureMpvConnectedForPlayback(), true); + assert.equal(typeof deps.getMpvClient(), 'object'); + assert.deepEqual( + await deps.resolvePlaybackPlan({ + session: { + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', + }, + clientInfo: { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'did', + }, + jellyfinConfig: {}, + itemId: 'i', + }), + { + url: 'u', + mode: 'direct', + title: 't', + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }, + ); + deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} }); + deps.sendMpvCommand(['show-text', 'x']); + deps.armQuitOnDisconnect(); + deps.schedule(() => {}, 500); + assert.equal(deps.convertTicksToSeconds(20_000_000), 2); + deps.preloadExternalSubtitles({ + session: { + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', + }, + clientInfo: { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'did', + }, + itemId: 'i', + }); + deps.setActivePlayback({ itemId: 'i', mediaSourceId: undefined, playMethod: 'DirectPlay' }); + deps.setLastProgressAtMs(0); + deps.reportPlaying({ + itemId: 'i', + mediaSourceId: undefined, + playMethod: 'DirectPlay', + eventName: 'start', + }); + deps.showMpvOsd('ok'); + + assert.deepEqual(calls, [ + 'defaults', + 'cmd:show-text', + 'arm', + 'schedule:500', + 'preload', + 'active', + 'progress', + 'report', + 'osd:ok', + ]); +}); diff --git a/src/main/runtime/jellyfin-playback-launch-main-deps.ts b/src/main/runtime/jellyfin-playback-launch-main-deps.ts new file mode 100644 index 0000000..83af3c1 --- /dev/null +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.ts @@ -0,0 +1,21 @@ +import type { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch'; + +type PlayJellyfinItemInMpvMainDeps = Parameters[0]; + +export function createBuildPlayJellyfinItemInMpvMainDepsHandler(deps: PlayJellyfinItemInMpvMainDeps) { + return (): PlayJellyfinItemInMpvMainDeps => ({ + ensureMpvConnectedForPlayback: () => deps.ensureMpvConnectedForPlayback(), + getMpvClient: () => deps.getMpvClient(), + resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params), + applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient), + sendMpvCommand: (command: Array) => deps.sendMpvCommand(command), + armQuitOnDisconnect: () => deps.armQuitOnDisconnect(), + schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs), + convertTicksToSeconds: (ticks: number) => deps.convertTicksToSeconds(ticks), + preloadExternalSubtitles: (params) => deps.preloadExternalSubtitles(params), + setActivePlayback: (state) => deps.setActivePlayback(state), + setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value), + reportPlaying: (payload) => deps.reportPlaying(payload), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + }); +} diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts new file mode 100644 index 0000000..5aa8fe4 --- /dev/null +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch'; + +const baseSession = { + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', +}; + +const baseClientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'did', +}; + +test('playback handler throws when mpv is not connected', async () => { + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => false, + getMpvClient: () => null, + resolvePlaybackPlan: async () => { + throw new Error('unreachable'); + }, + applyJellyfinMpvDefaults: () => {}, + sendMpvCommand: () => {}, + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + }); + + await assert.rejects( + () => + handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-1', + }), + /MPV not connected and auto-launch failed/, + ); +}); + +test('playback handler drives mpv commands and playback state', async () => { + const commands: Array> = []; + const scheduled: Array<{ delay: number; callback: () => void }> = []; + const calls: string[] = []; + const activeStates: Array> = []; + const reportPayloads: Array> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 1', + startTimeTicks: 12_000_000, + audioStreamIndex: 1, + subtitleStreamIndex: 2, + }), + applyJellyfinMpvDefaults: () => calls.push('defaults'), + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => calls.push('arm'), + schedule: (callback, delayMs) => { + scheduled.push({ delay: delayMs, callback }); + }, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => calls.push('preload'), + setActivePlayback: (state) => activeStates.push(state as Record), + setLastProgressAtMs: (value) => calls.push(`progress:${value}`), + reportPlaying: (payload) => reportPayloads.push(payload as Record), + showMpvOsd: (text) => calls.push(`osd:${text}`), + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-1', + }); + + assert.deepEqual(commands.slice(0, 5), [ + ['set_property', 'sub-auto', 'no'], + ['loadfile', 'https://stream.example/video.m3u8', 'replace'], + ['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'], + ['set_property', 'sid', 'no'], + ['seek', 1.2, 'absolute+exact'], + ]); + assert.equal(scheduled.length, 1); + assert.equal(scheduled[0]?.delay, 500); + scheduled[0]?.callback(); + assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']); + + assert.ok(calls.includes('defaults')); + assert.ok(calls.includes('arm')); + assert.ok(calls.includes('preload')); + assert.ok(calls.includes('progress:0')); + assert.ok(calls.includes('osd:Jellyfin direct: Episode 1')); + + assert.equal(activeStates.length, 1); + assert.equal(activeStates[0]?.playMethod, 'DirectPlay'); + assert.equal(reportPayloads.length, 1); + assert.equal(reportPayloads[0]?.eventName, 'start'); +}); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts new file mode 100644 index 0000000..915106d --- /dev/null +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -0,0 +1,128 @@ +import type { JellyfinAuthSession, JellyfinPlaybackPlan } from '../../core/services/jellyfin'; +import type { JellyfinConfig } from '../../types'; +import type { MpvRuntimeClientLike } from '../../core/services/mpv'; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type ActivePlaybackState = { + itemId: string; + mediaSourceId: undefined; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + playMethod: 'DirectPlay' | 'Transcode'; +}; + +export function createPlayJellyfinItemInMpvHandler(deps: { + ensureMpvConnectedForPlayback: () => Promise; + getMpvClient: () => MpvRuntimeClientLike | null; + resolvePlaybackPlan: (params: { + session: JellyfinAuthSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: JellyfinConfig; + itemId: string; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + }) => Promise; + applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void; + sendMpvCommand: (command: Array) => void; + armQuitOnDisconnect: () => void; + schedule: (callback: () => void, delayMs: number) => void; + convertTicksToSeconds: (ticks: number) => number; + preloadExternalSubtitles: (params: { + session: JellyfinAuthSession; + clientInfo: JellyfinClientInfo; + itemId: string; + }) => void; + setActivePlayback: (state: ActivePlaybackState) => void; + setLastProgressAtMs: (value: number) => void; + reportPlaying: (payload: { + itemId: string; + mediaSourceId: undefined; + playMethod: 'DirectPlay' | 'Transcode'; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + eventName: 'start'; + }) => void; + showMpvOsd: (text: string) => void; +}) { + return async (params: { + session: JellyfinAuthSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: JellyfinConfig; + itemId: string; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + startTimeTicksOverride?: number; + setQuitOnDisconnectArm?: boolean; + }): Promise => { + const connected = await deps.ensureMpvConnectedForPlayback(); + const mpvClient = deps.getMpvClient(); + if (!connected || !mpvClient) { + throw new Error( + 'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.', + ); + } + + const plan = await deps.resolvePlaybackPlan({ + session: params.session, + clientInfo: params.clientInfo, + jellyfinConfig: params.jellyfinConfig, + itemId: params.itemId, + audioStreamIndex: params.audioStreamIndex, + subtitleStreamIndex: params.subtitleStreamIndex, + }); + + deps.applyJellyfinMpvDefaults(mpvClient); + deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); + deps.sendMpvCommand(['loadfile', plan.url, 'replace']); + if (params.setQuitOnDisconnectArm !== false) { + deps.armQuitOnDisconnect(); + } + deps.sendMpvCommand([ + 'set_property', + 'force-media-title', + `[Jellyfin/${plan.mode}] ${plan.title}`, + ]); + deps.sendMpvCommand(['set_property', 'sid', 'no']); + deps.schedule(() => { + deps.sendMpvCommand(['set_property', 'sid', 'no']); + }, 500); + + const startTimeTicks = + typeof params.startTimeTicksOverride === 'number' + ? Math.max(0, params.startTimeTicksOverride) + : plan.startTimeTicks; + if (startTimeTicks > 0) { + deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']); + } + + deps.preloadExternalSubtitles({ + session: params.session, + clientInfo: params.clientInfo, + itemId: params.itemId, + }); + + const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode'; + deps.setActivePlayback({ + itemId: params.itemId, + mediaSourceId: undefined, + audioStreamIndex: plan.audioStreamIndex, + subtitleStreamIndex: plan.subtitleStreamIndex, + playMethod, + }); + deps.setLastProgressAtMs(0); + deps.reportPlaying({ + itemId: params.itemId, + mediaSourceId: undefined, + playMethod, + audioStreamIndex: plan.audioStreamIndex, + subtitleStreamIndex: plan.subtitleStreamIndex, + eventName: 'start', + }); + deps.showMpvOsd(`Jellyfin ${plan.mode}: ${plan.title}`); + }; +} diff --git a/src/main/runtime/jellyfin-remote-commands.test.ts b/src/main/runtime/jellyfin-remote-commands.test.ts new file mode 100644 index 0000000..313545e --- /dev/null +++ b/src/main/runtime/jellyfin-remote-commands.test.ts @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createHandleJellyfinRemoteGeneralCommand, + createHandleJellyfinRemotePlay, + createHandleJellyfinRemotePlaystate, + getConfiguredJellyfinSession, + type ActiveJellyfinRemotePlaybackState, +} from './jellyfin-remote-commands'; + +test('getConfiguredJellyfinSession returns null for incomplete config', () => { + assert.equal( + getConfiguredJellyfinSession({ + serverUrl: '', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + null, + ); +}); + +test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => { + const calls: Array<{ itemId: string; audio?: number; subtitle?: number; start?: number }> = []; + const handlePlay = createHandleJellyfinRemotePlay({ + getConfiguredSession: () => ({ + serverUrl: 'https://jellyfin.local', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }), + getJellyfinConfig: () => ({ enabled: true }), + playJellyfinItem: async (params) => { + calls.push({ + itemId: params.itemId, + audio: params.audioStreamIndex, + subtitle: params.subtitleStreamIndex, + start: params.startTimeTicksOverride, + }); + }, + logWarn: () => {}, + }); + + await handlePlay({ + ItemIds: ['item-1'], + AudioStreamIndex: 3, + SubtitleStreamIndex: 7, + StartPositionTicks: 1000, + }); + + assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]); +}); + +test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => { + const warnings: string[] = []; + const handlePlay = createHandleJellyfinRemotePlay({ + getConfiguredSession: () => ({ + serverUrl: 'https://jellyfin.local', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }), + getJellyfinConfig: () => ({}), + playJellyfinItem: async () => { + throw new Error('should not be called'); + }, + logWarn: (message) => warnings.push(message), + }); + + await handlePlay({ ItemIds: [] }); + assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']); +}); + +test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => { + const mpvClient = {}; + const commands: Array<(string | number)[]> = []; + const calls: string[] = []; + const handlePlaystate = createHandleJellyfinRemotePlaystate({ + getMpvClient: () => mpvClient, + sendMpvCommand: (_client, command) => commands.push(command), + reportJellyfinRemoteProgress: async (force) => { + calls.push(`progress:${force}`); + }, + reportJellyfinRemoteStopped: async () => { + calls.push('stopped'); + }, + jellyfinTicksToSeconds: (ticks) => ticks / 10, + }); + + await handlePlaystate({ Command: 'Pause' }); + await handlePlaystate({ Command: 'Seek', SeekPositionTicks: 50 }); + await handlePlaystate({ Command: 'Stop' }); + + assert.deepEqual(commands, [ + ['set_property', 'pause', 'yes'], + ['seek', 5, 'absolute+exact'], + ['stop'], + ]); + assert.deepEqual(calls, ['progress:true', 'progress:true', 'stopped']); +}); + +test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices', async () => { + const mpvClient = {}; + const commands: Array<(string | number)[]> = []; + const playback: ActiveJellyfinRemotePlaybackState = { + itemId: 'item-1', + playMethod: 'DirectPlay', + audioStreamIndex: null, + subtitleStreamIndex: null, + }; + const calls: string[] = []; + + const handleGeneral = createHandleJellyfinRemoteGeneralCommand({ + getMpvClient: () => mpvClient, + sendMpvCommand: (_client, command) => commands.push(command), + getActivePlayback: () => playback, + reportJellyfinRemoteProgress: async (force) => { + calls.push(`progress:${force}`); + }, + logDebug: (message) => { + calls.push(`debug:${message}`); + }, + }); + + await handleGeneral({ Name: 'SetAudioStreamIndex', Arguments: { Index: 2 } }); + await handleGeneral({ Name: 'SetSubtitleStreamIndex', Arguments: { Index: -1 } }); + await handleGeneral({ Name: 'UnsupportedCommand', Arguments: {} }); + + assert.deepEqual(commands, [ + ['set_property', 'aid', 2], + ['set_property', 'sid', 'no'], + ]); + assert.equal(playback.audioStreamIndex, 2); + assert.equal(playback.subtitleStreamIndex, null); + assert.ok(calls.includes('progress:true')); + assert.ok( + calls.some((entry) => entry.includes('Ignoring unsupported Jellyfin GeneralCommand: UnsupportedCommand')), + ); +}); diff --git a/src/main/runtime/jellyfin-remote-commands.ts b/src/main/runtime/jellyfin-remote-commands.ts new file mode 100644 index 0000000..ec6f777 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-commands.ts @@ -0,0 +1,189 @@ +export type ActiveJellyfinRemotePlaybackState = { + itemId: string; + mediaSourceId?: string; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + playMethod: 'DirectPlay' | 'Transcode'; +}; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type JellyfinConfigLike = { + serverUrl?: string; + accessToken?: string; + userId?: string; + username?: string; +}; + +function asInteger(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isInteger(value)) return undefined; + return value; +} + +export function getConfiguredJellyfinSession(config: JellyfinConfigLike): JellyfinSession | null { + if (!config.serverUrl || !config.accessToken || !config.userId) { + return null; + } + return { + serverUrl: config.serverUrl, + accessToken: config.accessToken, + userId: config.userId, + username: config.username || '', + }; +} + +export type JellyfinRemotePlayHandlerDeps = { + getConfiguredSession: () => JellyfinSession | null; + getClientInfo: () => JellyfinClientInfo; + getJellyfinConfig: () => unknown; + playJellyfinItem: (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: unknown; + itemId: string; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + startTimeTicksOverride?: number; + setQuitOnDisconnectArm?: boolean; + }) => Promise; + logWarn: (message: string) => void; +}; + +export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDeps) { + return async (payload: unknown): Promise => { + const session = deps.getConfiguredSession(); + if (!session) return; + const clientInfo = deps.getClientInfo(); + const jellyfinConfig = deps.getJellyfinConfig(); + const data = payload && typeof payload === 'object' ? (payload as Record) : {}; + const itemIds = Array.isArray(data.ItemIds) + ? data.ItemIds.filter((entry): entry is string => typeof entry === 'string') + : []; + const itemId = itemIds[0]; + if (!itemId) { + deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.'); + return; + } + await deps.playJellyfinItem({ + session, + clientInfo, + jellyfinConfig, + itemId, + audioStreamIndex: asInteger(data.AudioStreamIndex), + subtitleStreamIndex: asInteger(data.SubtitleStreamIndex), + startTimeTicksOverride: asInteger(data.StartPositionTicks), + setQuitOnDisconnectArm: false, + }); + }; +} + +type MpvClientLike = object; + +export type JellyfinRemotePlaystateHandlerDeps = { + getMpvClient: () => MpvClientLike | null; + sendMpvCommand: (client: MpvClientLike, command: (string | number)[]) => void; + reportJellyfinRemoteProgress: (force: boolean) => Promise; + reportJellyfinRemoteStopped: () => Promise; + jellyfinTicksToSeconds: (ticks: number) => number; +}; + +export function createHandleJellyfinRemotePlaystate(deps: JellyfinRemotePlaystateHandlerDeps) { + return async (payload: unknown): Promise => { + const data = payload && typeof payload === 'object' ? (payload as Record) : {}; + const command = String(data.Command || ''); + const client = deps.getMpvClient(); + if (!client) return; + if (command === 'Pause') { + deps.sendMpvCommand(client, ['set_property', 'pause', 'yes']); + await deps.reportJellyfinRemoteProgress(true); + return; + } + if (command === 'Unpause') { + deps.sendMpvCommand(client, ['set_property', 'pause', 'no']); + await deps.reportJellyfinRemoteProgress(true); + return; + } + if (command === 'PlayPause') { + deps.sendMpvCommand(client, ['cycle', 'pause']); + await deps.reportJellyfinRemoteProgress(true); + return; + } + if (command === 'Stop') { + deps.sendMpvCommand(client, ['stop']); + await deps.reportJellyfinRemoteStopped(); + return; + } + if (command === 'Seek') { + const seekTicks = asInteger(data.SeekPositionTicks); + if (seekTicks !== undefined) { + deps.sendMpvCommand(client, [ + 'seek', + deps.jellyfinTicksToSeconds(seekTicks), + 'absolute+exact', + ]); + await deps.reportJellyfinRemoteProgress(true); + } + } + }; +} + +export type JellyfinRemoteGeneralCommandHandlerDeps = { + getMpvClient: () => MpvClientLike | null; + sendMpvCommand: (client: MpvClientLike, command: (string | number)[]) => void; + getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; + reportJellyfinRemoteProgress: (force: boolean) => Promise; + logDebug: (message: string) => void; +}; + +export function createHandleJellyfinRemoteGeneralCommand( + deps: JellyfinRemoteGeneralCommandHandlerDeps, +) { + return async (payload: unknown): Promise => { + const data = payload && typeof payload === 'object' ? (payload as Record) : {}; + const command = String(data.Name || ''); + const args = + data.Arguments && typeof data.Arguments === 'object' + ? (data.Arguments as Record) + : {}; + const client = deps.getMpvClient(); + if (!client) return; + + if (command === 'SetAudioStreamIndex') { + const index = asInteger(args.Index); + if (index !== undefined) { + deps.sendMpvCommand(client, ['set_property', 'aid', index]); + const playback = deps.getActivePlayback(); + if (playback) { + playback.audioStreamIndex = index; + } + await deps.reportJellyfinRemoteProgress(true); + } + return; + } + if (command === 'SetSubtitleStreamIndex') { + const index = asInteger(args.Index); + if (index !== undefined) { + deps.sendMpvCommand(client, ['set_property', 'sid', index < 0 ? 'no' : index]); + const playback = deps.getActivePlayback(); + if (playback) { + playback.subtitleStreamIndex = index < 0 ? null : index; + } + await deps.reportJellyfinRemoteProgress(true); + } + return; + } + + deps.logDebug(`Ignoring unsupported Jellyfin GeneralCommand: ${command}`); + }; +} diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts new file mode 100644 index 0000000..79de86a --- /dev/null +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler, + createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler, + createBuildWaitForMpvConnectedMainDepsHandler, +} from './jellyfin-remote-connection-main-deps'; + +test('wait for mpv connected main deps builder maps callbacks', async () => { + const calls: string[] = []; + const client = { connected: false, connect: () => calls.push('connect') }; + const deps = createBuildWaitForMpvConnectedMainDepsHandler({ + getMpvClient: () => client, + now: () => 123, + sleep: async () => { + calls.push('sleep'); + }, + })(); + + assert.equal(deps.getMpvClient(), client); + assert.equal(deps.now(), 123); + await deps.sleep(10); + assert.deepEqual(calls, ['sleep']); +}); + +test('launch mpv for jellyfin main deps builder maps callbacks', () => { + const calls: string[] = []; + const proc = { + on: () => {}, + unref: () => { + calls.push('unref'); + }, + }; + const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({ + getSocketPath: () => '/tmp/mpv.sock', + platform: 'darwin', + execPath: '/tmp/subminer', + defaultMpvLogPath: '/tmp/mpv.log', + defaultMpvArgs: ['--no-config'], + removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`), + spawnMpv: (args) => { + calls.push(`spawn:${args.join(' ')}`); + return proc; + }, + logWarn: (message) => calls.push(`warn:${message}`), + logInfo: (message) => calls.push(`info:${message}`), + })(); + + assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); + assert.equal(deps.platform, 'darwin'); + assert.equal(deps.execPath, '/tmp/subminer'); + assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log'); + assert.deepEqual(deps.defaultMpvArgs, ['--no-config']); + deps.removeSocketPath('/tmp/mpv.sock'); + deps.spawnMpv(['--idle=yes']); + deps.logInfo('launched'); + deps.logWarn('bad', null); + assert.deepEqual(calls, ['rm:/tmp/mpv.sock', 'spawn:--idle=yes', 'info:launched', 'warn:bad']); +}); + +test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => { + const calls: string[] = []; + const client = { connected: true, connect: () => {} }; + const waitPromise = Promise.resolve(true); + const inFlight = Promise.resolve(false); + const deps = createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler({ + getMpvClient: () => client, + setMpvClient: () => calls.push('set-client'), + createMpvClient: () => client, + waitForMpvConnected: () => waitPromise, + launchMpvIdleForJellyfinPlayback: () => calls.push('launch'), + getAutoLaunchInFlight: () => inFlight, + setAutoLaunchInFlight: () => calls.push('set-in-flight'), + connectTimeoutMs: 7000, + autoLaunchTimeoutMs: 15000, + })(); + + assert.equal(deps.getMpvClient(), client); + deps.setMpvClient(client); + assert.equal(deps.createMpvClient(), client); + assert.equal(await deps.waitForMpvConnected(1), true); + deps.launchMpvIdleForJellyfinPlayback(); + assert.equal(deps.getAutoLaunchInFlight(), inFlight); + deps.setAutoLaunchInFlight(null); + assert.equal(deps.connectTimeoutMs, 7000); + assert.equal(deps.autoLaunchTimeoutMs, 15000); + assert.deepEqual(calls, ['set-client', 'launch', 'set-in-flight']); +}); diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.ts new file mode 100644 index 0000000..563ecc1 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.ts @@ -0,0 +1,45 @@ +import type { + EnsureMpvConnectedDeps, + LaunchMpvForJellyfinDeps, + WaitForMpvConnectedDeps, +} from './jellyfin-remote-connection'; + +export function createBuildWaitForMpvConnectedMainDepsHandler(deps: WaitForMpvConnectedDeps) { + return (): WaitForMpvConnectedDeps => ({ + getMpvClient: () => deps.getMpvClient(), + now: () => deps.now(), + sleep: (delayMs: number) => deps.sleep(delayMs), + }); +} + +export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler( + deps: LaunchMpvForJellyfinDeps, +) { + return (): LaunchMpvForJellyfinDeps => ({ + getSocketPath: () => deps.getSocketPath(), + platform: deps.platform, + execPath: deps.execPath, + defaultMpvLogPath: deps.defaultMpvLogPath, + defaultMpvArgs: deps.defaultMpvArgs, + removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath), + spawnMpv: (args: string[]) => deps.spawnMpv(args), + logWarn: (message: string, error: unknown) => deps.logWarn(message, error), + logInfo: (message: string) => deps.logInfo(message), + }); +} + +export function createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler( + deps: EnsureMpvConnectedDeps, +) { + return (): EnsureMpvConnectedDeps => ({ + getMpvClient: () => deps.getMpvClient(), + setMpvClient: (client) => deps.setMpvClient(client), + createMpvClient: () => deps.createMpvClient(), + waitForMpvConnected: (timeoutMs: number) => deps.waitForMpvConnected(timeoutMs), + launchMpvIdleForJellyfinPlayback: () => deps.launchMpvIdleForJellyfinPlayback(), + getAutoLaunchInFlight: () => deps.getAutoLaunchInFlight(), + setAutoLaunchInFlight: (promise) => deps.setAutoLaunchInFlight(promise), + connectTimeoutMs: deps.connectTimeoutMs, + autoLaunchTimeoutMs: deps.autoLaunchTimeoutMs, + }); +} diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts new file mode 100644 index 0000000..f51cf6c --- /dev/null +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createEnsureMpvConnectedForJellyfinPlaybackHandler, + createLaunchMpvIdleForJellyfinPlaybackHandler, + createWaitForMpvConnectedHandler, +} from './jellyfin-remote-connection'; + +test('createWaitForMpvConnectedHandler connects and waits for readiness', async () => { + let connected = false; + let nowMs = 0; + const waitForConnected = createWaitForMpvConnectedHandler({ + getMpvClient: () => ({ + connected, + connect: () => { + connected = true; + }, + }), + now: () => nowMs, + sleep: async () => { + nowMs += 100; + }, + }); + + const ready = await waitForConnected(500); + assert.equal(ready, true); +}); + +test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', () => { + const spawnedArgs: string[][] = []; + const logs: string[] = []; + const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ + getSocketPath: () => '/tmp/subminer.sock', + platform: 'darwin', + execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + defaultMpvLogPath: '/tmp/mp.log', + defaultMpvArgs: ['--sid=auto'], + removeSocketPath: () => {}, + spawnMpv: (args) => { + spawnedArgs.push(args); + return { + on: () => {}, + unref: () => {}, + }; + }, + logWarn: (message) => logs.push(message), + logInfo: (message) => logs.push(message), + }); + + launch(); + assert.equal(spawnedArgs.length, 1); + assert.ok(spawnedArgs[0]!.includes('--idle=yes')); + assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock'))); + assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback'))); +}); + +test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => { + let autoLaunchInFlight: Promise | null = null; + let launchCalls = 0; + let waitCalls = 0; + let mpvClient: { connected: boolean; connect: () => void } | null = null; + let resolveAutoLaunchPromise: (value: boolean) => void = () => {}; + const autoLaunchPromise = new Promise((resolve) => { + resolveAutoLaunchPromise = resolve; + }); + + const ensureConnected = createEnsureMpvConnectedForJellyfinPlaybackHandler({ + getMpvClient: () => mpvClient, + setMpvClient: (client) => { + mpvClient = client; + }, + createMpvClient: () => ({ + connected: false, + connect: () => {}, + }), + waitForMpvConnected: async (timeoutMs) => { + waitCalls += 1; + if (timeoutMs === 3000) return false; + return await autoLaunchPromise; + }, + launchMpvIdleForJellyfinPlayback: () => { + launchCalls += 1; + }, + getAutoLaunchInFlight: () => autoLaunchInFlight, + setAutoLaunchInFlight: (promise) => { + autoLaunchInFlight = promise; + }, + connectTimeoutMs: 3000, + autoLaunchTimeoutMs: 20000, + }); + + const firstPromise = ensureConnected(); + const secondPromise = ensureConnected(); + resolveAutoLaunchPromise(true); + const first = await firstPromise; + const second = await secondPromise; + + assert.equal(first, true); + assert.equal(second, true); + assert.equal(launchCalls, 1); + assert.equal(waitCalls >= 2, true); +}); diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts new file mode 100644 index 0000000..8f34f1e --- /dev/null +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -0,0 +1,108 @@ +type MpvClientLike = { + connected: boolean; + connect: () => void; +}; + +type SpawnedProcessLike = { + on: (event: 'error', listener: (error: unknown) => void) => void; + unref: () => void; +}; + +export type WaitForMpvConnectedDeps = { + getMpvClient: () => MpvClientLike | null; + now: () => number; + sleep: (delayMs: number) => Promise; +}; + +export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps) { + return async (timeoutMs = 7000): Promise => { + const client = deps.getMpvClient(); + if (!client) return false; + if (client.connected) return true; + try { + client.connect(); + } catch {} + + const startedAt = deps.now(); + while (deps.now() - startedAt < timeoutMs) { + if (deps.getMpvClient()?.connected) return true; + await deps.sleep(100); + } + return Boolean(deps.getMpvClient()?.connected); + }; +} + +export type LaunchMpvForJellyfinDeps = { + getSocketPath: () => string; + platform: NodeJS.Platform; + execPath: string; + defaultMpvLogPath: string; + defaultMpvArgs: readonly string[]; + removeSocketPath: (socketPath: string) => void; + spawnMpv: (args: string[]) => SpawnedProcessLike; + logWarn: (message: string, error: unknown) => void; + logInfo: (message: string) => void; +}; + +export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) { + return (): void => { + const socketPath = deps.getSocketPath(); + if (deps.platform !== 'win32') { + try { + deps.removeSocketPath(socketPath); + } catch { + // ignore stale socket cleanup errors + } + } + + const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`; + const mpvArgs = [ + ...deps.defaultMpvArgs, + '--idle=yes', + scriptOpts, + `--log-file=${deps.defaultMpvLogPath}`, + `--input-ipc-server=${socketPath}`, + ]; + const proc = deps.spawnMpv(mpvArgs); + proc.on('error', (error) => { + deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error); + }); + proc.unref(); + deps.logInfo(`Launched mpv for Jellyfin playback on socket: ${socketPath}`); + }; +} + +export type EnsureMpvConnectedDeps = { + getMpvClient: () => MpvClientLike | null; + setMpvClient: (client: MpvClientLike | null) => void; + createMpvClient: () => MpvClientLike; + waitForMpvConnected: (timeoutMs: number) => Promise; + launchMpvIdleForJellyfinPlayback: () => void; + getAutoLaunchInFlight: () => Promise | null; + setAutoLaunchInFlight: (promise: Promise | null) => void; + connectTimeoutMs: number; + autoLaunchTimeoutMs: number; +}; + +export function createEnsureMpvConnectedForJellyfinPlaybackHandler(deps: EnsureMpvConnectedDeps) { + return async (): Promise => { + if (!deps.getMpvClient()) { + deps.setMpvClient(deps.createMpvClient()); + } + + const connected = await deps.waitForMpvConnected(deps.connectTimeoutMs); + if (connected) return true; + + if (!deps.getAutoLaunchInFlight()) { + const inFlight = (async () => { + deps.launchMpvIdleForJellyfinPlayback(); + return deps.waitForMpvConnected(deps.autoLaunchTimeoutMs); + })().finally(() => { + deps.setAutoLaunchInFlight(null); + }); + deps.setAutoLaunchInFlight(inFlight); + } + + return deps.getAutoLaunchInFlight() as Promise; + }; +} diff --git a/src/main/runtime/jellyfin-remote-main-deps.test.ts b/src/main/runtime/jellyfin-remote-main-deps.test.ts new file mode 100644 index 0000000..e8c6730 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-main-deps.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler, + createBuildHandleJellyfinRemotePlayMainDepsHandler, + createBuildHandleJellyfinRemotePlaystateMainDepsHandler, + createBuildReportJellyfinRemoteProgressMainDepsHandler, + createBuildReportJellyfinRemoteStoppedMainDepsHandler, +} from './jellyfin-remote-main-deps'; + +test('jellyfin remote play main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildHandleJellyfinRemotePlayMainDepsHandler({ + getConfiguredSession: () => ({ id: 1 }) as never, + getClientInfo: () => ({ id: 2 }) as never, + getJellyfinConfig: () => ({ id: 3 }), + playJellyfinItem: async () => { + calls.push('play'); + }, + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + assert.deepEqual(deps.getConfiguredSession(), { id: 1 }); + assert.deepEqual(deps.getClientInfo(), { id: 2 }); + assert.deepEqual(deps.getJellyfinConfig(), { id: 3 }); + await deps.playJellyfinItem({} as never); + deps.logWarn('missing'); + assert.deepEqual(calls, ['play', 'warn:missing']); +}); + +test('jellyfin remote playstate main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildHandleJellyfinRemotePlaystateMainDepsHandler({ + getMpvClient: () => ({ id: 1 }), + sendMpvCommand: () => calls.push('send'), + reportJellyfinRemoteProgress: async () => { + calls.push('progress'); + }, + reportJellyfinRemoteStopped: async () => { + calls.push('stopped'); + }, + jellyfinTicksToSeconds: (ticks) => ticks / 10, + })(); + + assert.deepEqual(deps.getMpvClient(), { id: 1 }); + deps.sendMpvCommand({} as never, ['stop']); + await deps.reportJellyfinRemoteProgress(true); + await deps.reportJellyfinRemoteStopped(); + assert.equal(deps.jellyfinTicksToSeconds(100), 10); + assert.deepEqual(calls, ['send', 'progress', 'stopped']); +}); + +test('jellyfin remote general command main deps builder maps callbacks', async () => { + const calls: string[] = []; + const playback = { itemId: 'abc', playMethod: 'DirectPlay' as const }; + const deps = createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({ + getMpvClient: () => ({ id: 1 }), + sendMpvCommand: () => calls.push('send'), + getActivePlayback: () => playback, + reportJellyfinRemoteProgress: async () => { + calls.push('progress'); + }, + logDebug: (message) => calls.push(`debug:${message}`), + })(); + + assert.deepEqual(deps.getMpvClient(), { id: 1 }); + deps.sendMpvCommand({} as never, ['set_property', 'sid', 1]); + assert.deepEqual(deps.getActivePlayback(), playback); + await deps.reportJellyfinRemoteProgress(true); + deps.logDebug('ignore'); + assert.deepEqual(calls, ['send', 'progress', 'debug:ignore']); +}); + +test('jellyfin remote progress main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildReportJellyfinRemoteProgressMainDepsHandler({ + getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }), + clearActivePlayback: () => calls.push('clear'), + getSession: () => ({ id: 1, isConnected: () => true }) as never, + getMpvClient: () => ({ id: 2, requestProperty: async () => 0 }) as never, + getNow: () => 123, + getLastProgressAtMs: () => 10, + setLastProgressAtMs: () => calls.push('set-last'), + progressIntervalMs: 2500, + ticksPerSecond: 10000000, + logDebug: (message) => calls.push(`debug:${message}`), + })(); + + assert.equal(deps.getNow(), 123); + assert.equal(deps.getLastProgressAtMs(), 10); + deps.setLastProgressAtMs(5); + assert.equal(deps.progressIntervalMs, 2500); + assert.equal(deps.ticksPerSecond, 10000000); + deps.clearActivePlayback(); + deps.logDebug('x', null); + assert.deepEqual(calls, ['set-last', 'clear', 'debug:x']); +}); + +test('jellyfin remote stopped main deps builder maps callbacks', () => { + const calls: string[] = []; + const session = { id: 1, isConnected: () => true }; + const deps = createBuildReportJellyfinRemoteStoppedMainDepsHandler({ + getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }), + clearActivePlayback: () => calls.push('clear'), + getSession: () => session as never, + logDebug: (message) => calls.push(`debug:${message}`), + })(); + + assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' }); + deps.clearActivePlayback(); + assert.equal(deps.getSession(), session); + deps.logDebug('stopped', null); + assert.deepEqual(calls, ['clear', 'debug:stopped']); +}); diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts new file mode 100644 index 0000000..aebfa2a --- /dev/null +++ b/src/main/runtime/jellyfin-remote-main-deps.ts @@ -0,0 +1,73 @@ +import type { + JellyfinRemoteGeneralCommandHandlerDeps, + JellyfinRemotePlayHandlerDeps, + JellyfinRemotePlaystateHandlerDeps, +} from './jellyfin-remote-commands'; +import type { + JellyfinRemoteProgressReporterDeps, + JellyfinRemoteStoppedReporterDeps, +} from './jellyfin-remote-playback'; + +export function createBuildHandleJellyfinRemotePlayMainDepsHandler( + deps: JellyfinRemotePlayHandlerDeps, +) { + return (): JellyfinRemotePlayHandlerDeps => ({ + getConfiguredSession: () => deps.getConfiguredSession(), + getClientInfo: () => deps.getClientInfo(), + getJellyfinConfig: () => deps.getJellyfinConfig(), + playJellyfinItem: (params) => deps.playJellyfinItem(params), + logWarn: (message: string) => deps.logWarn(message), + }); +} + +export function createBuildHandleJellyfinRemotePlaystateMainDepsHandler( + deps: JellyfinRemotePlaystateHandlerDeps, +) { + return (): JellyfinRemotePlaystateHandlerDeps => ({ + getMpvClient: () => deps.getMpvClient(), + sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command), + reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force), + reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + jellyfinTicksToSeconds: (ticks: number) => deps.jellyfinTicksToSeconds(ticks), + }); +} + +export function createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler( + deps: JellyfinRemoteGeneralCommandHandlerDeps, +) { + return (): JellyfinRemoteGeneralCommandHandlerDeps => ({ + getMpvClient: () => deps.getMpvClient(), + sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command), + getActivePlayback: () => deps.getActivePlayback(), + reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force), + logDebug: (message: string) => deps.logDebug(message), + }); +} + +export function createBuildReportJellyfinRemoteProgressMainDepsHandler( + deps: JellyfinRemoteProgressReporterDeps, +) { + return (): JellyfinRemoteProgressReporterDeps => ({ + getActivePlayback: () => deps.getActivePlayback(), + clearActivePlayback: () => deps.clearActivePlayback(), + getSession: () => deps.getSession(), + getMpvClient: () => deps.getMpvClient(), + getNow: () => deps.getNow(), + getLastProgressAtMs: () => deps.getLastProgressAtMs(), + setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value), + progressIntervalMs: deps.progressIntervalMs, + ticksPerSecond: deps.ticksPerSecond, + logDebug: (message: string, error: unknown) => deps.logDebug(message, error), + }); +} + +export function createBuildReportJellyfinRemoteStoppedMainDepsHandler( + deps: JellyfinRemoteStoppedReporterDeps, +) { + return (): JellyfinRemoteStoppedReporterDeps => ({ + getActivePlayback: () => deps.getActivePlayback(), + clearActivePlayback: () => deps.clearActivePlayback(), + getSession: () => deps.getSession(), + logDebug: (message: string, error: unknown) => deps.logDebug(message, error), + }); +} diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts new file mode 100644 index 0000000..743018c --- /dev/null +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createReportJellyfinRemoteProgressHandler, + createReportJellyfinRemoteStoppedHandler, + secondsToJellyfinTicks, +} from './jellyfin-remote-playback'; + +test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => { + assert.equal(secondsToJellyfinTicks(1.25, 10_000_000), 12_500_000); + assert.equal(secondsToJellyfinTicks(-3, 10_000_000), 0); + assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0); +}); + +test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => { + let lastProgressAtMs = 0; + const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = []; + + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => ({ + itemId: 'item-1', + mediaSourceId: undefined, + playMethod: 'DirectPlay', + audioStreamIndex: 1, + subtitleStreamIndex: 2, + }), + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => true, + reportProgress: async (payload) => { + reportPayloads.push({ + itemId: payload.itemId, + positionTicks: payload.positionTicks, + isPaused: payload.isPaused, + }); + }, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true), + }), + getNow: () => 5000, + getLastProgressAtMs: () => lastProgressAtMs, + setLastProgressAtMs: (value) => { + lastProgressAtMs = value; + }, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(true); + + assert.deepEqual(reportPayloads, [ + { + itemId: 'item-1', + positionTicks: 25_000_000, + isPaused: true, + }, + ]); + assert.equal(lastProgressAtMs, 5000); +}); + +test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => { + let called = false; + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => true, + reportProgress: async () => { + called = true; + }, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + requestProperty: async () => 1, + }), + getNow: () => 4000, + getLastProgressAtMs: () => 3500, + setLastProgressAtMs: () => {}, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(false); + assert.equal(called, false); +}); + +test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => { + let cleared = false; + let stoppedItemId: string | null = null; + const reportStopped = createReportJellyfinRemoteStoppedHandler({ + getActivePlayback: () => ({ + itemId: 'item-2', + mediaSourceId: undefined, + playMethod: 'Transcode', + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + clearActivePlayback: () => { + cleared = true; + }, + getSession: () => ({ + isConnected: () => true, + reportProgress: async () => {}, + reportStopped: async (payload) => { + stoppedItemId = payload.itemId; + }, + }), + logDebug: () => {}, + }); + + await reportStopped(); + assert.equal(stoppedItemId, 'item-2'); + assert.equal(cleared, true); +}); diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts new file mode 100644 index 0000000..e39ffec --- /dev/null +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -0,0 +1,109 @@ +import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands'; + +type JellyfinRemoteSessionLike = { + isConnected: () => boolean; + reportProgress: (payload: { + itemId: string; + mediaSourceId?: string; + positionTicks: number; + isPaused: boolean; + playMethod: 'DirectPlay' | 'Transcode'; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + eventName: 'timeupdate'; + }) => Promise; + reportStopped: (payload: { + itemId: string; + mediaSourceId?: string; + playMethod: 'DirectPlay' | 'Transcode'; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + eventName: 'stop'; + }) => Promise; +}; + +type MpvClientLike = { + requestProperty: (name: string) => Promise; +}; + +export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number { + if (!Number.isFinite(seconds)) return 0; + return Math.max(0, Math.floor(seconds * ticksPerSecond)); +} + +export type JellyfinRemoteProgressReporterDeps = { + getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; + clearActivePlayback: () => void; + getSession: () => JellyfinRemoteSessionLike | null; + getMpvClient: () => MpvClientLike | null; + getNow: () => number; + getLastProgressAtMs: () => number; + setLastProgressAtMs: (value: number) => void; + progressIntervalMs: number; + ticksPerSecond: number; + logDebug: (message: string, error: unknown) => void; +}; + +export function createReportJellyfinRemoteProgressHandler(deps: JellyfinRemoteProgressReporterDeps) { + return async (force = false): Promise => { + const playback = deps.getActivePlayback(); + if (!playback) return; + const session = deps.getSession(); + if (!session || !session.isConnected()) return; + const now = deps.getNow(); + if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) { + return; + } + try { + const mpvClient = deps.getMpvClient(); + const position = await mpvClient?.requestProperty('time-pos'); + const paused = await mpvClient?.requestProperty('pause'); + await session.reportProgress({ + itemId: playback.itemId, + mediaSourceId: playback.mediaSourceId, + positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond), + isPaused: paused === true, + playMethod: playback.playMethod, + audioStreamIndex: playback.audioStreamIndex, + subtitleStreamIndex: playback.subtitleStreamIndex, + eventName: 'timeupdate', + }); + deps.setLastProgressAtMs(now); + } catch (error) { + deps.logDebug('Failed to report Jellyfin remote progress', error); + } + }; +} + +export type JellyfinRemoteStoppedReporterDeps = { + getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; + clearActivePlayback: () => void; + getSession: () => JellyfinRemoteSessionLike | null; + logDebug: (message: string, error: unknown) => void; +}; + +export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) { + return async (): Promise => { + const playback = deps.getActivePlayback(); + if (!playback) return; + const session = deps.getSession(); + if (!session || !session.isConnected()) { + deps.clearActivePlayback(); + return; + } + try { + await session.reportStopped({ + itemId: playback.itemId, + mediaSourceId: playback.mediaSourceId, + playMethod: playback.playMethod, + audioStreamIndex: playback.audioStreamIndex, + subtitleStreamIndex: playback.subtitleStreamIndex, + eventName: 'stop', + }); + } catch (error) { + deps.logDebug('Failed to report Jellyfin remote stop', error); + } finally { + deps.clearActivePlayback(); + } + }; +} diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts new file mode 100644 index 0000000..ba82d46 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createStartJellyfinRemoteSessionHandler, + createStopJellyfinRemoteSessionHandler, +} from './jellyfin-remote-session-lifecycle'; + +function createConfig(overrides?: Partial>) { + return { + enabled: true, + remoteControlEnabled: true, + remoteControlAutoConnect: true, + serverUrl: 'http://localhost', + accessToken: 'token', + userId: 'user-id', + deviceId: '', + clientName: '', + clientVersion: '', + remoteControlDeviceName: '', + autoAnnounce: false, + ...(overrides || {}), + } as never; +} + +test('start handler no-ops when jellyfin integration is disabled', async () => { + let created = false; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => createConfig({ enabled: false }), + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: () => { + created = true; + return { + start: () => {}, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'default-device', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: () => {}, + logWarn: () => {}, + }); + + await startRemote(); + assert.equal(created, false); +}); + +test('start handler no-ops when remote control is disabled', async () => { + let created = false; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => createConfig({ remoteControlEnabled: false }), + getCurrentSession: () => null, + setCurrentSession: () => {}, + createRemoteSessionService: () => { + created = true; + return { + start: () => {}, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'default-device', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: () => {}, + logWarn: () => {}, + }); + + await startRemote(); + assert.equal(created, false); +}); + +test('start handler creates, starts, and stores session', async () => { + let storedSession: { + start: () => void; + stop: () => void; + advertiseNow: () => Promise; + } | null = null; + let started = false; + const infos: string[] = []; + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => createConfig({ clientName: 'Desk' }), + getCurrentSession: () => null, + setCurrentSession: (session) => { + storedSession = session as never; + }, + createRemoteSessionService: (options) => { + assert.equal(options.deviceName, 'Desk'); + return { + start: () => { + started = true; + }, + stop: () => {}, + advertiseNow: async () => true, + }; + }, + defaultDeviceId: 'default-device', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: (message) => infos.push(message), + logWarn: () => {}, + }); + + await startRemote(); + + assert.equal(started, true); + assert.ok(storedSession); + assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).'))); +}); + +test('start handler stops previous session before replacing', async () => { + let stopCalls = 0; + const oldSession = { + start: () => {}, + stop: () => { + stopCalls += 1; + }, + advertiseNow: async () => true, + }; + let current: typeof oldSession | null = oldSession; + + const startRemote = createStartJellyfinRemoteSessionHandler({ + getJellyfinConfig: () => createConfig(), + getCurrentSession: () => current, + setCurrentSession: (session) => { + current = session as never; + }, + createRemoteSessionService: () => ({ + start: () => {}, + stop: () => {}, + advertiseNow: async () => true, + }), + defaultDeviceId: 'default-device', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0', + handlePlay: async () => {}, + handlePlaystate: async () => {}, + handleGeneralCommand: async () => {}, + logInfo: () => {}, + logWarn: () => {}, + }); + + await startRemote(); + assert.equal(stopCalls, 1); +}); + +test('stop handler stops active session and clears playback', () => { + let stopCalls = 0; + let clearCalls = 0; + let currentSession: { stop: () => void } | null = { + stop: () => { + stopCalls += 1; + }, + }; + + const stopRemote = createStopJellyfinRemoteSessionHandler({ + getCurrentSession: () => currentSession as never, + setCurrentSession: (session) => { + currentSession = session as never; + }, + clearActivePlayback: () => { + clearCalls += 1; + }, + }); + + stopRemote(); + assert.equal(stopCalls, 1); + assert.equal(clearCalls, 1); + assert.equal(currentSession, null); +}); diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.ts new file mode 100644 index 0000000..adc0710 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.ts @@ -0,0 +1,139 @@ +type JellyfinRemoteConfig = { + enabled: boolean; + remoteControlEnabled: boolean; + remoteControlAutoConnect: boolean; + serverUrl: string; + accessToken?: string; + userId?: string; + deviceId: string; + clientName: string; + clientVersion: string; + remoteControlDeviceName: string; + autoAnnounce: boolean; +}; + +type JellyfinRemoteService = { + start: () => void; + stop: () => void; + advertiseNow: () => Promise; +}; + +type JellyfinRemoteEventPayload = unknown; + +type JellyfinRemoteServiceOptions = { + serverUrl: string; + accessToken: string; + deviceId: string; + clientName: string; + clientVersion: string; + deviceName: string; + capabilities: { + PlayableMediaTypes: string; + SupportedCommands: string; + SupportsMediaControl: boolean; + }; + onConnected: () => void; + onDisconnected: () => void; + onPlay: (payload: JellyfinRemoteEventPayload) => void; + onPlaystate: (payload: JellyfinRemoteEventPayload) => void; + onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void; +}; + +export function createStartJellyfinRemoteSessionHandler(deps: { + getJellyfinConfig: () => JellyfinRemoteConfig; + getCurrentSession: () => JellyfinRemoteService | null; + setCurrentSession: (session: JellyfinRemoteService | null) => void; + createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService; + defaultDeviceId: string; + defaultClientName: string; + defaultClientVersion: string; + handlePlay: (payload: JellyfinRemoteEventPayload) => Promise; + handlePlaystate: (payload: JellyfinRemoteEventPayload) => Promise; + handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise; + logInfo: (message: string) => void; + logWarn: (message: string, details?: unknown) => void; +}) { + return async (): Promise => { + const jellyfinConfig = deps.getJellyfinConfig(); + if (jellyfinConfig.enabled === false) return; + if (jellyfinConfig.remoteControlEnabled === false) return; + if (jellyfinConfig.remoteControlAutoConnect === false) return; + if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return; + + const existing = deps.getCurrentSession(); + if (existing) { + existing.stop(); + deps.setCurrentSession(null); + } + + const service = deps.createRemoteSessionService({ + serverUrl: jellyfinConfig.serverUrl, + accessToken: jellyfinConfig.accessToken, + deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId, + clientName: jellyfinConfig.clientName || deps.defaultClientName, + clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion, + deviceName: + jellyfinConfig.remoteControlDeviceName || + jellyfinConfig.clientName || + deps.defaultClientName, + capabilities: { + PlayableMediaTypes: 'Video,Audio', + SupportedCommands: + 'Play,Playstate,PlayMediaSource,SetAudioStreamIndex,SetSubtitleStreamIndex,Mute,Unmute,SetVolume,DisplayContent', + SupportsMediaControl: true, + }, + onConnected: () => { + deps.logInfo('Jellyfin remote websocket connected.'); + if (jellyfinConfig.autoAnnounce) { + void service.advertiseNow().then((registered) => { + if (registered) { + deps.logInfo('Jellyfin cast target is visible to server sessions.'); + } else { + deps.logWarn( + 'Jellyfin remote connected but device not visible in server sessions yet.', + ); + } + }); + } + }, + onDisconnected: () => { + deps.logWarn('Jellyfin remote websocket disconnected; retrying.'); + }, + onPlay: (payload) => { + void deps.handlePlay(payload).catch((error) => { + deps.logWarn('Failed handling Jellyfin remote Play event', error); + }); + }, + onPlaystate: (payload) => { + void deps.handlePlaystate(payload).catch((error) => { + deps.logWarn('Failed handling Jellyfin remote Playstate event', error); + }); + }, + onGeneralCommand: (payload) => { + void deps.handleGeneralCommand(payload).catch((error) => { + deps.logWarn('Failed handling Jellyfin remote GeneralCommand event', error); + }); + }, + }); + + service.start(); + deps.setCurrentSession(service); + deps.logInfo( + `Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`, + ); + }; +} + +export function createStopJellyfinRemoteSessionHandler(deps: { + getCurrentSession: () => JellyfinRemoteService | null; + setCurrentSession: (session: JellyfinRemoteService | null) => void; + clearActivePlayback: () => void; +}) { + return (): void => { + const session = deps.getCurrentSession(); + if (!session) return; + session.stop(); + deps.setCurrentSession(null); + deps.clearActivePlayback(); + }; +} diff --git a/src/main/runtime/jellyfin-remote-session-main-deps.test.ts b/src/main/runtime/jellyfin-remote-session-main-deps.test.ts new file mode 100644 index 0000000..6d5f9b6 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-session-main-deps.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildStartJellyfinRemoteSessionMainDepsHandler, + createBuildStopJellyfinRemoteSessionMainDepsHandler, +} from './jellyfin-remote-session-main-deps'; + +test('start jellyfin remote session main deps builder maps callbacks', async () => { + const calls: string[] = []; + const session = { start: () => {}, stop: () => {}, advertiseNow: async () => true }; + const deps = createBuildStartJellyfinRemoteSessionMainDepsHandler({ + getJellyfinConfig: () => ({ serverUrl: 'http://localhost' }) as never, + getCurrentSession: () => null, + setCurrentSession: () => calls.push('set-session'), + createRemoteSessionService: () => session as never, + defaultDeviceId: 'device', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0', + handlePlay: async () => { + calls.push('play'); + }, + handlePlaystate: async () => { + calls.push('playstate'); + }, + handleGeneralCommand: async () => { + calls.push('general'); + }, + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + })(); + + assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' }); + assert.equal(deps.defaultDeviceId, 'device'); + assert.equal(deps.defaultClientName, 'SubMiner'); + assert.equal(deps.defaultClientVersion, '1.0'); + assert.equal(deps.createRemoteSessionService({} as never), session); + await deps.handlePlay({}); + await deps.handlePlaystate({}); + await deps.handleGeneralCommand({}); + deps.logInfo('connected'); + deps.logWarn('missing'); + assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']); +}); + +test('stop jellyfin remote session main deps builder maps callbacks', () => { + const calls: string[] = []; + const session = { start: () => {}, stop: () => {}, advertiseNow: async () => true }; + const deps = createBuildStopJellyfinRemoteSessionMainDepsHandler({ + getCurrentSession: () => session as never, + setCurrentSession: () => calls.push('set-null'), + clearActivePlayback: () => calls.push('clear'), + })(); + + assert.equal(deps.getCurrentSession(), session); + deps.setCurrentSession(null); + deps.clearActivePlayback(); + assert.deepEqual(calls, ['set-null', 'clear']); +}); diff --git a/src/main/runtime/jellyfin-remote-session-main-deps.ts b/src/main/runtime/jellyfin-remote-session-main-deps.ts new file mode 100644 index 0000000..657f583 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-session-main-deps.ts @@ -0,0 +1,36 @@ +import type { + createStartJellyfinRemoteSessionHandler, + createStopJellyfinRemoteSessionHandler, +} from './jellyfin-remote-session-lifecycle'; + +type StartJellyfinRemoteSessionMainDeps = Parameters[0]; +type StopJellyfinRemoteSessionMainDeps = Parameters[0]; + +export function createBuildStartJellyfinRemoteSessionMainDepsHandler( + deps: StartJellyfinRemoteSessionMainDeps, +) { + return (): StartJellyfinRemoteSessionMainDeps => ({ + getJellyfinConfig: () => deps.getJellyfinConfig(), + getCurrentSession: () => deps.getCurrentSession(), + setCurrentSession: (session) => deps.setCurrentSession(session), + createRemoteSessionService: (options) => deps.createRemoteSessionService(options), + defaultDeviceId: deps.defaultDeviceId, + defaultClientName: deps.defaultClientName, + defaultClientVersion: deps.defaultClientVersion, + handlePlay: (payload) => deps.handlePlay(payload), + handlePlaystate: (payload) => deps.handlePlaystate(payload), + handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload), + logInfo: (message: string) => deps.logInfo(message), + logWarn: (message: string, details?: unknown) => deps.logWarn(message, details), + }); +} + +export function createBuildStopJellyfinRemoteSessionMainDepsHandler( + deps: StopJellyfinRemoteSessionMainDeps, +) { + return (): StopJellyfinRemoteSessionMainDeps => ({ + getCurrentSession: () => deps.getCurrentSession(), + setCurrentSession: (session) => deps.setCurrentSession(session), + clearActivePlayback: () => deps.clearActivePlayback(), + }); +} diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.test.ts b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts new file mode 100644 index 0000000..aa3107e --- /dev/null +++ b/src/main/runtime/jellyfin-setup-window-main-deps.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-setup-window-main-deps'; + +test('open jellyfin setup window main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildOpenJellyfinSetupWindowMainDepsHandler({ + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => ({}) as never, + getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }), + buildSetupFormHtml: () => '', + parseSubmissionUrl: () => ({ server: 's', username: 'u', password: 'p' }), + authenticateWithPassword: async () => ({ + serverUrl: 'http://127.0.0.1:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }), + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'dev' }), + saveStoredSession: () => calls.push('save'), + patchJellyfinConfig: () => calls.push('patch'), + logInfo: (message) => calls.push(`info:${message}`), + logError: (message) => calls.push(`error:${message}`), + showMpvOsd: (message) => calls.push(`osd:${message}`), + clearSetupWindow: () => calls.push('clear'), + setSetupWindow: () => calls.push('set-window'), + encodeURIComponent: (value) => encodeURIComponent(value), + })(); + + assert.equal(deps.maybeFocusExistingSetupWindow(), false); + assert.deepEqual(deps.getResolvedJellyfinConfig(), { + serverUrl: 'http://127.0.0.1:8096', + username: 'alice', + }); + assert.equal(deps.buildSetupFormHtml('a', 'b'), ''); + assert.deepEqual(deps.parseSubmissionUrl('subminer://jellyfin-setup?x=1'), { + server: 's', + username: 'u', + password: 'p', + }); + assert.deepEqual(await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()), { + serverUrl: 'http://127.0.0.1:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }); + deps.saveStoredSession({ accessToken: 'token', userId: 'uid' }); + deps.patchJellyfinConfig({ + serverUrl: 'http://127.0.0.1:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }); + deps.logInfo('ok'); + deps.logError('bad', null); + deps.showMpvOsd('toast'); + deps.clearSetupWindow(); + deps.setSetupWindow({} as never); + assert.equal(deps.encodeURIComponent('a b'), 'a%20b'); + assert.deepEqual(calls, ['save', 'patch', 'info:ok', 'error:bad', 'osd:toast', 'clear', 'set-window']); +}); diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.ts b/src/main/runtime/jellyfin-setup-window-main-deps.ts new file mode 100644 index 0000000..08a2ff1 --- /dev/null +++ b/src/main/runtime/jellyfin-setup-window-main-deps.ts @@ -0,0 +1,27 @@ +import type { createOpenJellyfinSetupWindowHandler } from './jellyfin-setup-window'; + +type OpenJellyfinSetupWindowMainDeps = Parameters[0]; + +export function createBuildOpenJellyfinSetupWindowMainDepsHandler( + deps: OpenJellyfinSetupWindowMainDeps, +) { + return (): OpenJellyfinSetupWindowMainDeps => ({ + maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(), + createSetupWindow: () => deps.createSetupWindow(), + getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), + buildSetupFormHtml: (defaultServer: string, defaultUser: string) => + deps.buildSetupFormHtml(defaultServer, defaultUser), + parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl), + authenticateWithPassword: (server: string, username: string, password: string, clientInfo) => + deps.authenticateWithPassword(server, username, password, clientInfo), + getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), + saveStoredSession: (session) => deps.saveStoredSession(session), + patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), + logInfo: (message: string) => deps.logInfo(message), + logError: (message: string, error: unknown) => deps.logError(message, error), + showMpvOsd: (message: string) => deps.showMpvOsd(message), + clearSetupWindow: () => deps.clearSetupWindow(), + setSetupWindow: (window) => deps.setSetupWindow(window), + encodeURIComponent: (value: string) => deps.encodeURIComponent(value), + }); +} diff --git a/src/main/runtime/jellyfin-setup-window.test.ts b/src/main/runtime/jellyfin-setup-window.test.ts new file mode 100644 index 0000000..b87cfd6 --- /dev/null +++ b/src/main/runtime/jellyfin-setup-window.test.ts @@ -0,0 +1,278 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildJellyfinSetupFormHtml, + createHandleJellyfinSetupWindowClosedHandler, + createHandleJellyfinSetupNavigationHandler, + createHandleJellyfinSetupSubmissionHandler, + createHandleJellyfinSetupWindowOpenedHandler, + createMaybeFocusExistingJellyfinSetupWindowHandler, + createOpenJellyfinSetupWindowHandler, + parseJellyfinSetupSubmissionUrl, +} from './jellyfin-setup-window'; + +test('buildJellyfinSetupFormHtml escapes default values', () => { + const html = buildJellyfinSetupFormHtml('http://host/"x"', 'user"name'); + assert.ok(html.includes('http://host/"x"')); + assert.ok(html.includes('user"name')); + assert.ok(html.includes('subminer://jellyfin-setup?')); +}); + +test('maybe focus jellyfin setup window no-ops without window', () => { + const handler = createMaybeFocusExistingJellyfinSetupWindowHandler({ + getSetupWindow: () => null, + }); + const handled = handler(); + assert.equal(handled, false); +}); + +test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => { + const parsed = parseJellyfinSetupSubmissionUrl( + 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', + ); + assert.deepEqual(parsed, { + server: 'http://localhost', + username: 'a', + password: 'b', + }); + assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null); +}); + +test('createHandleJellyfinSetupSubmissionHandler applies successful login', async () => { + const calls: string[] = []; + let patchPayload: unknown = null; + let savedSession: unknown = null; + const handler = createHandleJellyfinSetupSubmissionHandler({ + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: async () => ({ + serverUrl: 'http://localhost', + username: 'user', + accessToken: 'token', + userId: 'uid', + }), + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredSession: (session) => { + savedSession = session; + calls.push('save'); + }, + patchJellyfinConfig: (session) => { + patchPayload = session; + calls.push('patch'); + }, + logInfo: () => calls.push('info'), + logError: () => calls.push('error'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + closeSetupWindow: () => calls.push('close'), + }); + + const handled = await handler( + 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', + ); + assert.equal(handled, true); + assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'close']); + assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' }); + assert.deepEqual(patchPayload, { + serverUrl: 'http://localhost', + username: 'user', + accessToken: 'token', + userId: 'uid', + }); +}); + +test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async () => { + const calls: string[] = []; + const handler = createHandleJellyfinSetupSubmissionHandler({ + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: async () => { + throw new Error('bad credentials'); + }, + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredSession: () => calls.push('save'), + patchJellyfinConfig: () => calls.push('patch'), + logInfo: () => calls.push('info'), + logError: () => calls.push('error'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + closeSetupWindow: () => calls.push('close'), + }); + + const handled = await handler( + 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b', + ); + assert.equal(handled, true); + assert.deepEqual(calls, ['error', 'osd:Jellyfin login failed: bad credentials']); +}); + +test('createHandleJellyfinSetupNavigationHandler ignores unrelated urls', () => { + const handleNavigation = createHandleJellyfinSetupNavigationHandler({ + setupSchemePrefix: 'subminer://jellyfin-setup', + handleSubmission: async () => {}, + logError: () => {}, + }); + let prevented = false; + const handled = handleNavigation({ + url: 'https://example.com', + preventDefault: () => { + prevented = true; + }, + }); + assert.equal(handled, false); + assert.equal(prevented, false); +}); + +test('createHandleJellyfinSetupNavigationHandler intercepts setup urls', async () => { + const submittedUrls: string[] = []; + const handleNavigation = createHandleJellyfinSetupNavigationHandler({ + setupSchemePrefix: 'subminer://jellyfin-setup', + handleSubmission: async (rawUrl) => { + submittedUrls.push(rawUrl); + }, + logError: () => {}, + }); + let prevented = false; + const handled = handleNavigation({ + url: 'subminer://jellyfin-setup?server=http%3A%2F%2F127.0.0.1%3A8096', + preventDefault: () => { + prevented = true; + }, + }); + await Promise.resolve(); + assert.equal(handled, true); + assert.equal(prevented, true); + assert.equal(submittedUrls.length, 1); +}); + +test('createHandleJellyfinSetupWindowClosedHandler clears setup window ref', () => { + let cleared = false; + const handler = createHandleJellyfinSetupWindowClosedHandler({ + clearSetupWindow: () => { + cleared = true; + }, + }); + handler(); + assert.equal(cleared, true); +}); + +test('createHandleJellyfinSetupWindowOpenedHandler sets setup window ref', () => { + let set = false; + const handler = createHandleJellyfinSetupWindowOpenedHandler({ + setSetupWindow: () => { + set = true; + }, + }); + handler(); + assert.equal(set, true); +}); + +test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is focused', () => { + const calls: string[] = []; + const handler = createOpenJellyfinSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => { + calls.push('focus-existing'); + return true; + }, + createSetupWindow: () => { + calls.push('create-window'); + throw new Error('should not create'); + }, + getResolvedJellyfinConfig: () => ({}), + buildSetupFormHtml: () => '', + parseSubmissionUrl: () => null, + authenticateWithPassword: async () => { + throw new Error('should not auth'); + }, + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredSession: () => {}, + patchJellyfinConfig: () => {}, + logInfo: () => {}, + logError: () => {}, + showMpvOsd: () => {}, + clearSetupWindow: () => {}, + setSetupWindow: () => {}, + encodeURIComponent: (value) => value, + }); + + handler(); + assert.deepEqual(calls, ['focus-existing']); +}); + +test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => { + let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null; + let closedHandler: (() => void) | null = null; + let prevented = false; + const calls: string[] = []; + const fakeWindow = { + focus: () => {}, + webContents: { + on: (event: 'will-navigate', handler: (event: { preventDefault: () => void }, url: string) => void) => { + if (event === 'will-navigate') { + willNavigateHandler = handler; + } + }, + }, + loadURL: (url: string) => { + calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`); + }, + on: (event: 'closed', handler: () => void) => { + if (event === 'closed') { + closedHandler = handler; + } + }, + isDestroyed: () => false, + close: () => calls.push('close'), + }; + + const handler = createOpenJellyfinSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => fakeWindow, + getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }), + buildSetupFormHtml: (server, username) => `${server}|${username}`, + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: async () => ({ + serverUrl: 'http://localhost:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }), + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + saveStoredSession: () => calls.push('save'), + patchJellyfinConfig: () => calls.push('patch'), + logInfo: () => calls.push('info'), + logError: () => calls.push('error'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + clearSetupWindow: () => calls.push('clear-window'), + setSetupWindow: () => calls.push('set-window'), + encodeURIComponent: (value) => encodeURIComponent(value), + }); + + handler(); + assert.ok(willNavigateHandler); + assert.ok(closedHandler); + assert.deepEqual(calls.slice(0, 2), ['load:data-url', 'set-window']); + + const navHandler = willNavigateHandler as ((event: { preventDefault: () => void }, url: string) => void) | null; + if (!navHandler) { + throw new Error('missing will-navigate handler'); + } + navHandler( + { + preventDefault: () => { + prevented = true; + }, + }, + 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=alice&password=pass', + ); + await Promise.resolve(); + + assert.equal(prevented, true); + assert.ok(calls.includes('save')); + assert.ok(calls.includes('patch')); + assert.ok(calls.includes('osd:Jellyfin login success')); + assert.ok(calls.includes('close')); + + const onClosed = closedHandler as (() => void) | null; + if (!onClosed) { + throw new Error('missing closed handler'); + } + onClosed(); + assert.ok(calls.includes('clear-window')); +}); diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts new file mode 100644 index 0000000..568ead4 --- /dev/null +++ b/src/main/runtime/jellyfin-setup-window.ts @@ -0,0 +1,269 @@ +type JellyfinSession = { + serverUrl: string; + username: string; + accessToken: string; + userId: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type FocusableWindowLike = { + focus: () => void; +}; + +type JellyfinSetupWebContentsLike = { + on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void; +}; + +type JellyfinSetupWindowLike = FocusableWindowLike & { + webContents: JellyfinSetupWebContentsLike; + loadURL: (url: string) => unknown; + on: (event: 'closed', handler: () => void) => void; + isDestroyed: () => boolean; + close: () => void; +}; + +function escapeHtmlAttr(value: string): string { + return value.replace(/"/g, '"'); +} + +export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: { + getSetupWindow: () => FocusableWindowLike | null; +}) { + return (): boolean => { + const window = deps.getSetupWindow(); + if (!window) { + return false; + } + window.focus(); + return true; + }; +} + +export function buildJellyfinSetupFormHtml(defaultServer: string, defaultUser: string): string { + return ` + + + + Jellyfin Setup + + + +
+

Jellyfin Setup

+

Login info is used to fetch a token and save Jellyfin config values.

+
+ + + + + + + +
Equivalent CLI: --jellyfin-login --jellyfin-server ... --jellyfin-username ... --jellyfin-password ...
+
+
+ + +`; +} + +export function parseJellyfinSetupSubmissionUrl(rawUrl: string): { + server: string; + username: string; + password: string; +} | null { + if (!rawUrl.startsWith('subminer://jellyfin-setup')) { + return null; + } + const parsed = new URL(rawUrl); + return { + server: parsed.searchParams.get('server') || '', + username: parsed.searchParams.get('username') || '', + password: parsed.searchParams.get('password') || '', + }; +} + +export function createHandleJellyfinSetupSubmissionHandler(deps: { + parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null; + authenticateWithPassword: ( + server: string, + username: string, + password: string, + clientInfo: JellyfinClientInfo, + ) => Promise; + getJellyfinClientInfo: () => JellyfinClientInfo; + saveStoredSession: (session: { accessToken: string; userId: string }) => void; + patchJellyfinConfig: (session: JellyfinSession) => void; + logInfo: (message: string) => void; + logError: (message: string, error: unknown) => void; + showMpvOsd: (message: string) => void; + closeSetupWindow: () => void; +}) { + return async (rawUrl: string): Promise => { + const submission = deps.parseSubmissionUrl(rawUrl); + if (!submission) { + return false; + } + + try { + const session = await deps.authenticateWithPassword( + submission.server, + submission.username, + submission.password, + deps.getJellyfinClientInfo(), + ); + deps.saveStoredSession({ + accessToken: session.accessToken, + userId: session.userId, + }); + deps.patchJellyfinConfig(session); + deps.logInfo(`Jellyfin setup saved for ${session.username}.`); + deps.showMpvOsd('Jellyfin login success'); + deps.closeSetupWindow(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.logError('Jellyfin setup failed', error); + deps.showMpvOsd(`Jellyfin login failed: ${message}`); + } + return true; + }; +} + +export function createHandleJellyfinSetupNavigationHandler(deps: { + setupSchemePrefix: string; + handleSubmission: (rawUrl: string) => Promise; + logError: (message: string, error: unknown) => void; +}) { + return (params: { url: string; preventDefault: () => void }): boolean => { + if (!params.url.startsWith(deps.setupSchemePrefix)) { + return false; + } + params.preventDefault(); + void deps.handleSubmission(params.url).catch((error) => { + deps.logError('Failed handling Jellyfin setup submission', error); + }); + return true; + }; +} + +export function createHandleJellyfinSetupWindowClosedHandler(deps: { + clearSetupWindow: () => void; +}) { + return (): void => { + deps.clearSetupWindow(); + }; +} + +export function createHandleJellyfinSetupWindowOpenedHandler(deps: { + setSetupWindow: () => void; +}) { + return (): void => { + deps.setSetupWindow(); + }; +} + +export function createOpenJellyfinSetupWindowHandler(deps: { + maybeFocusExistingSetupWindow: () => boolean; + createSetupWindow: () => TWindow; + getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null }; + buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string; + parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null; + authenticateWithPassword: ( + server: string, + username: string, + password: string, + clientInfo: JellyfinClientInfo, + ) => Promise; + getJellyfinClientInfo: () => JellyfinClientInfo; + saveStoredSession: (session: { accessToken: string; userId: string }) => void; + patchJellyfinConfig: (session: JellyfinSession) => void; + logInfo: (message: string) => void; + logError: (message: string, error: unknown) => void; + showMpvOsd: (message: string) => void; + clearSetupWindow: () => void; + setSetupWindow: (window: TWindow) => void; + encodeURIComponent: (value: string) => string; +}) { + return (): void => { + if (deps.maybeFocusExistingSetupWindow()) { + return; + } + + const setupWindow = deps.createSetupWindow(); + const defaults = deps.getResolvedJellyfinConfig(); + const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096'; + const defaultUser = defaults.username || ''; + const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser); + const handleSubmission = createHandleJellyfinSetupSubmissionHandler({ + parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl), + authenticateWithPassword: (server, username, password, clientInfo) => + deps.authenticateWithPassword(server, username, password, clientInfo), + getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), + saveStoredSession: (session) => deps.saveStoredSession(session), + patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), + logInfo: (message) => deps.logInfo(message), + logError: (message, error) => deps.logError(message, error), + showMpvOsd: (message) => deps.showMpvOsd(message), + closeSetupWindow: () => { + if (!setupWindow.isDestroyed()) { + setupWindow.close(); + } + }, + }); + const handleNavigation = createHandleJellyfinSetupNavigationHandler({ + setupSchemePrefix: 'subminer://jellyfin-setup', + handleSubmission: (rawUrl) => handleSubmission(rawUrl), + logError: (message, error) => deps.logError(message, error), + }); + const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({ + clearSetupWindow: () => deps.clearSetupWindow(), + }); + const handleWindowOpened = createHandleJellyfinSetupWindowOpenedHandler({ + setSetupWindow: () => deps.setSetupWindow(setupWindow), + }); + + setupWindow.webContents.on('will-navigate', (event, url) => { + handleNavigation({ + url, + preventDefault: () => { + if (event && typeof event === 'object' && 'preventDefault' in event) { + const typedEvent = event as { preventDefault?: () => void }; + typedEvent.preventDefault?.(); + } + }, + }); + }); + void setupWindow.loadURL( + `data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`, + ); + setupWindow.on('closed', () => { + handleWindowClosed(); + }); + handleWindowOpened(); + }; +} diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts new file mode 100644 index 0000000..ce6f8d9 --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler } from './jellyfin-subtitle-preload-main-deps'; + +test('preload jellyfin external subtitles main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler({ + listJellyfinSubtitleTracks: async () => { + calls.push('list'); + return []; + }, + getMpvClient: () => ({ requestProperty: async () => [] }), + sendMpvCommand: () => calls.push('send'), + wait: async () => { + calls.push('wait'); + }, + logDebug: (message) => calls.push(`debug:${message}`), + })(); + + await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'item'); + assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function'); + deps.sendMpvCommand(['set_property', 'sid', 'auto']); + await deps.wait(1); + deps.logDebug('oops', null); + assert.deepEqual(calls, ['list', 'send', 'wait', 'debug:oops']); +}); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts new file mode 100644 index 0000000..ed84df5 --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts @@ -0,0 +1,18 @@ +import type { createPreloadJellyfinExternalSubtitlesHandler } from './jellyfin-subtitle-preload'; + +type PreloadJellyfinExternalSubtitlesMainDeps = Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler +>[0]; + +export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler( + deps: PreloadJellyfinExternalSubtitlesMainDeps, +) { + return (): PreloadJellyfinExternalSubtitlesMainDeps => ({ + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => + deps.listJellyfinSubtitleTracks(session, clientInfo, itemId), + getMpvClient: () => deps.getMpvClient(), + sendMpvCommand: (command) => deps.sendMpvCommand(command), + wait: (ms: number) => deps.wait(ms), + logDebug: (message: string, error: unknown) => deps.logDebug(message, error), + }); +} diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts new file mode 100644 index 0000000..696a917 --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPreloadJellyfinExternalSubtitlesHandler } from './jellyfin-subtitle-preload'; + +const session = { + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', +}; + +const clientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0', + deviceId: 'dev', +}; + +test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => { + const commands: Array> = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, + { index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => [ + { type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true }, + { type: 'sub', id: 6, lang: 'eng', title: 'English', external: true }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + wait: async () => {}, + logDebug: () => {}, + }); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual(commands, [ + ['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'], + ['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'], + ['set_property', 'sid', 5], + ['set_property', 'secondary-sid', 6], + ]); +}); + +test('preload jellyfin subtitles exits quietly when no external tracks', async () => { + const commands: Array> = []; + let waited = false; + const preload = createPreloadJellyfinExternalSubtitlesHandler({ + listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }], + getMpvClient: () => ({ requestProperty: async () => [] }), + sendMpvCommand: (command) => commands.push(command), + wait: async () => { + waited = true; + }, + logDebug: () => {}, + }); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(waited, false); + assert.deepEqual(commands, []); +}); + +test('preload jellyfin subtitles logs debug on failure', async () => { + const logs: string[] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler({ + listJellyfinSubtitleTracks: async () => { + throw new Error('network down'); + }, + getMpvClient: () => null, + sendMpvCommand: () => {}, + wait: async () => {}, + logDebug: (message) => logs.push(message), + }); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual(logs, ['Failed to preload Jellyfin external subtitles']); +}); diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts new file mode 100644 index 0000000..beb2e5f --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -0,0 +1,163 @@ +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type JellyfinSubtitleTrack = { + index: number; + language?: string; + title?: string; + deliveryUrl?: string | null; +}; + +type MpvClientLike = { + requestProperty: (name: string) => Promise; +}; + +function normalizeLang(value: unknown): string { + return String(value || '') + .trim() + .toLowerCase() + .replace(/_/g, '-'); +} + +function isJapanese(value: string): boolean { + const v = normalizeLang(value); + return ( + v === 'ja' || + v === 'jp' || + v === 'jpn' || + v === 'japanese' || + v.startsWith('ja-') || + v.startsWith('jp-') + ); +} + +function isEnglish(value: string): boolean { + const v = normalizeLang(value); + return ( + v === 'en' || + v === 'eng' || + v === 'english' || + v === 'enus' || + v === 'en-us' || + v.startsWith('en-') + ); +} + +function isLikelyHearingImpaired(title: string): boolean { + return /\b(hearing impaired|sdh|closed captions?|cc)\b/i.test(title); +} + +function pickBestTrackId( + tracks: Array<{ + id: number; + lang: string; + title: string; + external: boolean; + }>, + languageMatcher: (value: string) => boolean, + excludeId: number | null = null, +): number | null { + const ranked = tracks + .filter((track) => languageMatcher(track.lang)) + .filter((track) => track.id !== excludeId) + .map((track) => ({ + track, + score: + (track.external ? 100 : 0) + + (isLikelyHearingImpaired(track.title) ? -10 : 10) + + (/\bdefault\b/i.test(track.title) ? 3 : 0), + })) + .sort((a, b) => b.score - a.score); + return ranked[0]?.track.id ?? null; +} + +export function createPreloadJellyfinExternalSubtitlesHandler(deps: { + listJellyfinSubtitleTracks: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + itemId: string, + ) => Promise; + getMpvClient: () => MpvClientLike | null; + sendMpvCommand: (command: Array) => void; + wait: (ms: number) => Promise; + logDebug: (message: string, error: unknown) => void; +}) { + return async (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + itemId: string; + }): Promise => { + try { + const tracks = await deps.listJellyfinSubtitleTracks( + params.session, + params.clientInfo, + params.itemId, + ); + const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl)); + if (externalTracks.length === 0) { + return; + } + + await deps.wait(300); + const seenUrls = new Set(); + for (const track of externalTracks) { + if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) { + continue; + } + seenUrls.add(track.deliveryUrl); + const labelBase = (track.title || track.language || '').trim(); + const label = labelBase || `Jellyfin Subtitle ${track.index}`; + deps.sendMpvCommand([ + 'sub-add', + track.deliveryUrl, + 'cached', + label, + track.language || '', + ]); + } + + await deps.wait(250); + const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list'); + const subtitleTracks = Array.isArray(trackListRaw) + ? trackListRaw + .filter( + (track): track is Record => + Boolean(track) && + typeof track === 'object' && + track.type === 'sub' && + typeof track.id === 'number', + ) + .map((track) => ({ + id: track.id as number, + lang: String(track.lang || ''), + title: String(track.title || ''), + external: track.external === true, + })) + : []; + + const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese); + if (japanesePrimaryId !== null) { + deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); + } else { + deps.sendMpvCommand(['set_property', 'sid', 'no']); + } + + const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId); + if (englishSecondaryId !== null) { + deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); + } + } catch (error) { + deps.logDebug('Failed to preload Jellyfin external subtitles', error); + } + }; +} diff --git a/src/main/runtime/media-runtime-main-deps.test.ts b/src/main/runtime/media-runtime-main-deps.test.ts new file mode 100644 index 0000000..aaeb660 --- /dev/null +++ b/src/main/runtime/media-runtime-main-deps.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildMediaRuntimeMainDepsHandler } from './media-runtime-main-deps'; + +test('media runtime main deps builder maps state and subtitle broadcast channel', () => { + const calls: string[] = []; + let currentPath: string | null = '/tmp/a.mkv'; + let subtitlePosition: unknown = null; + let currentTitle: string | null = 'Title'; + + const deps = createBuildMediaRuntimeMainDepsHandler({ + isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('http'), + loadSubtitlePosition: () => ({ x: 1 }) as never, + getCurrentMediaPath: () => currentPath, + getPendingSubtitlePosition: () => null, + getSubtitlePositionsDir: () => '/tmp/subs', + setCurrentMediaPath: (mediaPath) => { + currentPath = mediaPath; + calls.push(`path:${String(mediaPath)}`); + }, + clearPendingSubtitlePosition: () => calls.push('clear-pending'), + setSubtitlePosition: (position) => { + subtitlePosition = position; + calls.push('set-position'); + }, + broadcastToOverlayWindows: (channel, payload) => + calls.push(`broadcast:${channel}:${JSON.stringify(payload)}`), + getCurrentMediaTitle: () => currentTitle, + setCurrentMediaTitle: (title) => { + currentTitle = title; + calls.push(`title:${String(title)}`); + }, + })(); + + assert.equal(deps.isRemoteMediaPath('http://x'), true); + assert.equal(deps.getCurrentMediaPath(), '/tmp/a.mkv'); + assert.equal(deps.getSubtitlePositionsDir(), '/tmp/subs'); + assert.deepEqual(deps.loadSubtitlePosition(), { x: 1 }); + deps.setCurrentMediaPath('/tmp/b.mkv'); + deps.clearPendingSubtitlePosition(); + deps.setSubtitlePosition({ line: 1 } as never); + deps.broadcastSubtitlePosition({ line: 1 } as never); + deps.setCurrentMediaTitle('Next'); + assert.equal(currentPath, '/tmp/b.mkv'); + assert.deepEqual(subtitlePosition, { line: 1 }); + assert.equal(currentTitle, 'Next'); + assert.deepEqual(calls, [ + 'path:/tmp/b.mkv', + 'clear-pending', + 'set-position', + 'broadcast:subtitle-position:set:{"line":1}', + 'title:Next', + ]); +}); diff --git a/src/main/runtime/media-runtime-main-deps.ts b/src/main/runtime/media-runtime-main-deps.ts new file mode 100644 index 0000000..1d25908 --- /dev/null +++ b/src/main/runtime/media-runtime-main-deps.ts @@ -0,0 +1,30 @@ +import type { SubtitlePosition } from '../../types'; + +export function createBuildMediaRuntimeMainDepsHandler(deps: { + isRemoteMediaPath: (mediaPath: string) => boolean; + loadSubtitlePosition: () => SubtitlePosition | null; + getCurrentMediaPath: () => string | null; + getPendingSubtitlePosition: () => SubtitlePosition | null; + getSubtitlePositionsDir: () => string; + setCurrentMediaPath: (mediaPath: string | null) => void; + clearPendingSubtitlePosition: () => void; + setSubtitlePosition: (position: SubtitlePosition | null) => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getCurrentMediaTitle: () => string | null; + setCurrentMediaTitle: (title: string | null) => void; +}) { + return () => ({ + isRemoteMediaPath: (mediaPath: string) => deps.isRemoteMediaPath(mediaPath), + loadSubtitlePosition: () => deps.loadSubtitlePosition(), + getCurrentMediaPath: () => deps.getCurrentMediaPath(), + getPendingSubtitlePosition: () => deps.getPendingSubtitlePosition(), + getSubtitlePositionsDir: () => deps.getSubtitlePositionsDir(), + setCurrentMediaPath: (nextPath: string | null) => deps.setCurrentMediaPath(nextPath), + clearPendingSubtitlePosition: () => deps.clearPendingSubtitlePosition(), + setSubtitlePosition: (position: SubtitlePosition | null) => deps.setSubtitlePosition(position), + broadcastSubtitlePosition: (position: SubtitlePosition | null) => + deps.broadcastToOverlayWindows('subtitle-position:set', position), + getCurrentMediaTitle: () => deps.getCurrentMediaTitle(), + setCurrentMediaTitle: (title: string | null) => deps.setCurrentMediaTitle(title), + }); +} diff --git a/src/main/runtime/mining-actions-main-deps.test.ts b/src/main/runtime/mining-actions-main-deps.test.ts new file mode 100644 index 0000000..389c3f5 --- /dev/null +++ b/src/main/runtime/mining-actions-main-deps.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildCopyCurrentSubtitleMainDepsHandler, + createBuildHandleMineSentenceDigitMainDepsHandler, + createBuildHandleMultiCopyDigitMainDepsHandler, +} from './mining-actions-main-deps'; + +test('mining action main deps builders map callbacks', () => { + const calls: string[] = []; + + const multiCopy = createBuildHandleMultiCopyDigitMainDepsHandler({ + getSubtitleTimingTracker: () => ({ track: true }), + writeClipboardText: (text) => calls.push(`clip:${text}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + handleMultiCopyDigitCore: () => calls.push('multi-copy'), + })(); + assert.deepEqual(multiCopy.getSubtitleTimingTracker(), { track: true }); + multiCopy.writeClipboardText('x'); + multiCopy.showMpvOsd('y'); + multiCopy.handleMultiCopyDigitCore(2, { + subtitleTimingTracker: { track: true }, + writeClipboardText: () => {}, + showMpvOsd: () => {}, + }); + + const copyCurrent = createBuildCopyCurrentSubtitleMainDepsHandler({ + getSubtitleTimingTracker: () => ({ track: true }), + writeClipboardText: (text) => calls.push(`copy:${text}`), + showMpvOsd: (text) => calls.push(`copy-osd:${text}`), + copyCurrentSubtitleCore: () => calls.push('copy-current'), + })(); + assert.deepEqual(copyCurrent.getSubtitleTimingTracker(), { track: true }); + copyCurrent.writeClipboardText('a'); + copyCurrent.showMpvOsd('b'); + copyCurrent.copyCurrentSubtitleCore({ + subtitleTimingTracker: { track: true }, + writeClipboardText: () => {}, + showMpvOsd: () => {}, + }); + + const mineDigit = createBuildHandleMineSentenceDigitMainDepsHandler({ + getSubtitleTimingTracker: () => ({ track: true }), + getAnkiIntegration: () => ({ enabled: true }), + getCurrentSecondarySubText: () => 'sub', + showMpvOsd: (text) => calls.push(`mine-osd:${text}`), + logError: (message) => calls.push(`err:${message}`), + onCardsMined: (count) => calls.push(`cards:${count}`), + handleMineSentenceDigitCore: () => calls.push('mine-digit'), + })(); + assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub'); + mineDigit.showMpvOsd('done'); + mineDigit.logError('bad', null); + mineDigit.onCardsMined(2); + mineDigit.handleMineSentenceDigitCore(2, { + subtitleTimingTracker: { track: true }, + ankiIntegration: { enabled: true }, + getCurrentSecondarySubText: () => 'sub', + showMpvOsd: () => {}, + logError: () => {}, + onCardsMined: () => {}, + }); + + assert.deepEqual(calls, [ + 'clip:x', + 'osd:y', + 'multi-copy', + 'copy:a', + 'copy-osd:b', + 'copy-current', + 'mine-osd:done', + 'err:bad', + 'cards:2', + 'mine-digit', + ]); +}); diff --git a/src/main/runtime/mining-actions-main-deps.ts b/src/main/runtime/mining-actions-main-deps.ts new file mode 100644 index 0000000..e79fdbd --- /dev/null +++ b/src/main/runtime/mining-actions-main-deps.ts @@ -0,0 +1,92 @@ +export function createBuildHandleMultiCopyDigitMainDepsHandler(deps: { + getSubtitleTimingTracker: () => TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + handleMultiCopyDigitCore: ( + count: number, + options: { + subtitleTimingTracker: TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }, + ) => void; +}) { + return () => ({ + getSubtitleTimingTracker: () => deps.getSubtitleTimingTracker(), + writeClipboardText: (text: string) => deps.writeClipboardText(text), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + handleMultiCopyDigitCore: ( + count: number, + options: { + subtitleTimingTracker: TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }, + ) => deps.handleMultiCopyDigitCore(count, options), + }); +} + +export function createBuildCopyCurrentSubtitleMainDepsHandler(deps: { + getSubtitleTimingTracker: () => TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + copyCurrentSubtitleCore: (options: { + subtitleTimingTracker: TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }) => void; +}) { + return () => ({ + getSubtitleTimingTracker: () => deps.getSubtitleTimingTracker(), + writeClipboardText: (text: string) => deps.writeClipboardText(text), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + copyCurrentSubtitleCore: (options: { + subtitleTimingTracker: TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }) => deps.copyCurrentSubtitleCore(options), + }); +} + +export function createBuildHandleMineSentenceDigitMainDepsHandler< + TSubtitleTimingTracker, + TAnkiIntegration, +>(deps: { + getSubtitleTimingTracker: () => TSubtitleTimingTracker; + getAnkiIntegration: () => TAnkiIntegration; + getCurrentSecondarySubText: () => string | undefined; + showMpvOsd: (text: string) => void; + logError: (message: string, err: unknown) => void; + onCardsMined: (count: number) => void; + handleMineSentenceDigitCore: ( + count: number, + options: { + subtitleTimingTracker: TSubtitleTimingTracker; + ankiIntegration: TAnkiIntegration; + getCurrentSecondarySubText: () => string | undefined; + showMpvOsd: (text: string) => void; + logError: (message: string, err: unknown) => void; + onCardsMined: (count: number) => void; + }, + ) => void; +}) { + return () => ({ + getSubtitleTimingTracker: () => deps.getSubtitleTimingTracker(), + getAnkiIntegration: () => deps.getAnkiIntegration(), + getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + logError: (message: string, err: unknown) => deps.logError(message, err), + onCardsMined: (count: number) => deps.onCardsMined(count), + handleMineSentenceDigitCore: ( + count: number, + options: { + subtitleTimingTracker: TSubtitleTimingTracker; + ankiIntegration: TAnkiIntegration; + getCurrentSecondarySubText: () => string | undefined; + showMpvOsd: (text: string) => void; + logError: (message: string, err: unknown) => void; + onCardsMined: (count: number) => void; + }, + ) => deps.handleMineSentenceDigitCore(count, options), + }); +} diff --git a/src/main/runtime/mining-actions.test.ts b/src/main/runtime/mining-actions.test.ts new file mode 100644 index 0000000..9b38884 --- /dev/null +++ b/src/main/runtime/mining-actions.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createCopyCurrentSubtitleHandler, + createHandleMineSentenceDigitHandler, + createHandleMultiCopyDigitHandler, +} from './mining-actions'; + +test('multi-copy digit handler forwards tracker/clipboard/osd deps', () => { + const calls: string[] = []; + const tracker = {}; + const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({ + getSubtitleTimingTracker: () => tracker, + writeClipboardText: (text) => calls.push(`clipboard:${text}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + handleMultiCopyDigitCore: (count, options) => { + assert.equal(count, 3); + assert.equal(options.subtitleTimingTracker, tracker); + options.writeClipboardText('copied'); + options.showMpvOsd('done'); + }, + }); + + handleMultiCopyDigit(3); + assert.deepEqual(calls, ['clipboard:copied', 'osd:done']); +}); + +test('copy current subtitle handler forwards tracker/clipboard/osd deps', () => { + const calls: string[] = []; + const tracker = {}; + const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({ + getSubtitleTimingTracker: () => tracker, + writeClipboardText: (text) => calls.push(`clipboard:${text}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + copyCurrentSubtitleCore: (options) => { + assert.equal(options.subtitleTimingTracker, tracker); + options.writeClipboardText('subtitle'); + options.showMpvOsd('copied'); + }, + }); + + copyCurrentSubtitle(); + assert.deepEqual(calls, ['clipboard:subtitle', 'osd:copied']); +}); + +test('mine sentence digit handler forwards all dependencies', () => { + const calls: string[] = []; + const tracker = {}; + const integration = {}; + const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({ + getSubtitleTimingTracker: () => tracker, + getAnkiIntegration: () => integration, + getCurrentSecondarySubText: () => 'secondary', + showMpvOsd: (text) => calls.push(`osd:${text}`), + logError: (message) => calls.push(`err:${message}`), + onCardsMined: (count) => calls.push(`cards:${count}`), + handleMineSentenceDigitCore: (count, options) => { + assert.equal(count, 4); + assert.equal(options.subtitleTimingTracker, tracker); + assert.equal(options.ankiIntegration, integration); + assert.equal(options.getCurrentSecondarySubText(), 'secondary'); + options.showMpvOsd('mine'); + options.logError('boom', new Error('x')); + options.onCardsMined(2); + }, + }); + + handleMineSentenceDigit(4); + assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']); +}); diff --git a/src/main/runtime/mining-actions.ts b/src/main/runtime/mining-actions.ts new file mode 100644 index 0000000..7e0c4c4 --- /dev/null +++ b/src/main/runtime/mining-actions.ts @@ -0,0 +1,73 @@ +export function createHandleMultiCopyDigitHandler(deps: { + getSubtitleTimingTracker: () => TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + handleMultiCopyDigitCore: ( + count: number, + options: { + subtitleTimingTracker: TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }, + ) => void; +}) { + return (count: number): void => { + deps.handleMultiCopyDigitCore(count, { + subtitleTimingTracker: deps.getSubtitleTimingTracker(), + writeClipboardText: deps.writeClipboardText, + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createCopyCurrentSubtitleHandler(deps: { + getSubtitleTimingTracker: () => TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + copyCurrentSubtitleCore: (options: { + subtitleTimingTracker: TSubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }) => void; +}) { + return (): void => { + deps.copyCurrentSubtitleCore({ + subtitleTimingTracker: deps.getSubtitleTimingTracker(), + writeClipboardText: deps.writeClipboardText, + showMpvOsd: deps.showMpvOsd, + }); + }; +} + +export function createHandleMineSentenceDigitHandler( + deps: { + getSubtitleTimingTracker: () => TSubtitleTimingTracker; + getAnkiIntegration: () => TAnkiIntegration; + getCurrentSecondarySubText: () => string | undefined; + showMpvOsd: (text: string) => void; + logError: (message: string, err: unknown) => void; + onCardsMined: (count: number) => void; + handleMineSentenceDigitCore: ( + count: number, + options: { + subtitleTimingTracker: TSubtitleTimingTracker; + ankiIntegration: TAnkiIntegration; + getCurrentSecondarySubText: () => string | undefined; + showMpvOsd: (text: string) => void; + logError: (message: string, err: unknown) => void; + onCardsMined: (count: number) => void; + }, + ) => void; + }, +) { + return (count: number): void => { + deps.handleMineSentenceDigitCore(count, { + subtitleTimingTracker: deps.getSubtitleTimingTracker(), + ankiIntegration: deps.getAnkiIntegration(), + getCurrentSecondarySubText: deps.getCurrentSecondarySubText, + showMpvOsd: deps.showMpvOsd, + logError: deps.logError, + onCardsMined: deps.onCardsMined, + }); + }; +} diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts new file mode 100644 index 0000000..16655ab --- /dev/null +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createBindMpvClientEventHandlers, + createHandleMpvConnectionChangeHandler, + createHandleMpvSubtitleTimingHandler, +} from './mpv-client-event-bindings'; + +test('mpv connection handler reports stop and quits when disconnect guard passes', () => { + const calls: string[] = []; + const handler = createHandleMpvConnectionChangeHandler({ + reportJellyfinRemoteStopped: () => calls.push('report-stop'), + refreshDiscordPresence: () => calls.push('presence-refresh'), + hasInitialJellyfinPlayArg: () => true, + isOverlayRuntimeInitialized: () => false, + isQuitOnDisconnectArmed: () => true, + scheduleQuitCheck: (callback) => { + calls.push('schedule'); + callback(); + }, + isMpvConnected: () => false, + quitApp: () => calls.push('quit'), + }); + + handler({ connected: false }); + assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']); +}); + +test('mpv subtitle timing handler ignores blank subtitle lines', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleTimingHandler({ + recordImmersionSubtitleLine: () => calls.push('immersion'), + hasSubtitleTimingTracker: () => true, + recordSubtitleTiming: () => calls.push('timing'), + maybeRunAnilistPostWatchUpdate: async () => { + calls.push('post-watch'); + }, + logError: () => calls.push('error'), + }); + + handler({ text: ' ', start: 1, end: 2 }); + assert.deepEqual(calls, []); +}); + +test('mpv event bindings register all expected events', () => { + const seenEvents: string[] = []; + const bindHandlers = createBindMpvClientEventHandlers({ + onConnectionChange: () => {}, + onSubtitleChange: () => {}, + onSubtitleAssChange: () => {}, + onSecondarySubtitleChange: () => {}, + onSubtitleTiming: () => {}, + onMediaPathChange: () => {}, + onMediaTitleChange: () => {}, + onTimePosChange: () => {}, + onPauseChange: () => {}, + onSubtitleMetricsChange: () => {}, + onSecondarySubtitleVisibility: () => {}, + }); + + bindHandlers({ + on: (event) => { + seenEvents.push(event); + }, + }); + + assert.deepEqual(seenEvents, [ + 'connection-change', + 'subtitle-change', + 'subtitle-ass-change', + 'secondary-subtitle-change', + 'subtitle-timing', + 'media-path-change', + 'media-title-change', + 'time-pos-change', + 'pause-change', + 'subtitle-metrics-change', + 'secondary-subtitle-visibility', + ]); +}); diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts new file mode 100644 index 0000000..98d016b --- /dev/null +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -0,0 +1,86 @@ +type MpvBindingEventName = + | 'connection-change' + | 'subtitle-change' + | 'subtitle-ass-change' + | 'secondary-subtitle-change' + | 'subtitle-timing' + | 'media-path-change' + | 'media-title-change' + | 'time-pos-change' + | 'pause-change' + | 'subtitle-metrics-change' + | 'secondary-subtitle-visibility'; + +type MpvEventClient = { + on: (event: K, handler: (payload: any) => void) => void; +}; + +export function createHandleMpvConnectionChangeHandler(deps: { + reportJellyfinRemoteStopped: () => void; + refreshDiscordPresence: () => void; + hasInitialJellyfinPlayArg: () => boolean; + isOverlayRuntimeInitialized: () => boolean; + isQuitOnDisconnectArmed: () => boolean; + scheduleQuitCheck: (callback: () => void) => void; + isMpvConnected: () => boolean; + quitApp: () => void; +}) { + return ({ connected }: { connected: boolean }): void => { + deps.refreshDiscordPresence(); + if (connected) return; + deps.reportJellyfinRemoteStopped(); + if (!deps.hasInitialJellyfinPlayArg()) return; + if (deps.isOverlayRuntimeInitialized()) return; + if (!deps.isQuitOnDisconnectArmed()) return; + deps.scheduleQuitCheck(() => { + if (deps.isMpvConnected()) return; + deps.quitApp(); + }); + }; +} + +export function createHandleMpvSubtitleTimingHandler(deps: { + recordImmersionSubtitleLine: (text: string, start: number, end: number) => void; + hasSubtitleTimingTracker: () => boolean; + recordSubtitleTiming: (text: string, start: number, end: number) => void; + maybeRunAnilistPostWatchUpdate: () => Promise; + logError: (message: string, error: unknown) => void; +}) { + return ({ text, start, end }: { text: string; start: number; end: number }): void => { + if (!text.trim()) return; + deps.recordImmersionSubtitleLine(text, start, end); + if (!deps.hasSubtitleTimingTracker()) return; + deps.recordSubtitleTiming(text, start, end); + void deps.maybeRunAnilistPostWatchUpdate().catch((error) => { + deps.logError('AniList post-watch update failed unexpectedly', error); + }); + }; +} + +export function createBindMpvClientEventHandlers(deps: { + onConnectionChange: (payload: { connected: boolean }) => void; + onSubtitleChange: (payload: { text: string }) => void; + onSubtitleAssChange: (payload: { text: string }) => void; + onSecondarySubtitleChange: (payload: { text: string }) => void; + onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; + onMediaPathChange: (payload: { path: string }) => void; + onMediaTitleChange: (payload: { title: string }) => void; + onTimePosChange: (payload: { time: number }) => void; + onPauseChange: (payload: { paused: boolean }) => void; + onSubtitleMetricsChange: (payload: { patch: Record }) => void; + onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; +}) { + return (mpvClient: MpvEventClient): void => { + mpvClient.on('connection-change', deps.onConnectionChange); + mpvClient.on('subtitle-change', deps.onSubtitleChange); + mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange); + mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange); + mpvClient.on('subtitle-timing', deps.onSubtitleTiming); + mpvClient.on('media-path-change', deps.onMediaPathChange); + mpvClient.on('media-title-change', deps.onMediaTitleChange); + mpvClient.on('time-pos-change', deps.onTimePosChange); + mpvClient.on('pause-change', deps.onPauseChange); + mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange); + mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility); + }; +} diff --git a/src/main/runtime/mpv-client-runtime-service-main-deps.test.ts b/src/main/runtime/mpv-client-runtime-service-main-deps.test.ts new file mode 100644 index 0000000..351dd8f --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service-main-deps.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './mpv-client-runtime-service-main-deps'; + +test('mpv runtime service main deps builder maps state and callbacks', () => { + const calls: string[] = []; + let reconnectTimer: ReturnType | null = null; + + class FakeClient { + constructor(public socketPath: string, public options: unknown) {} + } + + const build = createBuildMpvClientRuntimeServiceFactoryDepsHandler({ + createClient: FakeClient, + getSocketPath: () => '/tmp/mpv.sock', + getResolvedConfig: () => ({ mode: 'test' }), + isAutoStartOverlayEnabled: () => true, + setOverlayVisible: (visible) => calls.push(`overlay:${visible}`), + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => reconnectTimer, + setReconnectTimer: (timer) => { + reconnectTimer = timer; + calls.push('set-reconnect'); + }, + bindEventHandlers: () => calls.push('bind'), + }); + + const deps = build(); + assert.equal(deps.socketPath, '/tmp/mpv.sock'); + assert.equal(deps.options.autoStartOverlay, true); + assert.equal(deps.options.shouldBindVisibleOverlayToMpvSubVisibility(), true); + assert.equal(deps.options.isVisibleOverlayVisible(), false); + assert.deepEqual(deps.options.getResolvedConfig(), { mode: 'test' }); + + deps.options.setOverlayVisible(true); + deps.options.setReconnectTimer(setTimeout(() => {}, 0)); + deps.bindEventHandlers(new FakeClient('/tmp/mpv.sock', {})); + + assert.ok(calls.includes('overlay:true')); + assert.ok(calls.includes('set-reconnect')); + assert.ok(calls.includes('bind')); + assert.ok(reconnectTimer); +}); diff --git a/src/main/runtime/mpv-client-runtime-service-main-deps.ts b/src/main/runtime/mpv-client-runtime-service-main-deps.ts new file mode 100644 index 0000000..f9831f7 --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service-main-deps.ts @@ -0,0 +1,32 @@ +export function createBuildMpvClientRuntimeServiceFactoryDepsHandler< + TClient, + TResolvedConfig, + TOptions, +>(deps: { + createClient: new (socketPath: string, options: TOptions) => TClient; + getSocketPath: () => string; + getResolvedConfig: () => TResolvedConfig; + isAutoStartOverlayEnabled: () => boolean; + setOverlayVisible: (visible: boolean) => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isVisibleOverlayVisible: () => boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; + bindEventHandlers: (client: TClient) => void; +}) { + return () => ({ + createClient: deps.createClient, + socketPath: deps.getSocketPath(), + options: { + getResolvedConfig: () => deps.getResolvedConfig(), + autoStartOverlay: deps.isAutoStartOverlayEnabled(), + setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible), + shouldBindVisibleOverlayToMpvSubVisibility: () => + deps.shouldBindVisibleOverlayToMpvSubVisibility(), + isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(), + getReconnectTimer: () => deps.getReconnectTimer(), + setReconnectTimer: (timer: ReturnType | null) => deps.setReconnectTimer(timer), + }, + bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client), + }); +} diff --git a/src/main/runtime/mpv-client-runtime-service.test.ts b/src/main/runtime/mpv-client-runtime-service.test.ts new file mode 100644 index 0000000..643338d --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service.test.ts @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMpvClientRuntimeServiceFactory } from './mpv-client-runtime-service'; + +test('mpv runtime service factory constructs client, binds handlers, and connects', () => { + const calls: string[] = []; + let constructedSocketPath = ''; + + class FakeClient { + connect(): void { + calls.push('connect'); + } + constructor(socketPath: string) { + constructedSocketPath = socketPath; + calls.push('construct'); + } + } + + const createRuntimeService = createMpvClientRuntimeServiceFactory({ + createClient: FakeClient, + socketPath: '/tmp/mpv.sock', + options: { + getResolvedConfig: () => ({}), + autoStartOverlay: true, + setOverlayVisible: () => {}, + shouldBindVisibleOverlayToMpvSubVisibility: () => false, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + }, + bindEventHandlers: () => { + calls.push('bind'); + }, + }); + + const client = createRuntimeService(); + assert.ok(client instanceof FakeClient); + assert.equal(constructedSocketPath, '/tmp/mpv.sock'); + assert.deepEqual(calls, ['construct', 'bind', 'connect']); +}); diff --git a/src/main/runtime/mpv-client-runtime-service.ts b/src/main/runtime/mpv-client-runtime-service.ts new file mode 100644 index 0000000..3f6056f --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service.ts @@ -0,0 +1,37 @@ +import type { Config } from '../../types'; + +export type MpvClientRuntimeServiceOptions = { + getResolvedConfig: () => Config; + autoStartOverlay: boolean; + setOverlayVisible: (visible: boolean) => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isVisibleOverlayVisible: () => boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; +}; + +type MpvClientLike = { + connect: () => void; +}; + +type MpvClientCtor< + TClient extends MpvClientLike, + TOptions extends MpvClientRuntimeServiceOptions, +> = new (socketPath: string, options: TOptions) => TClient; + +export function createMpvClientRuntimeServiceFactory< + TClient extends MpvClientLike, + TOptions extends MpvClientRuntimeServiceOptions, +>(deps: { + createClient: MpvClientCtor; + socketPath: string; + options: TOptions; + bindEventHandlers: (client: TClient) => void; +}) { + return (): TClient => { + const mpvClient = new deps.createClient(deps.socketPath, deps.options); + deps.bindEventHandlers(mpvClient); + mpvClient.connect(); + return mpvClient; + }; +} diff --git a/src/main/runtime/mpv-hover-highlight.test.ts b/src/main/runtime/mpv-hover-highlight.test.ts new file mode 100644 index 0000000..45a1fa3 --- /dev/null +++ b/src/main/runtime/mpv-hover-highlight.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PartOfSpeech, type SubtitleData } from '../../types'; +import { + HOVER_TOKEN_MESSAGE, + HOVER_SCRIPT_NAME, + buildHoveredTokenMessageCommand, + buildHoveredTokenPayload, + createApplyHoveredTokenOverlayHandler, +} from './mpv-hover-highlight'; + +const SUBTITLE: SubtitleData = { + text: '昨日は雨だった。', + tokens: [ + { + surface: '昨日', + reading: 'きのう', + headword: '昨日', + startPos: 0, + endPos: 2, + partOfSpeech: PartOfSpeech.noun, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + surface: 'は', + reading: 'は', + headword: 'は', + startPos: 2, + endPos: 3, + partOfSpeech: PartOfSpeech.particle, + isMerged: false, + isKnown: true, + isNPlusOneTarget: false, + }, + { + surface: '雨', + reading: 'あめ', + headword: '雨', + startPos: 3, + endPos: 4, + partOfSpeech: PartOfSpeech.noun, + isMerged: false, + isKnown: false, + isNPlusOneTarget: true, + }, + { + surface: 'だった。', + reading: 'だった。', + headword: 'だ', + startPos: 4, + endPos: 8, + partOfSpeech: PartOfSpeech.other, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], +}; + +test('buildHoveredTokenPayload normalizes metadata and strips empty tokens', () => { + const payload = buildHoveredTokenPayload({ + subtitle: SUBTITLE, + hoveredTokenIndex: 2, + revision: 5, + }); + + assert.equal(payload.revision, 5); + assert.equal(payload.subtitle, '昨日は雨だった。'); + assert.equal(payload.hoveredTokenIndex, 2); + assert.equal(payload.tokens.length, 4); + assert.equal(payload.tokens[0]?.text, '昨日'); + assert.equal(payload.tokens[0]?.index, 0); + assert.equal(payload.tokens[1]?.index, 1); + assert.equal(payload.colors.hover, 'C6A0F6'); +}); + +test('buildHoveredTokenPayload normalizes hover color override', () => { + const payload = buildHoveredTokenPayload({ + subtitle: SUBTITLE, + hoveredTokenIndex: 1, + revision: 7, + hoverColor: '#c6a0f6', + }); + + assert.equal(payload.colors.hover, 'C6A0F6'); +}); + +test('buildHoveredTokenMessageCommand sends script-message-to subminer payload', () => { + const payload = buildHoveredTokenPayload({ + subtitle: SUBTITLE, + hoveredTokenIndex: 0, + revision: 1, + }); + + const command = buildHoveredTokenMessageCommand(payload); + + assert.equal(command[0], 'script-message-to'); + assert.equal(command[1], HOVER_SCRIPT_NAME); + assert.equal(command[2], HOVER_TOKEN_MESSAGE); + + const raw = command[3] as string; + const parsed = JSON.parse(raw); + assert.equal(parsed.revision, 1); + assert.equal(parsed.hoveredTokenIndex, 0); + assert.equal(parsed.subtitle, '昨日は雨だった。'); + assert.equal(parsed.tokens.length, 4); +}); + +test('createApplyHoveredTokenOverlayHandler sends clear payload when hovered token is missing', () => { + const commands: Array<(string | number)[]> = []; + const apply = createApplyHoveredTokenOverlayHandler({ + getMpvClient: () => ({ + connected: true, + send: ({ command }: { command: (string | number)[] }) => { + commands.push(command); + return true; + }, + }), + getCurrentSubtitleData: () => SUBTITLE, + getHoveredTokenIndex: () => null, + getHoveredSubtitleRevision: () => 3, + getHoverTokenColor: () => null, + }); + + apply(); + + const parsed = JSON.parse(commands[0]?.[3] as string); + assert.equal(parsed.hoveredTokenIndex, null); + assert.equal(parsed.subtitle, null); + assert.equal(parsed.tokens.length, 0); +}); + +test('createApplyHoveredTokenOverlayHandler sends highlight payload when hover is active', () => { + const commands: Array<(string | number)[]> = []; + const apply = createApplyHoveredTokenOverlayHandler({ + getMpvClient: () => ({ + connected: true, + send: ({ command }: { command: (string | number)[] }) => { + commands.push(command); + return true; + }, + }), + getCurrentSubtitleData: () => SUBTITLE, + getHoveredTokenIndex: () => 0, + getHoveredSubtitleRevision: () => 3, + getHoverTokenColor: () => '#c6a0f6', + }); + + apply(); + + const parsed = JSON.parse(commands[0]?.[3] as string); + assert.equal(parsed.hoveredTokenIndex, 0); + assert.equal(parsed.subtitle, '昨日は雨だった。'); + assert.equal(parsed.tokens.length, 4); + assert.equal(parsed.colors.hover, 'C6A0F6'); + assert.equal(commands[0]?.[0], 'script-message-to'); + assert.equal(commands[0]?.[1], HOVER_SCRIPT_NAME); +}); diff --git a/src/main/runtime/mpv-hover-highlight.ts b/src/main/runtime/mpv-hover-highlight.ts new file mode 100644 index 0000000..1932bfa --- /dev/null +++ b/src/main/runtime/mpv-hover-highlight.ts @@ -0,0 +1,138 @@ +import type { SubtitleData } from '../../types'; + +export const HOVER_SCRIPT_NAME = 'subminer'; +export const HOVER_TOKEN_MESSAGE = 'subminer-hover-token'; + +const DEFAULT_HOVER_TOKEN_COLOR = 'C6A0F6'; +const DEFAULT_TOKEN_COLOR = 'FFFFFF'; + +export type HoverPayloadToken = { + text: string; + index: number; + startPos: number | null; + endPos: number | null; +}; + +export type HoverTokenPayload = { + revision: number; + subtitle: string | null; + hoveredTokenIndex: number | null; + tokens: HoverPayloadToken[]; + colors: { + base: string; + hover: string; + }; +}; + +type HoverTokenInput = { + subtitle: SubtitleData | null; + hoveredTokenIndex: number | null; + revision: number; + hoverColor?: string | null; +}; + +function normalizeHexColor(color: string | null | undefined, fallback: string): string { + if (typeof color !== 'string') { + return fallback; + } + const normalized = color.trim().replace(/^#/, '').toUpperCase(); + return /^[0-9A-F]{6}$/.test(normalized) ? normalized : fallback; +} + +function sanitizeSubtitleText(text: string): string { + return text + .replace(/\\N/g, '\n') + .replace(/\\n/g, '\n') + .replace(/\{[^}]*\}/g, '') + .trim(); +} + +function sanitizeTokenSurface(surface: unknown): string { + return typeof surface === 'string' ? surface : ''; +} + +function hasHoveredToken(subtitle: SubtitleData | null, hoveredTokenIndex: number | null): boolean { + if (!subtitle || hoveredTokenIndex === null || hoveredTokenIndex < 0) { + return false; + } + + return subtitle.tokens?.some((token, index) => index === hoveredTokenIndex) ?? false; +} + +export function buildHoveredTokenPayload(input: HoverTokenInput): HoverTokenPayload { + const { subtitle, hoveredTokenIndex, revision, hoverColor } = input; + + const tokens: HoverPayloadToken[] = []; + + if (subtitle?.tokens && subtitle.tokens.length > 0) { + for (let tokenIndex = 0; tokenIndex < subtitle.tokens.length; tokenIndex += 1) { + const token = subtitle.tokens[tokenIndex]; + if (!token) { + continue; + } + const surface = sanitizeTokenSurface(token?.surface); + if (!surface || surface.trim().length === 0) { + continue; + } + + tokens.push({ + text: surface, + index: tokenIndex, + startPos: Number.isFinite(token.startPos) ? token.startPos : null, + endPos: Number.isFinite(token.endPos) ? token.endPos : null, + }); + } + } + + return { + revision, + subtitle: subtitle ? sanitizeSubtitleText(subtitle.text) : null, + hoveredTokenIndex: + hoveredTokenIndex !== null && hoveredTokenIndex >= 0 ? hoveredTokenIndex : null, + tokens, + colors: { + base: DEFAULT_TOKEN_COLOR, + hover: normalizeHexColor(hoverColor, DEFAULT_HOVER_TOKEN_COLOR), + }, + }; +} + +export function buildHoveredTokenMessageCommand(payload: HoverTokenPayload): (string | number)[] { + return [ + 'script-message-to', + HOVER_SCRIPT_NAME, + HOVER_TOKEN_MESSAGE, + JSON.stringify(payload), + ]; +} + +export function createApplyHoveredTokenOverlayHandler(deps: { + getMpvClient: () => { + connected: boolean; + send: (payload: { command: (string | number)[] }) => boolean; + } | null; + getCurrentSubtitleData: () => SubtitleData | null; + getHoveredTokenIndex: () => number | null; + getHoveredSubtitleRevision: () => number; + getHoverTokenColor: () => string | null; +}) { + return (): void => { + const mpvClient = deps.getMpvClient(); + if (!mpvClient || !mpvClient.connected) { + return; + } + + const subtitle = deps.getCurrentSubtitleData(); + const hoveredTokenIndex = deps.getHoveredTokenIndex(); + const revision = deps.getHoveredSubtitleRevision(); + const hoverColor = deps.getHoverTokenColor(); + const payload = buildHoveredTokenPayload({ + subtitle: subtitle && hasHoveredToken(subtitle, hoveredTokenIndex) ? subtitle : null, + hoveredTokenIndex: hoveredTokenIndex, + revision, + hoverColor, + }); + + mpvClient.send({ command: buildHoveredTokenMessageCommand(payload) }); + }; +} diff --git a/src/main/runtime/mpv-jellyfin-defaults-main-deps.test.ts b/src/main/runtime/mpv-jellyfin-defaults-main-deps.test.ts new file mode 100644 index 0000000..cb2d36c --- /dev/null +++ b/src/main/runtime/mpv-jellyfin-defaults-main-deps.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildApplyJellyfinMpvDefaultsMainDepsHandler, + createBuildGetDefaultSocketPathMainDepsHandler, +} from './mpv-jellyfin-defaults-main-deps'; + +test('apply jellyfin mpv defaults main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildApplyJellyfinMpvDefaultsMainDepsHandler({ + sendMpvCommandRuntime: (_client, command) => calls.push(command.join(':')), + jellyfinLangPref: 'ja,jp', + })(); + + deps.sendMpvCommandRuntime({ connected: true, send: () => {} }, ['set_property', 'aid', 'auto']); + assert.equal(deps.jellyfinLangPref, 'ja,jp'); + assert.deepEqual(calls, ['set_property:aid:auto']); +}); + +test('get default socket path main deps builder maps platform', () => { + const deps = createBuildGetDefaultSocketPathMainDepsHandler({ + platform: 'darwin', + })(); + assert.equal(deps.platform, 'darwin'); +}); diff --git a/src/main/runtime/mpv-jellyfin-defaults-main-deps.ts b/src/main/runtime/mpv-jellyfin-defaults-main-deps.ts new file mode 100644 index 0000000..f79624e --- /dev/null +++ b/src/main/runtime/mpv-jellyfin-defaults-main-deps.ts @@ -0,0 +1,24 @@ +import type { + createApplyJellyfinMpvDefaultsHandler, + createGetDefaultSocketPathHandler, +} from './mpv-jellyfin-defaults'; + +type ApplyJellyfinMpvDefaultsMainDeps = Parameters[0]; +type GetDefaultSocketPathMainDeps = Parameters[0]; + +export function createBuildApplyJellyfinMpvDefaultsMainDepsHandler( + deps: ApplyJellyfinMpvDefaultsMainDeps, +) { + return (): ApplyJellyfinMpvDefaultsMainDeps => ({ + sendMpvCommandRuntime: (client, command) => deps.sendMpvCommandRuntime(client, command), + jellyfinLangPref: deps.jellyfinLangPref, + }); +} + +export function createBuildGetDefaultSocketPathMainDepsHandler( + deps: GetDefaultSocketPathMainDeps, +) { + return (): GetDefaultSocketPathMainDeps => ({ + platform: deps.platform, + }); +} diff --git a/src/main/runtime/mpv-jellyfin-defaults.test.ts b/src/main/runtime/mpv-jellyfin-defaults.test.ts new file mode 100644 index 0000000..6f2ea8e --- /dev/null +++ b/src/main/runtime/mpv-jellyfin-defaults.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createApplyJellyfinMpvDefaultsHandler, + createGetDefaultSocketPathHandler, +} from './mpv-jellyfin-defaults'; + +test('apply jellyfin mpv defaults sends expected property commands', () => { + const calls: string[] = []; + const applyDefaults = createApplyJellyfinMpvDefaultsHandler({ + sendMpvCommandRuntime: (_client, command) => calls.push(command.join(':')), + jellyfinLangPref: 'ja,jp', + }); + + applyDefaults({ connected: true, send: () => {} }); + assert.deepEqual(calls, [ + 'set_property:sub-auto:fuzzy', + 'set_property:aid:auto', + 'set_property:sid:auto', + 'set_property:secondary-sid:auto', + 'set_property:secondary-sub-visibility:no', + 'set_property:alang:ja,jp', + 'set_property:slang:ja,jp', + ]); +}); + +test('get default socket path returns platform specific value', () => { + const getWindowsPath = createGetDefaultSocketPathHandler({ platform: 'win32' }); + const getUnixPath = createGetDefaultSocketPathHandler({ platform: 'darwin' }); + assert.equal(getWindowsPath(), '\\\\.\\pipe\\subminer-socket'); + assert.equal(getUnixPath(), '/tmp/subminer-socket'); +}); diff --git a/src/main/runtime/mpv-jellyfin-defaults.ts b/src/main/runtime/mpv-jellyfin-defaults.ts new file mode 100644 index 0000000..6ee1247 --- /dev/null +++ b/src/main/runtime/mpv-jellyfin-defaults.ts @@ -0,0 +1,25 @@ +import type { MpvRuntimeClientLike } from '../../core/services/mpv'; + +export function createApplyJellyfinMpvDefaultsHandler(deps: { + sendMpvCommandRuntime: (client: MpvRuntimeClientLike, command: [string, string, string]) => void; + jellyfinLangPref: string; +}) { + return (client: MpvRuntimeClientLike): void => { + deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']); + deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']); + deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']); + deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']); + deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']); + deps.sendMpvCommandRuntime(client, ['set_property', 'alang', deps.jellyfinLangPref]); + deps.sendMpvCommandRuntime(client, ['set_property', 'slang', deps.jellyfinLangPref]); + }; +} + +export function createGetDefaultSocketPathHandler(deps: { platform: string }) { + return (): string => { + if (deps.platform === 'win32') { + return '\\\\.\\pipe\\subminer-socket'; + } + return '/tmp/subminer-socket'; + }; +} diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts new file mode 100644 index 0000000..61ee595 --- /dev/null +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createHandleMpvMediaPathChangeHandler, + createHandleMpvMediaTitleChangeHandler, + createHandleMpvPauseChangeHandler, + createHandleMpvSecondarySubtitleChangeHandler, + createHandleMpvSecondarySubtitleVisibilityHandler, + createHandleMpvSubtitleAssChangeHandler, + createHandleMpvSubtitleChangeHandler, + createHandleMpvSubtitleMetricsChangeHandler, + createHandleMpvTimePosChangeHandler, +} from './mpv-main-event-actions'; + +test('subtitle change handler updates state, broadcasts, and forwards', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleChangeHandler({ + setCurrentSubText: (text) => calls.push(`set:${text}`), + broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`), + onSubtitleChange: (text) => calls.push(`process:${text}`), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ text: 'line' }); + assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']); +}); + +test('subtitle ass change handler updates state and broadcasts', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleAssChangeHandler({ + setCurrentSubAssText: (text) => calls.push(`set:${text}`), + broadcastSubtitleAss: (text) => calls.push(`broadcast:${text}`), + }); + + handler({ text: '{\\an8}line' }); + assert.deepEqual(calls, ['set:{\\an8}line', 'broadcast:{\\an8}line']); +}); + +test('secondary subtitle change handler broadcasts text', () => { + const seen: string[] = []; + const handler = createHandleMpvSecondarySubtitleChangeHandler({ + broadcastSecondarySubtitle: (text) => seen.push(text), + }); + + handler({ text: 'secondary' }); + assert.deepEqual(seen, ['secondary']); +}); + +test('media path change handler reports stop for empty path and probes media key', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + reportJellyfinRemoteStopped: () => calls.push('stopped'), + getCurrentAnilistMediaKey: () => 'show:1', + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync'), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ path: '' }); + assert.deepEqual(calls, [ + 'path:', + 'stopped', + 'reset:show:1', + 'probe:show:1', + 'guess:show:1', + 'sync', + 'presence', + ]); +}); + +test('media title change handler clears guess state and syncs immersion', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaTitleChangeHandler({ + updateCurrentMediaTitle: (title) => calls.push(`title:${title}`), + resetAnilistMediaGuessState: () => calls.push('reset-guess'), + notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`), + syncImmersionMediaState: () => calls.push('sync'), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ title: 'Episode 1' }); + assert.deepEqual(calls, [ + 'title:Episode 1', + 'reset-guess', + 'notify:Episode 1', + 'sync', + 'presence', + ]); +}); + +test('time-pos and pause handlers report progress with correct urgency', () => { + const calls: string[] = []; + const timeHandler = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: (time) => calls.push(`time:${time}`), + reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), + refreshDiscordPresence: () => calls.push('presence'), + }); + const pauseHandler = createHandleMpvPauseChangeHandler({ + recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), + reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), + refreshDiscordPresence: () => calls.push('presence'), + }); + + timeHandler({ time: 12.5 }); + pauseHandler({ paused: true }); + assert.deepEqual(calls, [ + 'time:12.5', + 'progress:normal', + 'presence', + 'pause:yes', + 'progress:force', + 'presence', + ]); +}); + +test('subtitle metrics change handler forwards patch payload', () => { + let received: Record | null = null; + const handler = createHandleMpvSubtitleMetricsChangeHandler({ + updateSubtitleRenderMetrics: (patch) => { + received = patch; + }, + }); + + const patch = { fontSize: 48 }; + handler({ patch }); + assert.deepEqual(received, patch); +}); + +test('secondary subtitle visibility handler stores visibility flag', () => { + const seen: boolean[] = []; + const handler = createHandleMpvSecondarySubtitleVisibilityHandler({ + setPreviousSecondarySubVisibility: (visible) => seen.push(visible), + }); + + handler({ visible: true }); + handler({ visible: false }); + assert.deepEqual(seen, [true, false]); +}); diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts new file mode 100644 index 0000000..56ec606 --- /dev/null +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -0,0 +1,113 @@ +export function createHandleMpvSubtitleChangeHandler(deps: { + setCurrentSubText: (text: string) => void; + broadcastSubtitle: (payload: { text: string; tokens: null }) => void; + onSubtitleChange: (text: string) => void; + refreshDiscordPresence: () => void; +}) { + return ({ text }: { text: string }): void => { + deps.setCurrentSubText(text); + deps.broadcastSubtitle({ text, tokens: null }); + deps.onSubtitleChange(text); + deps.refreshDiscordPresence(); + }; +} + +export function createHandleMpvSubtitleAssChangeHandler(deps: { + setCurrentSubAssText: (text: string) => void; + broadcastSubtitleAss: (text: string) => void; +}) { + return ({ text }: { text: string }): void => { + deps.setCurrentSubAssText(text); + deps.broadcastSubtitleAss(text); + }; +} + +export function createHandleMpvSecondarySubtitleChangeHandler(deps: { + broadcastSecondarySubtitle: (text: string) => void; +}) { + return ({ text }: { text: string }): void => { + deps.broadcastSecondarySubtitle(text); + }; +} + +export function createHandleMpvMediaPathChangeHandler(deps: { + updateCurrentMediaPath: (path: string) => void; + reportJellyfinRemoteStopped: () => void; + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + maybeProbeAnilistDuration: (mediaKey: string) => void; + ensureAnilistMediaGuess: (mediaKey: string) => void; + syncImmersionMediaState: () => void; + refreshDiscordPresence: () => void; +}) { + return ({ path }: { path: string }): void => { + deps.updateCurrentMediaPath(path); + if (!path) { + deps.reportJellyfinRemoteStopped(); + } + const mediaKey = deps.getCurrentAnilistMediaKey(); + deps.resetAnilistMediaTracking(mediaKey); + if (mediaKey) { + deps.maybeProbeAnilistDuration(mediaKey); + deps.ensureAnilistMediaGuess(mediaKey); + } + deps.syncImmersionMediaState(); + deps.refreshDiscordPresence(); + }; +} + +export function createHandleMpvMediaTitleChangeHandler(deps: { + updateCurrentMediaTitle: (title: string) => void; + resetAnilistMediaGuessState: () => void; + notifyImmersionTitleUpdate: (title: string) => void; + syncImmersionMediaState: () => void; + refreshDiscordPresence: () => void; +}) { + return ({ title }: { title: string }): void => { + deps.updateCurrentMediaTitle(title); + deps.resetAnilistMediaGuessState(); + deps.notifyImmersionTitleUpdate(title); + deps.syncImmersionMediaState(); + deps.refreshDiscordPresence(); + }; +} + +export function createHandleMpvTimePosChangeHandler(deps: { + recordPlaybackPosition: (time: number) => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + refreshDiscordPresence: () => void; +}) { + return ({ time }: { time: number }): void => { + deps.recordPlaybackPosition(time); + deps.reportJellyfinRemoteProgress(false); + deps.refreshDiscordPresence(); + }; +} + +export function createHandleMpvPauseChangeHandler(deps: { + recordPauseState: (paused: boolean) => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + refreshDiscordPresence: () => void; +}) { + return ({ paused }: { paused: boolean }): void => { + deps.recordPauseState(paused); + deps.reportJellyfinRemoteProgress(true); + deps.refreshDiscordPresence(); + }; +} + +export function createHandleMpvSubtitleMetricsChangeHandler(deps: { + updateSubtitleRenderMetrics: (patch: Record) => void; +}) { + return ({ patch }: { patch: Record }): void => { + deps.updateSubtitleRenderMetrics(patch); + }; +} + +export function createHandleMpvSecondarySubtitleVisibilityHandler(deps: { + setPreviousSecondarySubVisibility: (visible: boolean) => void; +}) { + return ({ visible }: { visible: boolean }): void => { + deps.setPreviousSecondarySubVisibility(visible); + }; +} diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts new file mode 100644 index 0000000..76c5e00 --- /dev/null +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings'; + +test('main mpv event binder wires callbacks through to runtime deps', () => { + const handlers = new Map void>(); + const calls: string[] = []; + + const bind = createBindMpvMainEventHandlersHandler({ + reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), + hasInitialJellyfinPlayArg: () => false, + isOverlayRuntimeInitialized: () => false, + isQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => { + calls.push('schedule-quit-check'); + }, + isMpvConnected: () => false, + quitApp: () => calls.push('quit-app'), + + recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`), + hasSubtitleTimingTracker: () => false, + recordSubtitleTiming: () => calls.push('record-timing'), + maybeRunAnilistPostWatchUpdate: async () => { + 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}`), + refreshDiscordPresence: () => calls.push('presence-refresh'), + + setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`), + broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`), + broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`), + + updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`), + getCurrentAnilistMediaKey: () => 'media-key', + resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync-immersion'), + + updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`), + resetAnilistMediaGuessState: () => calls.push('reset-guess-state'), + notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`), + + recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`), + reportJellyfinRemoteProgress: (forceImmediate) => + calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`), + recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), + + updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'), + setPreviousSecondarySubVisibility: (visible) => + calls.push(`secondary-visible:${visible ? 'yes' : 'no'}`), + }); + + bind({ + on: (event, handler) => { + handlers.set(event, handler as (payload: unknown) => void); + }, + }); + + handlers.get('subtitle-change')?.({ text: 'line' }); + handlers.get('media-title-change')?.({ title: 'Episode 1' }); + handlers.get('time-pos-change')?.({ time: 2.5 }); + handlers.get('pause-change')?.({ paused: true }); + + assert.ok(calls.includes('set-sub:line')); + assert.ok(calls.includes('broadcast-sub:line')); + assert.ok(calls.includes('subtitle-change:line')); + assert.ok(calls.includes('media-title:Episode 1')); + assert.ok(calls.includes('reset-guess-state')); + assert.ok(calls.includes('notify-title:Episode 1')); + assert.ok(calls.includes('progress:normal')); + assert.ok(calls.includes('progress:force')); + assert.ok(calls.includes('presence-refresh')); +}); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts new file mode 100644 index 0000000..16be17e --- /dev/null +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -0,0 +1,145 @@ +import { + createBindMpvClientEventHandlers, + createHandleMpvConnectionChangeHandler, + createHandleMpvSubtitleTimingHandler, +} from './mpv-client-event-bindings'; +import { + createHandleMpvMediaPathChangeHandler, + createHandleMpvMediaTitleChangeHandler, + createHandleMpvPauseChangeHandler, + createHandleMpvSecondarySubtitleChangeHandler, + createHandleMpvSecondarySubtitleVisibilityHandler, + createHandleMpvSubtitleAssChangeHandler, + createHandleMpvSubtitleChangeHandler, + createHandleMpvSubtitleMetricsChangeHandler, + createHandleMpvTimePosChangeHandler, +} from './mpv-main-event-actions'; + +type MpvEventClient = Parameters>[0]; + +export function createBindMpvMainEventHandlersHandler(deps: { + reportJellyfinRemoteStopped: () => void; + hasInitialJellyfinPlayArg: () => boolean; + isOverlayRuntimeInitialized: () => boolean; + isQuitOnDisconnectArmed: () => boolean; + scheduleQuitCheck: (callback: () => void) => void; + isMpvConnected: () => boolean; + quitApp: () => void; + + recordImmersionSubtitleLine: (text: string, start: number, end: number) => void; + hasSubtitleTimingTracker: () => boolean; + recordSubtitleTiming: (text: string, start: number, end: number) => void; + maybeRunAnilistPostWatchUpdate: () => Promise; + logSubtitleTimingError: (message: string, error: unknown) => void; + + setCurrentSubText: (text: string) => void; + broadcastSubtitle: (payload: { text: string; tokens: null }) => void; + onSubtitleChange: (text: string) => void; + refreshDiscordPresence: () => void; + + setCurrentSubAssText: (text: string) => void; + broadcastSubtitleAss: (text: string) => void; + broadcastSecondarySubtitle: (text: string) => void; + + updateCurrentMediaPath: (path: string) => void; + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + maybeProbeAnilistDuration: (mediaKey: string) => void; + ensureAnilistMediaGuess: (mediaKey: string) => void; + syncImmersionMediaState: () => void; + + updateCurrentMediaTitle: (title: string) => void; + resetAnilistMediaGuessState: () => void; + notifyImmersionTitleUpdate: (title: string) => void; + + recordPlaybackPosition: (time: number) => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + recordPauseState: (paused: boolean) => void; + + updateSubtitleRenderMetrics: (patch: Record) => void; + setPreviousSecondarySubVisibility: (visible: boolean) => void; +}) { + return (mpvClient: MpvEventClient): void => { + const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ + reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(), + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(), + scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback), + isMpvConnected: () => deps.isMpvConnected(), + quitApp: () => deps.quitApp(), + }); + const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({ + recordImmersionSubtitleLine: (text, start, end) => + deps.recordImmersionSubtitleLine(text, start, end), + hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(), + recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end), + maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(), + logError: (message, error) => deps.logSubtitleTimingError(message, error), + }); + const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({ + setCurrentSubText: (text) => deps.setCurrentSubText(text), + broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload), + onSubtitleChange: (text) => deps.onSubtitleChange(text), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + }); + const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({ + setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text), + broadcastSubtitleAss: (text) => deps.broadcastSubtitleAss(text), + }); + const handleMpvSecondarySubtitleChange = createHandleMpvSecondarySubtitleChangeHandler({ + broadcastSecondarySubtitle: (text) => deps.broadcastSecondarySubtitle(text), + }); + const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path), + reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey), + maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey), + syncImmersionMediaState: () => deps.syncImmersionMediaState(), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + }); + const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({ + updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title), + resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), + notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title), + syncImmersionMediaState: () => deps.syncImmersionMediaState(), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + }); + const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time), + reportJellyfinRemoteProgress: (forceImmediate) => + deps.reportJellyfinRemoteProgress(forceImmediate), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + }); + const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ + recordPauseState: (paused) => deps.recordPauseState(paused), + reportJellyfinRemoteProgress: (forceImmediate) => + deps.reportJellyfinRemoteProgress(forceImmediate), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + }); + const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({ + updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch), + }); + const handleMpvSecondarySubtitleVisibility = createHandleMpvSecondarySubtitleVisibilityHandler({ + setPreviousSecondarySubVisibility: (visible) => + deps.setPreviousSecondarySubVisibility(visible), + }); + + createBindMpvClientEventHandlers({ + onConnectionChange: handleMpvConnectionChange, + onSubtitleChange: handleMpvSubtitleChange, + onSubtitleAssChange: handleMpvSubtitleAssChange, + onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, + onSubtitleTiming: handleMpvSubtitleTiming, + onMediaPathChange: handleMpvMediaPathChange, + onMediaTitleChange: handleMpvMediaTitleChange, + onTimePosChange: handleMpvTimePosChange, + onPauseChange: handleMpvPauseChange, + onSubtitleMetricsChange: handleMpvSubtitleMetricsChange, + onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility, + })(mpvClient); + }; +} diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts new file mode 100644 index 0000000..f19d782 --- /dev/null +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps'; + +test('mpv main event main deps map app state updates and delegate callbacks', async () => { + const calls: string[] = []; + const appState = { + initialArgs: { jellyfinPlay: true }, + overlayRuntimeInitialized: true, + mpvClient: { connected: true }, + immersionTracker: { + recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), + handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), + recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`), + recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`), + }, + subtitleTimingTracker: { + recordSubtitle: (text: string) => calls.push(`timing:${text}`), + }, + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: false, + }; + + const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState, + getQuitOnDisconnectArmed: () => true, + scheduleQuitCheck: (callback) => { + calls.push('schedule'); + callback(); + }, + quitApp: () => calls.push('quit'), + reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), + maybeRunAnilistPostWatchUpdate: async () => { + calls.push('anilist-post-watch'); + }, + logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`), + broadcastToOverlayWindows: (channel, payload) => + calls.push(`broadcast:${channel}:${String(payload)}`), + onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + getCurrentAnilistMediaKey: () => 'media-key', + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync-immersion'), + updateCurrentMediaTitle: (title) => calls.push(`title:${title}`), + resetAnilistMediaGuessState: () => calls.push('reset-guess'), + reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`), + updateSubtitleRenderMetrics: () => calls.push('metrics'), + refreshDiscordPresence: () => calls.push('presence-refresh'), + })(); + + assert.equal(deps.hasInitialJellyfinPlayArg(), true); + assert.equal(deps.isOverlayRuntimeInitialized(), true); + assert.equal(deps.isQuitOnDisconnectArmed(), true); + assert.equal(deps.isMpvConnected(), true); + deps.scheduleQuitCheck(() => calls.push('scheduled-callback')); + deps.quitApp(); + deps.reportJellyfinRemoteStopped(); + deps.recordImmersionSubtitleLine('x', 0, 1); + assert.equal(deps.hasSubtitleTimingTracker(), true); + deps.recordSubtitleTiming('y', 0, 1); + await deps.maybeRunAnilistPostWatchUpdate(); + deps.logSubtitleTimingError('err', new Error('boom')); + deps.setCurrentSubText('sub'); + deps.broadcastSubtitle({ text: 'sub', tokens: null }); + deps.onSubtitleChange('sub'); + deps.refreshDiscordPresence(); + deps.setCurrentSubAssText('ass'); + deps.broadcastSubtitleAss('ass'); + deps.broadcastSecondarySubtitle('sec'); + deps.updateCurrentMediaPath('/tmp/video'); + assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key'); + deps.resetAnilistMediaTracking('media-key'); + deps.maybeProbeAnilistDuration('media-key'); + deps.ensureAnilistMediaGuess('media-key'); + deps.syncImmersionMediaState(); + deps.updateCurrentMediaTitle('title'); + deps.resetAnilistMediaGuessState(); + deps.notifyImmersionTitleUpdate('title'); + deps.recordPlaybackPosition(10); + deps.reportJellyfinRemoteProgress(true); + deps.recordPauseState(true); + deps.updateSubtitleRenderMetrics({}); + deps.setPreviousSecondarySubVisibility(true); + + assert.equal(appState.currentSubText, 'sub'); + assert.equal(appState.currentSubAssText, 'ass'); + assert.equal(appState.playbackPaused, true); + assert.equal(appState.previousSecondarySubVisibility, true); + assert.ok(calls.includes('remote-stopped')); + assert.ok(calls.includes('anilist-post-watch')); + assert.ok(calls.includes('sync-immersion')); + assert.ok(calls.includes('metrics')); + assert.ok(calls.includes('presence-refresh')); +}); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts new file mode 100644 index 0000000..d70e77e --- /dev/null +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -0,0 +1,95 @@ +export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { + appState: { + initialArgs?: { jellyfinPlay?: unknown } | null; + overlayRuntimeInitialized: boolean; + mpvClient: { connected?: boolean } | null; + immersionTracker: { + recordSubtitleLine?: (text: string, start: number, end: number) => void; + handleMediaTitleUpdate?: (title: string) => void; + recordPlaybackPosition?: (time: number) => void; + recordPauseState?: (paused: boolean) => void; + } | null; + subtitleTimingTracker: { + recordSubtitle?: (text: string, start: number, end: number) => void; + } | null; + currentSubText: string; + currentSubAssText: string; + playbackPaused: boolean | null; + previousSecondarySubVisibility: boolean | null; + }; + getQuitOnDisconnectArmed: () => boolean; + scheduleQuitCheck: (callback: () => void) => void; + quitApp: () => void; + reportJellyfinRemoteStopped: () => void; + maybeRunAnilistPostWatchUpdate: () => Promise; + logSubtitleTimingError: (message: string, error: unknown) => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + onSubtitleChange: (text: string) => void; + updateCurrentMediaPath: (path: string) => void; + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + maybeProbeAnilistDuration: (mediaKey: string) => void; + ensureAnilistMediaGuess: (mediaKey: string) => void; + syncImmersionMediaState: () => void; + updateCurrentMediaTitle: (title: string) => void; + resetAnilistMediaGuessState: () => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + updateSubtitleRenderMetrics: (patch: Record) => void; + refreshDiscordPresence: () => void; +}) { + return () => ({ + reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay), + isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized, + isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(), + scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback), + isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected), + quitApp: () => deps.quitApp(), + recordImmersionSubtitleLine: (text: string, start: number, end: number) => + deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end), + hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), + recordSubtitleTiming: (text: string, start: number, end: number) => + deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end), + maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(), + logSubtitleTimingError: (message: string, error: unknown) => + deps.logSubtitleTimingError(message, error), + setCurrentSubText: (text: string) => { + deps.appState.currentSubText = text; + }, + broadcastSubtitle: (payload: { text: string; tokens: null }) => + deps.broadcastToOverlayWindows('subtitle:set', payload), + onSubtitleChange: (text: string) => deps.onSubtitleChange(text), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + setCurrentSubAssText: (text: string) => { + deps.appState.currentSubAssText = text; + }, + broadcastSubtitleAss: (text: string) => + deps.broadcastToOverlayWindows('subtitle-ass:set', text), + broadcastSecondarySubtitle: (text: string) => + deps.broadcastToOverlayWindows('secondary-subtitle:set', text), + updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), + getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey: string | null) => + deps.resetAnilistMediaTracking(mediaKey), + maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), + syncImmersionMediaState: () => deps.syncImmersionMediaState(), + updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title), + resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), + notifyImmersionTitleUpdate: (title: string) => + deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title), + recordPlaybackPosition: (time: number) => + deps.appState.immersionTracker?.recordPlaybackPosition?.(time), + reportJellyfinRemoteProgress: (forceImmediate: boolean) => + deps.reportJellyfinRemoteProgress(forceImmediate), + recordPauseState: (paused: boolean) => { + deps.appState.playbackPaused = paused; + deps.appState.immersionTracker?.recordPauseState?.(paused); + }, + updateSubtitleRenderMetrics: (patch: Record) => + deps.updateSubtitleRenderMetrics(patch), + setPreviousSecondarySubVisibility: (visible: boolean) => { + deps.appState.previousSecondarySubVisibility = visible; + }, + }); +} diff --git a/src/main/runtime/mpv-osd-log-main-deps.test.ts b/src/main/runtime/mpv-osd-log-main-deps.test.ts new file mode 100644 index 0000000..de5131d --- /dev/null +++ b/src/main/runtime/mpv-osd-log-main-deps.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildAppendToMpvLogMainDepsHandler, + createBuildShowMpvOsdMainDepsHandler, +} from './mpv-osd-log-main-deps'; + +test('append to mpv log main deps map filesystem functions and log path', async () => { + const calls: string[] = []; + const deps = createBuildAppendToMpvLogMainDepsHandler({ + logPath: '/tmp/mpv.log', + dirname: (targetPath) => { + calls.push(`dirname:${targetPath}`); + return '/tmp'; + }, + mkdir: async (targetPath) => { + calls.push(`mkdir:${targetPath}`); + }, + appendFile: async (_targetPath, data) => { + calls.push(`append:${data}`); + }, + now: () => new Date('2026-02-20T00:00:00.000Z'), + })(); + + assert.equal(deps.logPath, '/tmp/mpv.log'); + assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp'); + await deps.mkdir('/tmp', { recursive: true }); + await deps.appendFile('/tmp/mpv.log', 'line', { encoding: 'utf8' }); + assert.equal(deps.now().toISOString(), '2026-02-20T00:00:00.000Z'); + assert.deepEqual(calls, ['dirname:/tmp/mpv.log', 'mkdir:/tmp', 'append:line']); +}); + +test('show mpv osd main deps map runtime delegates and logging callback', () => { + const calls: string[] = []; + const client = { + connected: true, + send: () => {}, + }; + const deps = createBuildShowMpvOsdMainDepsHandler({ + appendToMpvLog: (message) => calls.push(`append:${message}`), + showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => { + calls.push(`show:${text}`); + fallbackLog('fallback'); + }, + getMpvClient: () => client, + logInfo: (line) => calls.push(`info:${line}`), + })(); + + assert.deepEqual(deps.getMpvClient(), client); + deps.appendToMpvLog('hello'); + deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) => deps.logInfo(line)); + assert.deepEqual(calls, ['append:hello', 'show:subtitle', 'info:fallback']); +}); diff --git a/src/main/runtime/mpv-osd-log-main-deps.ts b/src/main/runtime/mpv-osd-log-main-deps.ts new file mode 100644 index 0000000..c3816d0 --- /dev/null +++ b/src/main/runtime/mpv-osd-log-main-deps.ts @@ -0,0 +1,25 @@ +import type { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log'; + +type AppendToMpvLogMainDeps = Parameters[0]; +type ShowMpvOsdMainDeps = Parameters[0]; + +export function createBuildAppendToMpvLogMainDepsHandler(deps: AppendToMpvLogMainDeps) { + return (): AppendToMpvLogMainDeps => ({ + logPath: deps.logPath, + dirname: (targetPath: string) => deps.dirname(targetPath), + mkdir: (targetPath: string, options: { recursive: boolean }) => deps.mkdir(targetPath, options), + appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) => + deps.appendFile(targetPath, data, options), + now: () => deps.now(), + }); +} + +export function createBuildShowMpvOsdMainDepsHandler(deps: ShowMpvOsdMainDeps) { + return (): ShowMpvOsdMainDeps => ({ + appendToMpvLog: (message: string) => deps.appendToMpvLog(message), + showMpvOsdRuntime: (mpvClient, text, fallbackLog) => + deps.showMpvOsdRuntime(mpvClient, text, fallbackLog), + getMpvClient: () => deps.getMpvClient(), + logInfo: (line: string) => deps.logInfo(line), + }); +} diff --git a/src/main/runtime/mpv-osd-log.test.ts b/src/main/runtime/mpv-osd-log.test.ts new file mode 100644 index 0000000..1f23f70 --- /dev/null +++ b/src/main/runtime/mpv-osd-log.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log'; + +test('append mpv log writes timestamped message', () => { + const writes: string[] = []; + const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({ + logPath: '/tmp/subminer/mpv.log', + dirname: (targetPath: string) => { + writes.push(`dirname:${targetPath}`); + return '/tmp/subminer'; + }, + mkdir: async (targetPath: string) => { + writes.push(`mkdir:${targetPath}`); + }, + appendFile: async (_targetPath: string, data: string) => { + writes.push(`append:${data.trimEnd()}`); + }, + now: () => new Date('2026-02-20T00:00:00.000Z'), + }); + + appendToMpvLog('hello'); + return flushMpvLog().then(() => { + assert.deepEqual(writes, [ + 'dirname:/tmp/subminer/mpv.log', + 'mkdir:/tmp/subminer', + 'append:[2026-02-20T00:00:00.000Z] hello', + ]); + }); +}); + +test('append mpv log queues multiple messages and flush waits for pending write', async () => { + const writes: string[] = []; + const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({ + logPath: '/tmp/subminer/mpv.log', + dirname: () => '/tmp/subminer', + mkdir: async () => { + writes.push('mkdir'); + }, + appendFile: async (_targetPath: string, data: string) => { + writes.push(`append:${data.trimEnd()}`); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + }, + now: (() => { + const values = ['2026-02-20T00:00:00.000Z', '2026-02-20T00:00:01.000Z']; + let index = 0; + return () => { + const nextValue = values[index] ?? values[values.length - 1] ?? values[0] ?? ''; + index += 1; + return new Date(nextValue); + }; + })(), + }); + + appendToMpvLog('first'); + appendToMpvLog('second'); + + let flushed = false; + const flushPromise = flushMpvLog().then(() => { + flushed = true; + }); + + await Promise.resolve(); + assert.equal(flushed, false); + await flushPromise; + + const appendedPayload = writes + .filter((entry) => entry.startsWith('append:')) + .map((entry) => entry.replace('append:', '')) + .join('\n'); + assert.ok(appendedPayload.includes('[2026-02-20T00:00:00.000Z] first')); + assert.ok(appendedPayload.includes('[2026-02-20T00:00:01.000Z] second')); +}); + +test('append mpv log swallows async filesystem errors', async () => { + const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({ + logPath: '/tmp/subminer/mpv.log', + dirname: () => '/tmp/subminer', + mkdir: async () => { + throw new Error('disk error'); + }, + appendFile: async () => { + throw new Error('should not reach'); + }, + now: () => new Date('2026-02-20T00:00:00.000Z'), + }); + + assert.doesNotThrow(() => appendToMpvLog('hello')); + await assert.doesNotReject(async () => flushMpvLog()); +}); + +test('show mpv osd logs marker and forwards fallback logging', () => { + const calls: string[] = []; + const client = { connected: false, send: () => {} } as never; + const showMpvOsd = createShowMpvOsdHandler({ + appendToMpvLog: (message) => calls.push(`append:${message}`), + showMpvOsdRuntime: (_client, text, fallbackLog) => { + calls.push(`show:${text}`); + fallbackLog('fallback-line'); + }, + getMpvClient: () => client, + logInfo: (line) => calls.push(`info:${line}`), + }); + + showMpvOsd('subtitle copied'); + assert.deepEqual(calls, [ + 'append:[OSD] subtitle copied', + 'show:subtitle copied', + 'info:fallback-line', + ]); +}); diff --git a/src/main/runtime/mpv-osd-log.ts b/src/main/runtime/mpv-osd-log.ts new file mode 100644 index 0000000..931d68c --- /dev/null +++ b/src/main/runtime/mpv-osd-log.ts @@ -0,0 +1,70 @@ +import type { MpvRuntimeClientLike } from '../../core/services/mpv'; + +export function createAppendToMpvLogHandler(deps: { + logPath: string; + dirname: (targetPath: string) => string; + mkdir: (targetPath: string, options: { recursive: boolean }) => Promise; + appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) => Promise; + now: () => Date; +}) { + const pendingLines: string[] = []; + let drainPromise: Promise | null = null; + + const drainPendingLines = async (): Promise => { + while (pendingLines.length > 0) { + const chunk = pendingLines.splice(0, pendingLines.length).join(''); + try { + await deps.mkdir(deps.dirname(deps.logPath), { recursive: true }); + await deps.appendFile(deps.logPath, chunk, { encoding: 'utf8' }); + } catch { + // best-effort logging + } + } + }; + + const scheduleDrain = (): Promise => { + if (drainPromise) return drainPromise; + drainPromise = (async () => { + try { + await drainPendingLines(); + } finally { + drainPromise = null; + } + })(); + return drainPromise; + }; + + const appendToMpvLog = (message: string): void => { + pendingLines.push(`[${deps.now().toISOString()}] ${message}\n`); + void scheduleDrain(); + }; + + const flushMpvLog = async (): Promise => { + while (pendingLines.length > 0 || drainPromise) { + await scheduleDrain(); + } + }; + + return { + appendToMpvLog, + flushMpvLog, + }; +} + +export function createShowMpvOsdHandler(deps: { + appendToMpvLog: (message: string) => void; + showMpvOsdRuntime: ( + mpvClient: MpvRuntimeClientLike | null, + text: string, + fallbackLog: (line: string) => void, + ) => void; + getMpvClient: () => MpvRuntimeClientLike | null; + logInfo: (line: string) => void; +}) { + return (text: string): void => { + deps.appendToMpvLog(`[OSD] ${text}`); + deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => { + deps.logInfo(line); + }); + }; +} diff --git a/src/main/runtime/mpv-osd-runtime-handlers.test.ts b/src/main/runtime/mpv-osd-runtime-handlers.test.ts new file mode 100644 index 0000000..592b13c --- /dev/null +++ b/src/main/runtime/mpv-osd-runtime-handlers.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMpvOsdRuntimeHandlers } from './mpv-osd-runtime-handlers'; + +test('mpv osd runtime handlers compose append and osd logging flow', async () => { + const calls: string[] = []; + const runtime = createMpvOsdRuntimeHandlers({ + appendToMpvLogMainDeps: { + logPath: '/tmp/subminer/mpv.log', + dirname: () => '/tmp/subminer', + mkdir: async () => {}, + appendFile: async (_targetPath: string, data: string) => { + calls.push(`append:${data.trimEnd()}`); + }, + now: () => new Date('2026-02-20T00:00:00.000Z'), + }, + buildShowMpvOsdMainDeps: (appendToMpvLog) => ({ + appendToMpvLog, + showMpvOsdRuntime: (_client, text, fallbackLog) => { + calls.push(`show:${text}`); + fallbackLog('fallback'); + }, + getMpvClient: () => null, + logInfo: (line) => calls.push(`info:${line}`), + }), + }); + + runtime.showMpvOsd('hello'); + await runtime.flushMpvLog(); + + assert.deepEqual(calls, [ + 'show:hello', + 'info:fallback', + 'append:[2026-02-20T00:00:00.000Z] [OSD] hello', + ]); +}); diff --git a/src/main/runtime/mpv-osd-runtime-handlers.ts b/src/main/runtime/mpv-osd-runtime-handlers.ts new file mode 100644 index 0000000..310582d --- /dev/null +++ b/src/main/runtime/mpv-osd-runtime-handlers.ts @@ -0,0 +1,32 @@ +import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log'; +import { + createBuildAppendToMpvLogMainDepsHandler, + createBuildShowMpvOsdMainDepsHandler, +} from './mpv-osd-log-main-deps'; + +type AppendToMpvLogMainDeps = Parameters[0]; +type ShowMpvOsdMainDeps = Parameters[0]; + +export function createMpvOsdRuntimeHandlers(deps: { + appendToMpvLogMainDeps: AppendToMpvLogMainDeps; + buildShowMpvOsdMainDeps: (appendToMpvLog: (message: string) => void) => ShowMpvOsdMainDeps; +}) { + const appendToMpvLogMainDeps = createBuildAppendToMpvLogMainDepsHandler( + deps.appendToMpvLogMainDeps, + )(); + const appendToMpvLogRuntime = createAppendToMpvLogHandler(appendToMpvLogMainDeps); + const appendToMpvLog = (message: string) => appendToMpvLogRuntime.appendToMpvLog(message); + const flushMpvLog = async () => appendToMpvLogRuntime.flushMpvLog(); + + const showMpvOsdMainDeps = createBuildShowMpvOsdMainDepsHandler( + deps.buildShowMpvOsdMainDeps(appendToMpvLog), + )(); + const showMpvOsdHandler = createShowMpvOsdHandler(showMpvOsdMainDeps); + const showMpvOsd = (text: string) => showMpvOsdHandler(text); + + return { + appendToMpvLog, + flushMpvLog, + showMpvOsd, + }; +} diff --git a/src/main/runtime/mpv-subtitle-render-metrics-main-deps.test.ts b/src/main/runtime/mpv-subtitle-render-metrics-main-deps.test.ts new file mode 100644 index 0000000..b3ea98e --- /dev/null +++ b/src/main/runtime/mpv-subtitle-render-metrics-main-deps.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { MpvSubtitleRenderMetrics } from '../../types'; +import { createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler } from './mpv-subtitle-render-metrics-main-deps'; + +const BASE_METRICS: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: '', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 0, + osdDimensions: null, +}; + +test('mpv subtitle render metrics main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler({ + getCurrentMetrics: () => BASE_METRICS, + setCurrentMetrics: () => calls.push('set'), + applyPatch: (current, patch) => { + calls.push('apply'); + return { next: { ...current, ...patch }, changed: true }; + }, + broadcastMetrics: () => calls.push('broadcast'), + })(); + + assert.equal(deps.getCurrentMetrics().subPos, 100); + deps.setCurrentMetrics(BASE_METRICS); + const patched = deps.applyPatch(BASE_METRICS, { subPos: 90 }); + deps.broadcastMetrics(BASE_METRICS); + + assert.equal(patched.changed, true); + assert.equal(patched.next.subPos, 90); + assert.deepEqual(calls, ['set', 'apply', 'broadcast']); +}); diff --git a/src/main/runtime/mpv-subtitle-render-metrics-main-deps.ts b/src/main/runtime/mpv-subtitle-render-metrics-main-deps.ts new file mode 100644 index 0000000..a48235d --- /dev/null +++ b/src/main/runtime/mpv-subtitle-render-metrics-main-deps.ts @@ -0,0 +1,14 @@ +import type { createUpdateMpvSubtitleRenderMetricsHandler } from './mpv-subtitle-render-metrics'; + +type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters[0]; + +export function createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler( + deps: UpdateMpvSubtitleRenderMetricsMainDeps, +) { + return (): UpdateMpvSubtitleRenderMetricsMainDeps => ({ + getCurrentMetrics: () => deps.getCurrentMetrics(), + setCurrentMetrics: (metrics) => deps.setCurrentMetrics(metrics), + applyPatch: (current, patch) => deps.applyPatch(current, patch), + broadcastMetrics: (metrics) => deps.broadcastMetrics(metrics), + }); +} diff --git a/src/main/runtime/mpv-subtitle-render-metrics.test.ts b/src/main/runtime/mpv-subtitle-render-metrics.test.ts new file mode 100644 index 0000000..e7d764a --- /dev/null +++ b/src/main/runtime/mpv-subtitle-render-metrics.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createUpdateMpvSubtitleRenderMetricsHandler } from './mpv-subtitle-render-metrics'; +import type { MpvSubtitleRenderMetrics } from '../../types'; + +const BASE_METRICS: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: '', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 0, + osdDimensions: null, +}; + +test('subtitle render metrics handler no-ops when patch does not change state', () => { + let metrics = { ...BASE_METRICS }; + let broadcasts = 0; + const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({ + getCurrentMetrics: () => metrics, + setCurrentMetrics: (next) => { + metrics = next; + }, + applyPatch: (current) => ({ next: current, changed: false }), + broadcastMetrics: () => { + broadcasts += 1; + }, + }); + + updateMetrics({}); + assert.equal(broadcasts, 0); +}); + +test('subtitle render metrics handler updates and broadcasts when changed', () => { + let metrics = { ...BASE_METRICS }; + let broadcasts = 0; + const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({ + getCurrentMetrics: () => metrics, + setCurrentMetrics: (next) => { + metrics = next; + }, + applyPatch: (current, patch) => ({ + next: { ...current, ...patch }, + changed: true, + }), + broadcastMetrics: () => { + broadcasts += 1; + }, + }); + + updateMetrics({ subPos: 80 }); + assert.equal(metrics.subPos, 80); + assert.equal(broadcasts, 1); +}); diff --git a/src/main/runtime/mpv-subtitle-render-metrics.ts b/src/main/runtime/mpv-subtitle-render-metrics.ts new file mode 100644 index 0000000..88819a5 --- /dev/null +++ b/src/main/runtime/mpv-subtitle-render-metrics.ts @@ -0,0 +1,18 @@ +import type { MpvSubtitleRenderMetrics } from '../../types'; + +export function createUpdateMpvSubtitleRenderMetricsHandler(deps: { + getCurrentMetrics: () => MpvSubtitleRenderMetrics; + setCurrentMetrics: (metrics: MpvSubtitleRenderMetrics) => void; + applyPatch: ( + current: MpvSubtitleRenderMetrics, + patch: Partial, + ) => { next: MpvSubtitleRenderMetrics; changed: boolean }; + broadcastMetrics: (metrics: MpvSubtitleRenderMetrics) => void; +}) { + return (patch: Partial): void => { + const { next, changed } = deps.applyPatch(deps.getCurrentMetrics(), patch); + if (!changed) return; + deps.setCurrentMetrics(next); + deps.broadcastMetrics(next); + }; +} diff --git a/src/main/runtime/numeric-shortcut-runtime-main-deps.test.ts b/src/main/runtime/numeric-shortcut-runtime-main-deps.test.ts new file mode 100644 index 0000000..f9cca84 --- /dev/null +++ b/src/main/runtime/numeric-shortcut-runtime-main-deps.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildNumericShortcutRuntimeMainDepsHandler } from './numeric-shortcut-runtime-main-deps'; + +test('numeric shortcut runtime main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildNumericShortcutRuntimeMainDepsHandler({ + globalShortcut: { + register: () => true, + unregister: () => { + calls.push('unregister'); + }, + }, + showMpvOsd: (text) => calls.push(`osd:${text}`), + setTimer: (handler) => { + calls.push('timer'); + handler(); + return 1 as never; + }, + clearTimer: () => { + calls.push('clear'); + }, + })(); + + assert.equal(deps.globalShortcut.register('1', () => {}), true); + deps.globalShortcut.unregister('1'); + deps.showMpvOsd('x'); + deps.setTimer(() => calls.push('tick'), 1000); + deps.clearTimer(1 as never); + + assert.deepEqual(calls, ['unregister', 'osd:x', 'timer', 'tick', 'clear']); +}); diff --git a/src/main/runtime/numeric-shortcut-runtime-main-deps.ts b/src/main/runtime/numeric-shortcut-runtime-main-deps.ts new file mode 100644 index 0000000..d28f306 --- /dev/null +++ b/src/main/runtime/numeric-shortcut-runtime-main-deps.ts @@ -0,0 +1,10 @@ +import type { NumericShortcutRuntimeOptions } from '../../core/services/numeric-shortcut'; + +export function createBuildNumericShortcutRuntimeMainDepsHandler(deps: NumericShortcutRuntimeOptions) { + return (): NumericShortcutRuntimeOptions => ({ + globalShortcut: deps.globalShortcut, + showMpvOsd: (text: string) => deps.showMpvOsd(text), + setTimer: (handler: () => void, timeoutMs: number) => deps.setTimer(handler, timeoutMs), + clearTimer: (timer: ReturnType) => deps.clearTimer(timer), + }); +} diff --git a/src/main/runtime/numeric-shortcut-session-handlers.test.ts b/src/main/runtime/numeric-shortcut-session-handlers.test.ts new file mode 100644 index 0000000..a77e899 --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-handlers.test.ts @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createCancelNumericShortcutSessionHandler, + createStartNumericShortcutSessionHandler, +} from './numeric-shortcut-session-handlers'; + +test('cancel numeric shortcut session handler cancels active session', () => { + const calls: string[] = []; + const cancel = createCancelNumericShortcutSessionHandler({ + session: { + start: () => {}, + cancel: () => calls.push('cancel'), + }, + }); + + cancel(); + assert.deepEqual(calls, ['cancel']); +}); + +test('start numeric shortcut session handler forwards timeout, messages, and onDigit', () => { + const calls: string[] = []; + const start = createStartNumericShortcutSessionHandler({ + session: { + cancel: () => {}, + start: ({ timeoutMs, onDigit, messages }) => { + calls.push(`timeout:${timeoutMs}`); + calls.push(`prompt:${messages.prompt}`); + onDigit(3); + }, + }, + onDigit: (digit) => calls.push(`digit:${digit}`), + messages: { + prompt: 'Prompt', + timeout: 'Timeout', + cancelled: 'Cancelled', + }, + }); + + start(1200); + assert.deepEqual(calls, ['timeout:1200', 'prompt:Prompt', 'digit:3']); +}); diff --git a/src/main/runtime/numeric-shortcut-session-handlers.ts b/src/main/runtime/numeric-shortcut-session-handlers.ts new file mode 100644 index 0000000..e9b85a5 --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-handlers.ts @@ -0,0 +1,31 @@ +import type { + NumericShortcutSessionMessages, + NumericShortcutSessionStartParams, +} from '../../core/services/numeric-shortcut'; + +type NumericShortcutSessionLike = { + start: (params: NumericShortcutSessionStartParams) => void; + cancel: () => void; +}; + +export function createCancelNumericShortcutSessionHandler(deps: { + session: NumericShortcutSessionLike; +}) { + return (): void => { + deps.session.cancel(); + }; +} + +export function createStartNumericShortcutSessionHandler(deps: { + session: NumericShortcutSessionLike; + onDigit: (digit: number) => void; + messages: NumericShortcutSessionMessages; +}) { + return (timeoutMs: number): void => { + deps.session.start({ + timeoutMs, + onDigit: deps.onDigit, + messages: deps.messages, + }); + }; +} diff --git a/src/main/runtime/numeric-shortcut-session-main-deps.test.ts b/src/main/runtime/numeric-shortcut-session-main-deps.test.ts new file mode 100644 index 0000000..ae0242e --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-main-deps.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildCancelNumericShortcutSessionMainDepsHandler, + createBuildStartNumericShortcutSessionMainDepsHandler, +} from './numeric-shortcut-session-main-deps'; + +test('numeric shortcut session main deps builders map callbacks', () => { + const calls: string[] = []; + const session = { + start: () => calls.push('start'), + cancel: () => calls.push('cancel'), + }; + + const cancel = createBuildCancelNumericShortcutSessionMainDepsHandler({ session })(); + cancel.session.cancel(); + + const start = createBuildStartNumericShortcutSessionMainDepsHandler({ + session, + onDigit: (digit) => calls.push(`digit:${digit}`), + messages: { + prompt: 'prompt', + timeout: 'timeout', + cancelled: 'cancelled', + }, + })(); + start.session.start({ + timeoutMs: 100, + onDigit: () => {}, + messages: start.messages, + }); + start.onDigit(4); + assert.equal(start.messages.prompt, 'prompt'); + assert.equal(start.messages.timeout, 'timeout'); + assert.equal(start.messages.cancelled, 'cancelled'); + + assert.deepEqual(calls, ['cancel', 'start', 'digit:4']); +}); diff --git a/src/main/runtime/numeric-shortcut-session-main-deps.ts b/src/main/runtime/numeric-shortcut-session-main-deps.ts new file mode 100644 index 0000000..1970d9c --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-main-deps.ts @@ -0,0 +1,25 @@ +import type { + createCancelNumericShortcutSessionHandler, + createStartNumericShortcutSessionHandler, +} from './numeric-shortcut-session-handlers'; + +type CancelNumericShortcutSessionMainDeps = Parameters[0]; +type StartNumericShortcutSessionMainDeps = Parameters[0]; + +export function createBuildCancelNumericShortcutSessionMainDepsHandler( + deps: CancelNumericShortcutSessionMainDeps, +) { + return (): CancelNumericShortcutSessionMainDeps => ({ + session: deps.session, + }); +} + +export function createBuildStartNumericShortcutSessionMainDepsHandler( + deps: StartNumericShortcutSessionMainDeps, +) { + return (): StartNumericShortcutSessionMainDeps => ({ + session: deps.session, + onDigit: (digit: number) => deps.onDigit(digit), + messages: deps.messages, + }); +} diff --git a/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts b/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts new file mode 100644 index 0000000..65f6013 --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createNumericShortcutSessionRuntimeHandlers } from './numeric-shortcut-session-runtime-handlers'; + +test('numeric shortcut session runtime handlers compose cancel/start handlers', () => { + const calls: string[] = []; + const createSession = (name: string) => ({ + start: ({ timeoutMs, onDigit }: { timeoutMs: number; onDigit: (digit: number) => void }) => { + calls.push(`${name}:start:${timeoutMs}`); + onDigit(3); + }, + cancel: () => calls.push(`${name}:cancel`), + }); + + const runtime = createNumericShortcutSessionRuntimeHandlers({ + multiCopySession: createSession('multi-copy'), + mineSentenceSession: createSession('mine-sentence'), + onMultiCopyDigit: (count) => calls.push(`multi-copy:digit:${count}`), + onMineSentenceDigit: (count) => calls.push(`mine-sentence:digit:${count}`), + }); + + runtime.cancelPendingMultiCopy(); + runtime.startPendingMultiCopy(500); + runtime.cancelPendingMineSentenceMultiple(); + runtime.startPendingMineSentenceMultiple(700); + + assert.deepEqual(calls, [ + 'multi-copy:cancel', + 'multi-copy:start:500', + 'multi-copy:digit:3', + 'mine-sentence:cancel', + 'mine-sentence:start:700', + 'mine-sentence:digit:3', + ]); +}); diff --git a/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts b/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts new file mode 100644 index 0000000..c0c098f --- /dev/null +++ b/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts @@ -0,0 +1,67 @@ +import { + createCancelNumericShortcutSessionHandler, + createStartNumericShortcutSessionHandler, +} from './numeric-shortcut-session-handlers'; +import { + createBuildCancelNumericShortcutSessionMainDepsHandler, + createBuildStartNumericShortcutSessionMainDepsHandler, +} from './numeric-shortcut-session-main-deps'; + +type CancelNumericShortcutSessionMainDeps = Parameters< + typeof createBuildCancelNumericShortcutSessionMainDepsHandler +>[0]; + +export function createNumericShortcutSessionRuntimeHandlers(deps: { + multiCopySession: CancelNumericShortcutSessionMainDeps['session']; + mineSentenceSession: CancelNumericShortcutSessionMainDeps['session']; + onMultiCopyDigit: (count: number) => void; + onMineSentenceDigit: (count: number) => void; +}) { + const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({ + session: deps.multiCopySession, + })(); + const cancelPendingMultiCopyHandler = + createCancelNumericShortcutSessionHandler(cancelPendingMultiCopyMainDeps); + + const startPendingMultiCopyMainDeps = createBuildStartNumericShortcutSessionMainDepsHandler({ + session: deps.multiCopySession, + onDigit: deps.onMultiCopyDigit, + messages: { + prompt: 'Copy how many lines? Press 1-9 (Esc to cancel)', + timeout: 'Copy timeout', + cancelled: 'Cancelled', + }, + })(); + const startPendingMultiCopyHandler = + createStartNumericShortcutSessionHandler(startPendingMultiCopyMainDeps); + + const cancelPendingMineSentenceMultipleMainDeps = + createBuildCancelNumericShortcutSessionMainDepsHandler({ + session: deps.mineSentenceSession, + })(); + const cancelPendingMineSentenceMultipleHandler = createCancelNumericShortcutSessionHandler( + cancelPendingMineSentenceMultipleMainDeps, + ); + + const startPendingMineSentenceMultipleMainDeps = + createBuildStartNumericShortcutSessionMainDepsHandler({ + session: deps.mineSentenceSession, + onDigit: deps.onMineSentenceDigit, + messages: { + prompt: 'Mine how many lines? Press 1-9 (Esc to cancel)', + timeout: 'Mine sentence timeout', + cancelled: 'Cancelled', + }, + })(); + const startPendingMineSentenceMultipleHandler = createStartNumericShortcutSessionHandler( + startPendingMineSentenceMultipleMainDeps, + ); + + return { + cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs), + cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultipleHandler(timeoutMs), + }; +} diff --git a/src/main/runtime/overlay-bootstrap-main-deps.test.ts b/src/main/runtime/overlay-bootstrap-main-deps.test.ts new file mode 100644 index 0000000..1e897a2 --- /dev/null +++ b/src/main/runtime/overlay-bootstrap-main-deps.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildOverlayContentMeasurementStoreMainDepsHandler, + createBuildOverlayModalRuntimeMainDepsHandler, +} from './overlay-bootstrap-main-deps'; + +test('overlay content measurement store main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildOverlayContentMeasurementStoreMainDepsHandler({ + now: () => 42, + warn: (message) => calls.push(`warn:${message}`), + })(); + + assert.equal(deps.now(), 42); + deps.warn('bad payload'); + assert.deepEqual(calls, ['warn:bad payload']); +}); + +test('overlay modal runtime main deps builder maps window resolvers', () => { + const mainWindow = { id: 'main' }; + const invisibleWindow = { id: 'invisible' }; + const deps = createBuildOverlayModalRuntimeMainDepsHandler({ + getMainWindow: () => mainWindow as never, + getInvisibleWindow: () => invisibleWindow as never, + })(); + + assert.equal(deps.getMainWindow(), mainWindow); + assert.equal(deps.getInvisibleWindow(), invisibleWindow); +}); diff --git a/src/main/runtime/overlay-bootstrap-main-deps.ts b/src/main/runtime/overlay-bootstrap-main-deps.ts new file mode 100644 index 0000000..dfea96c --- /dev/null +++ b/src/main/runtime/overlay-bootstrap-main-deps.ts @@ -0,0 +1,22 @@ +import type { OverlayWindowResolver } from '../overlay-runtime'; + +type OverlayContentMeasurementStoreMainDeps = { + now: () => number; + warn: (message: string) => void; +}; + +export function createBuildOverlayContentMeasurementStoreMainDepsHandler( + deps: OverlayContentMeasurementStoreMainDeps, +) { + return (): OverlayContentMeasurementStoreMainDeps => ({ + now: () => deps.now(), + warn: (message: string) => deps.warn(message), + }); +} + +export function createBuildOverlayModalRuntimeMainDepsHandler(deps: OverlayWindowResolver) { + return (): OverlayWindowResolver => ({ + getMainWindow: () => deps.getMainWindow(), + getInvisibleWindow: () => deps.getInvisibleWindow(), + }); +} diff --git a/src/main/runtime/overlay-main-actions-main-deps.test.ts b/src/main/runtime/overlay-main-actions-main-deps.test.ts new file mode 100644 index 0000000..c9731fa --- /dev/null +++ b/src/main/runtime/overlay-main-actions-main-deps.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildAppendClipboardVideoToQueueMainDepsHandler, + createBuildHandleOverlayModalClosedMainDepsHandler, + createBuildSetOverlayVisibleMainDepsHandler, + createBuildToggleOverlayMainDepsHandler, +} from './overlay-main-actions-main-deps'; + +test('overlay main action main deps builders map callbacks', () => { + const calls: string[] = []; + + const setOverlay = createBuildSetOverlayVisibleMainDepsHandler({ + setVisibleOverlayVisible: (visible) => calls.push(`set:${visible}`), + })(); + setOverlay.setVisibleOverlayVisible(true); + + const toggleOverlay = createBuildToggleOverlayMainDepsHandler({ + toggleVisibleOverlay: () => calls.push('toggle'), + })(); + toggleOverlay.toggleVisibleOverlay(); + + const modalClosed = createBuildHandleOverlayModalClosedMainDepsHandler({ + handleOverlayModalClosedRuntime: (modal) => calls.push(`modal:${modal}`), + })(); + modalClosed.handleOverlayModalClosedRuntime('runtime-options'); + + const append = createBuildAppendClipboardVideoToQueueMainDepsHandler({ + appendClipboardVideoToQueueRuntime: () => { + calls.push('append'); + return { ok: true, message: 'ok' }; + }, + getMpvClient: () => ({ connected: true }), + readClipboardText: () => '/tmp/v.mkv', + showMpvOsd: (text) => calls.push(`osd:${text}`), + sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`), + })(); + assert.deepEqual(append.appendClipboardVideoToQueueRuntime({ + getMpvClient: () => ({ connected: true }), + readClipboardText: () => '/tmp/v.mkv', + showMpvOsd: () => {}, + sendMpvCommand: () => {}, + }), { ok: true, message: 'ok' }); + assert.equal(append.readClipboardText(), '/tmp/v.mkv'); + assert.equal(typeof append.getMpvClient(), 'object'); + append.showMpvOsd('queued'); + append.sendMpvCommand(['loadfile', '/tmp/v.mkv', 'append']); + + assert.deepEqual(calls, [ + 'set:true', + 'toggle', + 'modal:runtime-options', + 'append', + 'osd:queued', + 'cmd:loadfile:/tmp/v.mkv:append', + ]); +}); diff --git a/src/main/runtime/overlay-main-actions-main-deps.ts b/src/main/runtime/overlay-main-actions-main-deps.ts new file mode 100644 index 0000000..387c6d4 --- /dev/null +++ b/src/main/runtime/overlay-main-actions-main-deps.ts @@ -0,0 +1,43 @@ +import type { + createAppendClipboardVideoToQueueHandler, + createHandleOverlayModalClosedHandler, + createSetOverlayVisibleHandler, + createToggleOverlayHandler, +} from './overlay-main-actions'; + +type SetOverlayVisibleMainDeps = Parameters[0]; +type ToggleOverlayMainDeps = Parameters[0]; +type HandleOverlayModalClosedMainDeps = Parameters[0]; +type AppendClipboardVideoToQueueMainDeps = Parameters[0]; + +export function createBuildSetOverlayVisibleMainDepsHandler(deps: SetOverlayVisibleMainDeps) { + return (): SetOverlayVisibleMainDeps => ({ + setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible), + }); +} + +export function createBuildToggleOverlayMainDepsHandler(deps: ToggleOverlayMainDeps) { + return (): ToggleOverlayMainDeps => ({ + toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + }); +} + +export function createBuildHandleOverlayModalClosedMainDepsHandler( + deps: HandleOverlayModalClosedMainDeps, +) { + return (): HandleOverlayModalClosedMainDeps => ({ + handleOverlayModalClosedRuntime: (modal) => deps.handleOverlayModalClosedRuntime(modal), + }); +} + +export function createBuildAppendClipboardVideoToQueueMainDepsHandler( + deps: AppendClipboardVideoToQueueMainDeps, +) { + return (): AppendClipboardVideoToQueueMainDeps => ({ + appendClipboardVideoToQueueRuntime: (options) => deps.appendClipboardVideoToQueueRuntime(options), + getMpvClient: () => deps.getMpvClient(), + readClipboardText: () => deps.readClipboardText(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), + }); +} diff --git a/src/main/runtime/overlay-main-actions.test.ts b/src/main/runtime/overlay-main-actions.test.ts new file mode 100644 index 0000000..0792d5c --- /dev/null +++ b/src/main/runtime/overlay-main-actions.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createAppendClipboardVideoToQueueHandler, + createHandleOverlayModalClosedHandler, + createSetOverlayVisibleHandler, + createToggleOverlayHandler, +} from './overlay-main-actions'; + +test('set overlay visible handler delegates to visible overlay setter', () => { + const calls: string[] = []; + const setOverlayVisible = createSetOverlayVisibleHandler({ + setVisibleOverlayVisible: (visible) => calls.push(`set:${visible}`), + }); + + setOverlayVisible(true); + assert.deepEqual(calls, ['set:true']); +}); + +test('toggle overlay handler delegates to visible toggle', () => { + const calls: string[] = []; + const toggleOverlay = createToggleOverlayHandler({ + toggleVisibleOverlay: () => calls.push('toggle'), + }); + + toggleOverlay(); + assert.deepEqual(calls, ['toggle']); +}); + +test('overlay modal closed handler delegates to runtime handler', () => { + const calls: string[] = []; + const handleClosed = createHandleOverlayModalClosedHandler({ + handleOverlayModalClosedRuntime: (modal) => calls.push(`closed:${modal}`), + }); + + handleClosed('runtime-options'); + assert.deepEqual(calls, ['closed:runtime-options']); +}); + +test('append clipboard queue handler forwards runtime deps and result', () => { + const calls: string[] = []; + const mpvClient = { connected: true }; + const appendClipboardVideoToQueue = createAppendClipboardVideoToQueueHandler({ + appendClipboardVideoToQueueRuntime: (options) => { + assert.equal(options.getMpvClient(), mpvClient); + assert.equal(options.readClipboardText(), '/tmp/video.mkv'); + options.showMpvOsd('queued'); + options.sendMpvCommand(['loadfile', '/tmp/video.mkv', 'append']); + return { ok: true, message: 'ok' }; + }, + getMpvClient: () => mpvClient, + readClipboardText: () => '/tmp/video.mkv', + showMpvOsd: (text) => calls.push(`osd:${text}`), + sendMpvCommand: (command) => calls.push(`mpv:${command.join(':')}`), + }); + + const result = appendClipboardVideoToQueue(); + assert.deepEqual(result, { ok: true, message: 'ok' }); + assert.deepEqual(calls, ['osd:queued', 'mpv:loadfile:/tmp/video.mkv:append']); +}); diff --git a/src/main/runtime/overlay-main-actions.ts b/src/main/runtime/overlay-main-actions.ts new file mode 100644 index 0000000..fc0b2a2 --- /dev/null +++ b/src/main/runtime/overlay-main-actions.ts @@ -0,0 +1,47 @@ +import type { OverlayHostedModal } from '../overlay-runtime'; +import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue'; + +export function createSetOverlayVisibleHandler(deps: { + setVisibleOverlayVisible: (visible: boolean) => void; +}) { + return (visible: boolean): void => { + deps.setVisibleOverlayVisible(visible); + }; +} + +export function createToggleOverlayHandler(deps: { + toggleVisibleOverlay: () => void; +}) { + return (): void => { + deps.toggleVisibleOverlay(); + }; +} + +export function createHandleOverlayModalClosedHandler(deps: { + handleOverlayModalClosedRuntime: (modal: OverlayHostedModal) => void; +}) { + return (modal: OverlayHostedModal): void => { + deps.handleOverlayModalClosedRuntime(modal); + }; +} + +export function createAppendClipboardVideoToQueueHandler(deps: { + appendClipboardVideoToQueueRuntime: ( + options: AppendClipboardVideoToQueueRuntimeDeps, + ) => { ok: boolean; message: string }; + getMpvClient: () => AppendClipboardVideoToQueueRuntimeDeps['getMpvClient'] extends () => infer T + ? T + : never; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + sendMpvCommand: (command: (string | number)[]) => void; +}) { + return (): { ok: boolean; message: string } => { + return deps.appendClipboardVideoToQueueRuntime({ + getMpvClient: () => deps.getMpvClient(), + readClipboardText: deps.readClipboardText, + showMpvOsd: deps.showMpvOsd, + sendMpvCommand: deps.sendMpvCommand, + }); + }; +} diff --git a/src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts b/src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts new file mode 100644 index 0000000..8ce8462 --- /dev/null +++ b/src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { BaseWindowTracker } from '../../window-trackers'; +import type { KikuFieldGroupingChoice } from '../../types'; +import { createOverlayRuntimeBootstrapHandlers } from './overlay-runtime-bootstrap-handlers'; + +test('overlay runtime bootstrap handlers compose options builder and bootstrap handler', () => { + const appState = { + backendOverride: null as string | null, + windowTracker: null as BaseWindowTracker | null, + subtitleTimingTracker: null as unknown, + mpvClient: null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null as unknown, + }; + let initialized = false; + let invisibleOverlayVisible = false; + let warmupsStarted = 0; + + const { initializeOverlayRuntime } = createOverlayRuntimeBootstrapHandlers({ + initializeOverlayRuntimeMainDeps: { + appState, + overlayManager: { + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => {}, + updateInvisibleOverlayVisibility: () => {}, + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => {}, + }, + getInitialInvisibleOverlayVisibility: () => false, + createMainWindow: () => {}, + createInvisibleWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + updateInvisibleOverlayBounds: () => {}, + getOverlayWindows: () => [], + getResolvedConfig: () => ({}), + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => + ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: true, + }) as KikuFieldGroupingChoice, + getKnownWordCacheStatePath: () => '/tmp/known.json', + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => initialized, + initializeOverlayRuntimeCore: () => ({ invisibleOverlayVisible: true }), + setInvisibleOverlayVisible: (visible) => { + invisibleOverlayVisible = visible; + }, + setOverlayRuntimeInitialized: (next) => { + initialized = next; + }, + startBackgroundWarmups: () => { + warmupsStarted += 1; + }, + }, + }); + + initializeOverlayRuntime(); + initializeOverlayRuntime(); + + assert.equal(invisibleOverlayVisible, true); + assert.equal(initialized, true); + assert.equal(warmupsStarted, 1); +}); diff --git a/src/main/runtime/overlay-runtime-bootstrap-handlers.ts b/src/main/runtime/overlay-runtime-bootstrap-handlers.ts new file mode 100644 index 0000000..21cbe43 --- /dev/null +++ b/src/main/runtime/overlay-runtime-bootstrap-handlers.ts @@ -0,0 +1,37 @@ +import { createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler } from './app-runtime-main-deps'; +import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap'; +import { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options'; +import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps'; + +type InitializeOverlayRuntimeMainDeps = Parameters< + typeof createBuildInitializeOverlayRuntimeMainDepsHandler +>[0]; +type InitializeOverlayRuntimeOptions = ReturnType< + ReturnType +>; +type InitializeOverlayRuntimeBootstrapMainDeps = Parameters< + typeof createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler +>[0]; + +export function createOverlayRuntimeBootstrapHandlers(deps: { + initializeOverlayRuntimeMainDeps: InitializeOverlayRuntimeMainDeps; + initializeOverlayRuntimeBootstrapDeps: Omit< + InitializeOverlayRuntimeBootstrapMainDeps, + 'buildOptions' + >; +}) { + const buildInitializeOverlayRuntimeOptionsHandler = + createBuildInitializeOverlayRuntimeOptionsHandler( + createBuildInitializeOverlayRuntimeMainDepsHandler(deps.initializeOverlayRuntimeMainDeps)(), + ); + const initializeOverlayRuntime = createInitializeOverlayRuntimeHandler( + createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({ + ...deps.initializeOverlayRuntimeBootstrapDeps, + buildOptions: () => buildInitializeOverlayRuntimeOptionsHandler(), + })(), + ); + + return { + initializeOverlayRuntime, + }; +} diff --git a/src/main/runtime/overlay-runtime-bootstrap.test.ts b/src/main/runtime/overlay-runtime-bootstrap.test.ts new file mode 100644 index 0000000..afe2228 --- /dev/null +++ b/src/main/runtime/overlay-runtime-bootstrap.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap'; + +test('overlay runtime bootstrap no-ops when already initialized', () => { + let coreCalls = 0; + const initialize = createInitializeOverlayRuntimeHandler({ + isOverlayRuntimeInitialized: () => true, + initializeOverlayRuntimeCore: () => { + coreCalls += 1; + return { invisibleOverlayVisible: false }; + }, + buildOptions: () => ({} as never), + setInvisibleOverlayVisible: () => {}, + setOverlayRuntimeInitialized: () => {}, + startBackgroundWarmups: () => {}, + }); + + initialize(); + assert.equal(coreCalls, 0); +}); + +test('overlay runtime bootstrap runs core init and applies post-init state', () => { + const calls: string[] = []; + let initialized = false; + const initialize = createInitializeOverlayRuntimeHandler({ + isOverlayRuntimeInitialized: () => initialized, + initializeOverlayRuntimeCore: () => { + calls.push('core'); + return { invisibleOverlayVisible: true }; + }, + buildOptions: () => { + calls.push('options'); + return {} as never; + }, + setInvisibleOverlayVisible: (visible) => { + calls.push(`invisible:${visible ? 'yes' : 'no'}`); + }, + setOverlayRuntimeInitialized: (value) => { + initialized = value; + calls.push(`initialized:${value ? 'yes' : 'no'}`); + }, + startBackgroundWarmups: () => { + calls.push('warmups'); + }, + }); + + initialize(); + assert.equal(initialized, true); + assert.deepEqual(calls, ['options', 'core', 'invisible:yes', 'initialized:yes', 'warmups']); +}); diff --git a/src/main/runtime/overlay-runtime-bootstrap.ts b/src/main/runtime/overlay-runtime-bootstrap.ts new file mode 100644 index 0000000..e05f7de --- /dev/null +++ b/src/main/runtime/overlay-runtime-bootstrap.ts @@ -0,0 +1,55 @@ +import type { BrowserWindow } from 'electron'; +import type { BaseWindowTracker } from '../../window-trackers'; +import type { + AnkiConnectConfig, + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, + WindowGeometry, +} from '../../types'; + +type InitializeOverlayRuntimeCore = (options: { + backendOverride: string | null; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + isVisibleOverlayVisible: () => boolean; + isInvisibleOverlayVisible: () => boolean; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + setWindowTracker: (tracker: BaseWindowTracker | null) => void; + getMpvSocketPath: () => string; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + 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; + getKnownWordCacheStatePath: () => string; +}) => { invisibleOverlayVisible: boolean }; + +export function createInitializeOverlayRuntimeHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore; + buildOptions: () => Parameters[0]; + setInvisibleOverlayVisible: (visible: boolean) => void; + setOverlayRuntimeInitialized: (initialized: boolean) => void; + startBackgroundWarmups: () => void; +}) { + return (): void => { + if (deps.isOverlayRuntimeInitialized()) return; + const result = deps.initializeOverlayRuntimeCore(deps.buildOptions()); + deps.setInvisibleOverlayVisible(result.invisibleOverlayVisible); + deps.setOverlayRuntimeInitialized(true); + deps.startBackgroundWarmups(); + }; +} diff --git a/src/main/runtime/overlay-runtime-main-actions-main-deps.test.ts b/src/main/runtime/overlay-runtime-main-actions-main-deps.test.ts new file mode 100644 index 0000000..4e50aef --- /dev/null +++ b/src/main/runtime/overlay-runtime-main-actions-main-deps.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildBroadcastRuntimeOptionsChangedMainDepsHandler, + createBuildGetRuntimeOptionsStateMainDepsHandler, + createBuildOpenRuntimeOptionsPaletteMainDepsHandler, + createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler, + createBuildSendToActiveOverlayWindowMainDepsHandler, + createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, +} from './overlay-runtime-main-actions-main-deps'; + +test('get runtime options state main deps builder maps callbacks', () => { + const manager = { listOptions: () => [] }; + const deps = createBuildGetRuntimeOptionsStateMainDepsHandler({ + getRuntimeOptionsManager: () => manager, + })(); + assert.equal(deps.getRuntimeOptionsManager(), manager); +}); + +test('restore secondary sub visibility main deps builder maps callbacks', () => { + const deps = createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({ + getMpvClient: () => ({ connected: true, restorePreviousSecondarySubVisibility: () => {} }), + })(); + assert.equal(deps.getMpvClient()?.connected, true); +}); + +test('broadcast runtime options changed main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ + broadcastRuntimeOptionsChangedRuntime: () => calls.push('broadcast-runtime'), + getRuntimeOptionsState: () => [], + broadcastToOverlayWindows: (channel) => calls.push(channel), + })(); + + deps.broadcastRuntimeOptionsChangedRuntime(() => [], () => {}); + deps.broadcastToOverlayWindows('runtime-options:changed'); + assert.deepEqual(deps.getRuntimeOptionsState(), []); + assert.deepEqual(calls, ['broadcast-runtime', 'runtime-options:changed']); +}); + +test('send to active overlay window main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildSendToActiveOverlayWindowMainDepsHandler({ + sendToActiveOverlayWindowRuntime: () => { + calls.push('send'); + return true; + }, + })(); + + assert.equal(deps.sendToActiveOverlayWindowRuntime('x'), true); + assert.deepEqual(calls, ['send']); +}); + +test('set overlay debug visualization main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({ + setOverlayDebugVisualizationEnabledRuntime: () => calls.push('set-runtime'), + getCurrentEnabled: () => false, + setCurrentEnabled: () => calls.push('set-current'), + broadcastToOverlayWindows: () => calls.push('broadcast'), + })(); + + deps.setOverlayDebugVisualizationEnabledRuntime(false, true, () => {}, () => {}); + assert.equal(deps.getCurrentEnabled(), false); + deps.setCurrentEnabled(true); + deps.broadcastToOverlayWindows('overlay:debug'); + assert.deepEqual(calls, ['set-runtime', 'set-current', 'broadcast']); +}); + +test('open runtime options palette main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildOpenRuntimeOptionsPaletteMainDepsHandler({ + openRuntimeOptionsPaletteRuntime: () => calls.push('open'), + })(); + + deps.openRuntimeOptionsPaletteRuntime(); + assert.deepEqual(calls, ['open']); +}); diff --git a/src/main/runtime/overlay-runtime-main-actions-main-deps.ts b/src/main/runtime/overlay-runtime-main-actions-main-deps.ts new file mode 100644 index 0000000..683c4ee --- /dev/null +++ b/src/main/runtime/overlay-runtime-main-actions-main-deps.ts @@ -0,0 +1,89 @@ +import { + createBroadcastRuntimeOptionsChangedHandler, + createGetRuntimeOptionsStateHandler, + createOpenRuntimeOptionsPaletteHandler, + createRestorePreviousSecondarySubVisibilityHandler, + createSendToActiveOverlayWindowHandler, + createSetOverlayDebugVisualizationEnabledHandler, +} from './overlay-runtime-main-actions'; + +type GetRuntimeOptionsStateMainDeps = Parameters[0]; +type RestorePreviousSecondarySubVisibilityMainDeps = Parameters< + typeof createRestorePreviousSecondarySubVisibilityHandler +>[0]; +type BroadcastRuntimeOptionsChangedMainDeps = Parameters< + typeof createBroadcastRuntimeOptionsChangedHandler +>[0]; +type SendToActiveOverlayWindowMainDeps = Parameters[0]; +type SetOverlayDebugVisualizationEnabledMainDeps = Parameters< + typeof createSetOverlayDebugVisualizationEnabledHandler +>[0]; +type OpenRuntimeOptionsPaletteMainDeps = Parameters[0]; + +export function createBuildGetRuntimeOptionsStateMainDepsHandler( + deps: GetRuntimeOptionsStateMainDeps, +) { + return (): GetRuntimeOptionsStateMainDeps => ({ + getRuntimeOptionsManager: () => deps.getRuntimeOptionsManager(), + }); +} + +export function createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler( + deps: RestorePreviousSecondarySubVisibilityMainDeps, +) { + return (): RestorePreviousSecondarySubVisibilityMainDeps => ({ + getMpvClient: () => deps.getMpvClient(), + }); +} + +export function createBuildBroadcastRuntimeOptionsChangedMainDepsHandler( + deps: BroadcastRuntimeOptionsChangedMainDeps, +) { + return (): BroadcastRuntimeOptionsChangedMainDeps => ({ + broadcastRuntimeOptionsChangedRuntime: (getRuntimeOptionsState, broadcastToOverlayWindows) => + deps.broadcastRuntimeOptionsChangedRuntime(getRuntimeOptionsState, broadcastToOverlayWindows), + getRuntimeOptionsState: () => deps.getRuntimeOptionsState(), + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => + deps.broadcastToOverlayWindows(channel, ...args), + }); +} + +export function createBuildSendToActiveOverlayWindowMainDepsHandler( + deps: SendToActiveOverlayWindowMainDeps, +) { + return (): SendToActiveOverlayWindowMainDeps => ({ + sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) => + deps.sendToActiveOverlayWindowRuntime(channel, payload, runtimeOptions), + }); +} + +export function createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler( + deps: SetOverlayDebugVisualizationEnabledMainDeps, +) { + return (): SetOverlayDebugVisualizationEnabledMainDeps => ({ + setOverlayDebugVisualizationEnabledRuntime: ( + currentEnabled, + nextEnabled, + setCurrentEnabled, + broadcastToOverlayWindows, + ) => + deps.setOverlayDebugVisualizationEnabledRuntime( + currentEnabled, + nextEnabled, + setCurrentEnabled, + broadcastToOverlayWindows, + ), + getCurrentEnabled: () => deps.getCurrentEnabled(), + setCurrentEnabled: (enabled: boolean) => deps.setCurrentEnabled(enabled), + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => + deps.broadcastToOverlayWindows(channel, ...args), + }); +} + +export function createBuildOpenRuntimeOptionsPaletteMainDepsHandler( + deps: OpenRuntimeOptionsPaletteMainDeps, +) { + return (): OpenRuntimeOptionsPaletteMainDeps => ({ + openRuntimeOptionsPaletteRuntime: () => deps.openRuntimeOptionsPaletteRuntime(), + }); +} diff --git a/src/main/runtime/overlay-runtime-main-actions.test.ts b/src/main/runtime/overlay-runtime-main-actions.test.ts new file mode 100644 index 0000000..f08c536 --- /dev/null +++ b/src/main/runtime/overlay-runtime-main-actions.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBroadcastRuntimeOptionsChangedHandler, + createGetRuntimeOptionsStateHandler, + createOpenRuntimeOptionsPaletteHandler, + createRestorePreviousSecondarySubVisibilityHandler, + createSendToActiveOverlayWindowHandler, + createSetOverlayDebugVisualizationEnabledHandler, +} from './overlay-runtime-main-actions'; + +test('runtime options state handler returns empty list without manager', () => { + const getState = createGetRuntimeOptionsStateHandler({ + getRuntimeOptionsManager: () => null, + }); + assert.deepEqual(getState(), []); +}); + +test('runtime options state handler returns list from manager', () => { + const getState = createGetRuntimeOptionsStateHandler({ + getRuntimeOptionsManager: () => + ({ + listOptions: () => [ + { + id: 'anki.autoUpdateNewCards', + label: 'X', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ], + }) as never, + }); + assert.deepEqual(getState(), [ + { + id: 'anki.autoUpdateNewCards', + label: 'X', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ]); +}); + +test('restore previous secondary subtitle visibility no-ops without connected mpv client', () => { + let restored = false; + const restore = createRestorePreviousSecondarySubVisibilityHandler({ + getMpvClient: () => ({ connected: false, restorePreviousSecondarySubVisibility: () => (restored = true) }), + }); + restore(); + assert.equal(restored, false); +}); + +test('restore previous secondary subtitle visibility calls runtime when connected', () => { + let restored = false; + const restore = createRestorePreviousSecondarySubVisibilityHandler({ + getMpvClient: () => ({ connected: true, restorePreviousSecondarySubVisibility: () => (restored = true) }), + }); + restore(); + assert.equal(restored, true); +}); + +test('broadcast runtime options changed passes through state getter and broadcaster', () => { + const calls: string[] = []; + const broadcast = createBroadcastRuntimeOptionsChangedHandler({ + broadcastRuntimeOptionsChangedRuntime: (getState, emit) => { + calls.push(`state:${JSON.stringify(getState())}`); + emit('runtime-options:changed', { id: 1 }); + }, + getRuntimeOptionsState: () => [ + { + id: 'anki.autoUpdateNewCards', + label: 'X', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ], + broadcastToOverlayWindows: (channel, payload) => calls.push(`emit:${channel}:${JSON.stringify(payload)}`), + }); + + broadcast(); + assert.deepEqual(calls, [ + 'state:[{"id":"anki.autoUpdateNewCards","label":"X","scope":"ankiConnect","valueType":"boolean","value":true,"allowedValues":[true,false],"requiresRestart":false}]', + 'emit:runtime-options:changed:{"id":1}', + ]); +}); + +test('send to active overlay window delegates to runtime sender', () => { + const send = createSendToActiveOverlayWindowHandler({ + sendToActiveOverlayWindowRuntime: (channel, payload) => channel === 'ok' && payload === 1, + }); + assert.equal(send('ok', 1), true); + assert.equal(send('no', 1), false); +}); + +test('set overlay debug visualization enabled delegates with current state and broadcast', () => { + const calls: string[] = []; + let current = false; + const setEnabled = createSetOverlayDebugVisualizationEnabledHandler({ + setOverlayDebugVisualizationEnabledRuntime: (curr, next, setCurrent, broadcast) => { + calls.push(`runtime:${curr}->${next}`); + setCurrent(next); + broadcast('overlay-debug:set', next); + }, + getCurrentEnabled: () => current, + setCurrentEnabled: (enabled) => { + current = enabled; + calls.push(`set:${enabled}`); + }, + broadcastToOverlayWindows: (channel, value) => calls.push(`emit:${channel}:${value}`), + }); + + setEnabled(true); + assert.equal(current, true); + assert.deepEqual(calls, ['runtime:false->true', 'set:true', 'emit:overlay-debug:set:true']); +}); + +test('open runtime options palette handler delegates to runtime', () => { + let opened = false; + const open = createOpenRuntimeOptionsPaletteHandler({ + openRuntimeOptionsPaletteRuntime: () => { + opened = true; + }, + }); + open(); + assert.equal(opened, true); +}); diff --git a/src/main/runtime/overlay-runtime-main-actions.ts b/src/main/runtime/overlay-runtime-main-actions.ts new file mode 100644 index 0000000..574195e --- /dev/null +++ b/src/main/runtime/overlay-runtime-main-actions.ts @@ -0,0 +1,90 @@ +import type { RuntimeOptionState } from '../../types'; +import type { OverlayHostedModal } from '../overlay-runtime'; + +type RuntimeOptionsManagerLike = { + listOptions: () => RuntimeOptionState[]; +}; + +type MpvClientLike = { + connected: boolean; + restorePreviousSecondarySubVisibility: () => void; +}; + +export function createGetRuntimeOptionsStateHandler(deps: { + getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; +}) { + return (): RuntimeOptionState[] => { + const manager = deps.getRuntimeOptionsManager(); + if (!manager) return []; + return manager.listOptions(); + }; +} + +export function createRestorePreviousSecondarySubVisibilityHandler(deps: { + getMpvClient: () => MpvClientLike | null; +}) { + return (): void => { + const client = deps.getMpvClient(); + if (!client || !client.connected) return; + client.restorePreviousSecondarySubVisibility(); + }; +} + +export function createBroadcastRuntimeOptionsChangedHandler(deps: { + broadcastRuntimeOptionsChangedRuntime: ( + getRuntimeOptionsState: () => RuntimeOptionState[], + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, + ) => void; + getRuntimeOptionsState: () => RuntimeOptionState[]; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; +}) { + return (): void => { + deps.broadcastRuntimeOptionsChangedRuntime( + () => deps.getRuntimeOptionsState(), + (channel, ...args) => deps.broadcastToOverlayWindows(channel, ...args), + ); + }; +} + +export function createSendToActiveOverlayWindowHandler(deps: { + sendToActiveOverlayWindowRuntime: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + ) => boolean; +}) { + return ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + ): boolean => deps.sendToActiveOverlayWindowRuntime(channel, payload, runtimeOptions); +} + +export function createSetOverlayDebugVisualizationEnabledHandler(deps: { + setOverlayDebugVisualizationEnabledRuntime: ( + currentEnabled: boolean, + nextEnabled: boolean, + setCurrentEnabled: (enabled: boolean) => void, + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, + ) => void; + getCurrentEnabled: () => boolean; + setCurrentEnabled: (enabled: boolean) => void; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; +}) { + return (enabled: boolean): void => { + deps.setOverlayDebugVisualizationEnabledRuntime( + deps.getCurrentEnabled(), + enabled, + (next) => deps.setCurrentEnabled(next), + (channel, ...args) => deps.broadcastToOverlayWindows(channel, ...args), + ); + }; +} + +export function createOpenRuntimeOptionsPaletteHandler(deps: { + openRuntimeOptionsPaletteRuntime: () => void; +}) { + return (): void => { + deps.openRuntimeOptionsPaletteRuntime(); + }; +} diff --git a/src/main/runtime/overlay-runtime-options-main-deps.test.ts b/src/main/runtime/overlay-runtime-options-main-deps.test.ts new file mode 100644 index 0000000..8e15506 --- /dev/null +++ b/src/main/runtime/overlay-runtime-options-main-deps.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { BaseWindowTracker } from '../../window-trackers'; +import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps'; + +test('overlay runtime main deps builder maps runtime state and callbacks', () => { + const calls: string[] = []; + const appState = { + backendOverride: 'x11' as string | null, + windowTracker: null as BaseWindowTracker | null, + subtitleTimingTracker: { id: 'tracker' } as unknown, + mpvClient: null as { send?: (payload: { command: string[] }) => void } | null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null as unknown, + }; + + const build = createBuildInitializeOverlayRuntimeMainDepsHandler({ + appState, + overlayManager: { + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => calls.push('update-visible'), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => calls.push('sync-shortcuts'), + }, + getInitialInvisibleOverlayVisibility: () => true, + createMainWindow: () => calls.push('create-main'), + createInvisibleWindow: () => calls.push('create-invisible'), + registerGlobalShortcuts: () => calls.push('register-shortcuts'), + updateVisibleOverlayBounds: () => calls.push('visible-bounds'), + updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'), + getOverlayWindows: () => [], + getResolvedConfig: () => ({}), + showDesktopNotification: () => calls.push('notify'), + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: true, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + const deps = build(); + assert.equal(deps.getBackendOverride(), 'x11'); + assert.equal(deps.getInitialInvisibleOverlayVisibility(), true); + assert.equal(deps.isVisibleOverlayVisible(), true); + assert.equal(deps.isInvisibleOverlayVisible(), false); + assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock'); + assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json'); + + deps.createMainWindow(); + deps.createInvisibleWindow(); + deps.registerGlobalShortcuts(); + deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.updateVisibleOverlayVisibility(); + deps.updateInvisibleOverlayVisibility(); + deps.syncOverlayShortcuts(); + deps.showDesktopNotification('title', {}); + + const tracker = { + close: () => {}, + getWindowGeometry: () => null, + } as unknown as BaseWindowTracker; + deps.setWindowTracker(tracker); + deps.setAnkiIntegration({ id: 'anki' }); + + assert.deepEqual(calls, [ + 'create-main', + 'create-invisible', + 'register-shortcuts', + 'visible-bounds', + 'invisible-bounds', + 'update-visible', + 'update-invisible', + 'sync-shortcuts', + 'notify', + ]); + assert.equal(appState.windowTracker, tracker); + assert.deepEqual(appState.ankiIntegration, { id: 'anki' }); +}); diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts new file mode 100644 index 0000000..b088d1b --- /dev/null +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -0,0 +1,92 @@ +import type { AnkiConnectConfig } from '../../types'; +import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options'; + +type OverlayRuntimeOptionsMainDeps = Parameters< + typeof createBuildInitializeOverlayRuntimeOptionsHandler +>[0]; + +export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { + appState: { + backendOverride: string | null; + windowTracker: Parameters[0]; + subtitleTimingTracker: ReturnType; + mpvClient: ReturnType; + mpvSocketPath: string; + runtimeOptionsManager: ReturnType; + ankiIntegration: Parameters[0]; + }; + overlayManager: { + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + }; + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + }; + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => void; + }; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: { + x: number; + y: number; + width: number; + height: number; + }) => void; + updateInvisibleOverlayBounds: (geometry: { + x: number; + y: number; + width: number; + height: number; + }) => void; + getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows']; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; + getKnownWordCacheStatePath: () => string; +}) { + return (): OverlayRuntimeOptionsMainDeps => ({ + getBackendOverride: () => deps.appState.backendOverride, + getInitialInvisibleOverlayVisibility: () => deps.getInitialInvisibleOverlayVisibility(), + createMainWindow: () => deps.createMainWindow(), + createInvisibleWindow: () => deps.createInvisibleWindow(), + registerGlobalShortcuts: () => deps.registerGlobalShortcuts(), + updateVisibleOverlayBounds: (geometry: { + x: number; + y: number; + width: number; + height: number; + }) => deps.updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry: { + x: number; + y: number; + width: number; + height: number; + }) => deps.updateInvisibleOverlayBounds(geometry), + isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), + isInvisibleOverlayVisible: () => deps.overlayManager.getInvisibleOverlayVisible(), + updateVisibleOverlayVisibility: () => + deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => + deps.overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + getOverlayWindows: () => deps.getOverlayWindows(), + syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(), + setWindowTracker: (tracker) => { + deps.appState.windowTracker = tracker; + }, + getResolvedConfig: () => deps.getResolvedConfig(), + getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker, + getMpvClient: () => deps.appState.mpvClient, + getMpvSocketPath: () => deps.appState.mpvSocketPath, + getRuntimeOptionsManager: () => deps.appState.runtimeOptionsManager, + setAnkiIntegration: (integration) => { + deps.appState.ankiIntegration = integration; + }, + showDesktopNotification: deps.showDesktopNotification, + createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), + }); +} diff --git a/src/main/runtime/overlay-runtime-options.test.ts b/src/main/runtime/overlay-runtime-options.test.ts new file mode 100644 index 0000000..9f8d853 --- /dev/null +++ b/src/main/runtime/overlay-runtime-options.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options'; + +test('build initialize overlay runtime options maps dependencies', () => { + const calls: string[] = []; + const buildOptions = createBuildInitializeOverlayRuntimeOptionsHandler({ + getBackendOverride: () => 'x11', + getInitialInvisibleOverlayVisibility: () => true, + createMainWindow: () => calls.push('create-main'), + createInvisibleWindow: () => calls.push('create-invisible'), + registerGlobalShortcuts: () => calls.push('register-shortcuts'), + updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'), + updateInvisibleOverlayBounds: () => calls.push('update-invisible-bounds'), + isVisibleOverlayVisible: () => true, + isInvisibleOverlayVisible: () => false, + updateVisibleOverlayVisibility: () => calls.push('update-visible'), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + getOverlayWindows: () => [], + syncOverlayShortcuts: () => calls.push('sync-shortcuts'), + setWindowTracker: () => calls.push('set-tracker'), + getResolvedConfig: () => ({}), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getMpvSocketPath: () => '/tmp/mpv.sock', + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => calls.push('set-anki'), + showDesktopNotification: () => calls.push('notify'), + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + const options = buildOptions(); + assert.equal(options.backendOverride, 'x11'); + assert.equal(options.getInitialInvisibleOverlayVisibility(), true); + assert.equal(options.isVisibleOverlayVisible(), true); + assert.equal(options.isInvisibleOverlayVisible(), false); + assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock'); + assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json'); + options.createMainWindow(); + options.createInvisibleWindow(); + options.registerGlobalShortcuts(); + options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + options.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncOverlayShortcuts(); + options.setWindowTracker(null); + options.setAnkiIntegration(null); + options.showDesktopNotification('title', {}); + + assert.deepEqual(calls, [ + 'create-main', + 'create-invisible', + 'register-shortcuts', + 'update-visible-bounds', + 'update-invisible-bounds', + 'update-visible', + 'update-invisible', + 'sync-shortcuts', + 'set-tracker', + 'set-anki', + 'notify', + ]); +}); diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts new file mode 100644 index 0000000..a6873a4 --- /dev/null +++ b/src/main/runtime/overlay-runtime-options.ts @@ -0,0 +1,94 @@ +import type { + AnkiConnectConfig, + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, + WindowGeometry, +} from '../../types'; +import type { BrowserWindow } from 'electron'; +import type { BaseWindowTracker } from '../../window-trackers'; + +type OverlayRuntimeOptions = { + backendOverride: string | null; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + isVisibleOverlayVisible: () => boolean; + isInvisibleOverlayVisible: () => boolean; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + setWindowTracker: (tracker: BaseWindowTracker | null) => void; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + getSubtitleTimingTracker: () => unknown | null; + getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null; + getMpvSocketPath: () => string; + getRuntimeOptionsManager: () => { + getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; + } | null; + setAnkiIntegration: (integration: unknown | null) => void; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getKnownWordCacheStatePath: () => string; +}; + +export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { + getBackendOverride: () => string | null; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + isVisibleOverlayVisible: () => boolean; + isInvisibleOverlayVisible: () => boolean; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + setWindowTracker: (tracker: BaseWindowTracker | null) => void; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + getSubtitleTimingTracker: () => unknown | null; + getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null; + getMpvSocketPath: () => string; + getRuntimeOptionsManager: () => { + getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; + } | null; + setAnkiIntegration: (integration: unknown | null) => void; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getKnownWordCacheStatePath: () => string; +}) { + return (): OverlayRuntimeOptions => ({ + backendOverride: deps.getBackendOverride(), + getInitialInvisibleOverlayVisibility: deps.getInitialInvisibleOverlayVisibility, + createMainWindow: deps.createMainWindow, + createInvisibleWindow: deps.createInvisibleWindow, + registerGlobalShortcuts: deps.registerGlobalShortcuts, + updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds, + updateInvisibleOverlayBounds: deps.updateInvisibleOverlayBounds, + isVisibleOverlayVisible: deps.isVisibleOverlayVisible, + isInvisibleOverlayVisible: deps.isInvisibleOverlayVisible, + updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, + updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, + getOverlayWindows: deps.getOverlayWindows, + syncOverlayShortcuts: deps.syncOverlayShortcuts, + setWindowTracker: deps.setWindowTracker, + getResolvedConfig: deps.getResolvedConfig, + getSubtitleTimingTracker: deps.getSubtitleTimingTracker, + getMpvClient: deps.getMpvClient, + getMpvSocketPath: deps.getMpvSocketPath, + getRuntimeOptionsManager: deps.getRuntimeOptionsManager, + setAnkiIntegration: deps.setAnkiIntegration, + showDesktopNotification: deps.showDesktopNotification, + createFieldGroupingCallback: deps.createFieldGroupingCallback, + getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, + }); +} diff --git a/src/main/runtime/overlay-shortcuts-lifecycle-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-lifecycle-main-deps.test.ts new file mode 100644 index 0000000..f5c7e37 --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-lifecycle-main-deps.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildRefreshOverlayShortcutsMainDepsHandler, + createBuildRegisterOverlayShortcutsMainDepsHandler, + createBuildSyncOverlayShortcutsMainDepsHandler, + createBuildUnregisterOverlayShortcutsMainDepsHandler, +} from './overlay-shortcuts-lifecycle-main-deps'; + +test('overlay shortcuts lifecycle main deps builders map runtime instance', () => { + const runtime = { + registerOverlayShortcuts: () => {}, + unregisterOverlayShortcuts: () => {}, + syncOverlayShortcuts: () => {}, + refreshOverlayShortcuts: () => {}, + }; + + const register = createBuildRegisterOverlayShortcutsMainDepsHandler({ + overlayShortcutsRuntime: runtime, + })(); + const unregister = createBuildUnregisterOverlayShortcutsMainDepsHandler({ + overlayShortcutsRuntime: runtime, + })(); + const sync = createBuildSyncOverlayShortcutsMainDepsHandler({ + overlayShortcutsRuntime: runtime, + })(); + const refresh = createBuildRefreshOverlayShortcutsMainDepsHandler({ + overlayShortcutsRuntime: runtime, + })(); + + assert.equal(register.overlayShortcutsRuntime, runtime); + assert.equal(unregister.overlayShortcutsRuntime, runtime); + assert.equal(sync.overlayShortcutsRuntime, runtime); + assert.equal(refresh.overlayShortcutsRuntime, runtime); +}); diff --git a/src/main/runtime/overlay-shortcuts-lifecycle-main-deps.ts b/src/main/runtime/overlay-shortcuts-lifecycle-main-deps.ts new file mode 100644 index 0000000..25814ac --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-lifecycle-main-deps.ts @@ -0,0 +1,41 @@ +import type { + createRefreshOverlayShortcutsHandler, + createRegisterOverlayShortcutsHandler, + createSyncOverlayShortcutsHandler, + createUnregisterOverlayShortcutsHandler, +} from './overlay-shortcuts-lifecycle'; + +type RegisterOverlayShortcutsMainDeps = Parameters[0]; +type UnregisterOverlayShortcutsMainDeps = Parameters[0]; +type SyncOverlayShortcutsMainDeps = Parameters[0]; +type RefreshOverlayShortcutsMainDeps = Parameters[0]; + +export function createBuildRegisterOverlayShortcutsMainDepsHandler( + deps: RegisterOverlayShortcutsMainDeps, +) { + return (): RegisterOverlayShortcutsMainDeps => ({ + overlayShortcutsRuntime: deps.overlayShortcutsRuntime, + }); +} + +export function createBuildUnregisterOverlayShortcutsMainDepsHandler( + deps: UnregisterOverlayShortcutsMainDeps, +) { + return (): UnregisterOverlayShortcutsMainDeps => ({ + overlayShortcutsRuntime: deps.overlayShortcutsRuntime, + }); +} + +export function createBuildSyncOverlayShortcutsMainDepsHandler(deps: SyncOverlayShortcutsMainDeps) { + return (): SyncOverlayShortcutsMainDeps => ({ + overlayShortcutsRuntime: deps.overlayShortcutsRuntime, + }); +} + +export function createBuildRefreshOverlayShortcutsMainDepsHandler( + deps: RefreshOverlayShortcutsMainDeps, +) { + return (): RefreshOverlayShortcutsMainDeps => ({ + overlayShortcutsRuntime: deps.overlayShortcutsRuntime, + }); +} diff --git a/src/main/runtime/overlay-shortcuts-lifecycle.test.ts b/src/main/runtime/overlay-shortcuts-lifecycle.test.ts new file mode 100644 index 0000000..b4fa970 --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-lifecycle.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createRefreshOverlayShortcutsHandler, + createRegisterOverlayShortcutsHandler, + createSyncOverlayShortcutsHandler, + createUnregisterOverlayShortcutsHandler, +} from './overlay-shortcuts-lifecycle'; + +function createRuntime(calls: string[]) { + return { + registerOverlayShortcuts: () => calls.push('register'), + unregisterOverlayShortcuts: () => calls.push('unregister'), + syncOverlayShortcuts: () => calls.push('sync'), + refreshOverlayShortcuts: () => calls.push('refresh'), + }; +} + +test('register overlay shortcuts handler delegates to runtime', () => { + const calls: string[] = []; + createRegisterOverlayShortcutsHandler({ + overlayShortcutsRuntime: createRuntime(calls), + })(); + assert.deepEqual(calls, ['register']); +}); + +test('unregister overlay shortcuts handler delegates to runtime', () => { + const calls: string[] = []; + createUnregisterOverlayShortcutsHandler({ + overlayShortcutsRuntime: createRuntime(calls), + })(); + assert.deepEqual(calls, ['unregister']); +}); + +test('sync overlay shortcuts handler delegates to runtime', () => { + const calls: string[] = []; + createSyncOverlayShortcutsHandler({ + overlayShortcutsRuntime: createRuntime(calls), + })(); + assert.deepEqual(calls, ['sync']); +}); + +test('refresh overlay shortcuts handler delegates to runtime', () => { + const calls: string[] = []; + createRefreshOverlayShortcutsHandler({ + overlayShortcutsRuntime: createRuntime(calls), + })(); + assert.deepEqual(calls, ['refresh']); +}); diff --git a/src/main/runtime/overlay-shortcuts-lifecycle.ts b/src/main/runtime/overlay-shortcuts-lifecycle.ts new file mode 100644 index 0000000..baf80dd --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-lifecycle.ts @@ -0,0 +1,38 @@ +type OverlayShortcutsRuntimeLike = { + registerOverlayShortcuts: () => void; + unregisterOverlayShortcuts: () => void; + syncOverlayShortcuts: () => void; + refreshOverlayShortcuts: () => void; +}; + +export function createRegisterOverlayShortcutsHandler(deps: { + overlayShortcutsRuntime: OverlayShortcutsRuntimeLike; +}) { + return (): void => { + deps.overlayShortcutsRuntime.registerOverlayShortcuts(); + }; +} + +export function createUnregisterOverlayShortcutsHandler(deps: { + overlayShortcutsRuntime: OverlayShortcutsRuntimeLike; +}) { + return (): void => { + deps.overlayShortcutsRuntime.unregisterOverlayShortcuts(); + }; +} + +export function createSyncOverlayShortcutsHandler(deps: { + overlayShortcutsRuntime: OverlayShortcutsRuntimeLike; +}) { + return (): void => { + deps.overlayShortcutsRuntime.syncOverlayShortcuts(); + }; +} + +export function createRefreshOverlayShortcutsHandler(deps: { + overlayShortcutsRuntime: OverlayShortcutsRuntimeLike; +}) { + return (): void => { + deps.overlayShortcutsRuntime.refreshOverlayShortcuts(); + }; +} diff --git a/src/main/runtime/overlay-shortcuts-runtime-handlers.test.ts b/src/main/runtime/overlay-shortcuts-runtime-handlers.test.ts new file mode 100644 index 0000000..8db02d3 --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-runtime-handlers.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createOverlayShortcutsRuntimeHandlers } from './overlay-shortcuts-runtime-handlers'; + +test('overlay shortcuts runtime handlers compose lifecycle handlers', () => { + const calls: string[] = []; + const runtime = createOverlayShortcutsRuntimeHandlers({ + overlayShortcutsRuntimeMainDeps: { + overlayShortcutsRuntime: { + registerOverlayShortcuts: () => calls.push('register'), + unregisterOverlayShortcuts: () => calls.push('unregister'), + syncOverlayShortcuts: () => calls.push('sync'), + refreshOverlayShortcuts: () => calls.push('refresh'), + }, + }, + }); + + runtime.registerOverlayShortcuts(); + runtime.unregisterOverlayShortcuts(); + runtime.syncOverlayShortcuts(); + runtime.refreshOverlayShortcuts(); + + assert.deepEqual(calls, ['register', 'unregister', 'sync', 'refresh']); +}); diff --git a/src/main/runtime/overlay-shortcuts-runtime-handlers.ts b/src/main/runtime/overlay-shortcuts-runtime-handlers.ts new file mode 100644 index 0000000..03624ff --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-runtime-handlers.ts @@ -0,0 +1,51 @@ +import { + createRefreshOverlayShortcutsHandler, + createRegisterOverlayShortcutsHandler, + createSyncOverlayShortcutsHandler, + createUnregisterOverlayShortcutsHandler, +} from './overlay-shortcuts-lifecycle'; +import { + createBuildRefreshOverlayShortcutsMainDepsHandler, + createBuildRegisterOverlayShortcutsMainDepsHandler, + createBuildSyncOverlayShortcutsMainDepsHandler, + createBuildUnregisterOverlayShortcutsMainDepsHandler, +} from './overlay-shortcuts-lifecycle-main-deps'; + +type RegisterOverlayShortcutsMainDeps = Parameters< + typeof createBuildRegisterOverlayShortcutsMainDepsHandler +>[0]; + +export function createOverlayShortcutsRuntimeHandlers(deps: { + overlayShortcutsRuntimeMainDeps: RegisterOverlayShortcutsMainDeps; +}) { + const registerOverlayShortcutsMainDeps = createBuildRegisterOverlayShortcutsMainDepsHandler( + deps.overlayShortcutsRuntimeMainDeps, + )(); + const registerOverlayShortcutsHandler = + createRegisterOverlayShortcutsHandler(registerOverlayShortcutsMainDeps); + + const unregisterOverlayShortcutsMainDeps = + createBuildUnregisterOverlayShortcutsMainDepsHandler( + deps.overlayShortcutsRuntimeMainDeps, + )(); + const unregisterOverlayShortcutsHandler = + createUnregisterOverlayShortcutsHandler(unregisterOverlayShortcutsMainDeps); + + const syncOverlayShortcutsMainDeps = createBuildSyncOverlayShortcutsMainDepsHandler( + deps.overlayShortcutsRuntimeMainDeps, + )(); + const syncOverlayShortcutsHandler = createSyncOverlayShortcutsHandler(syncOverlayShortcutsMainDeps); + + const refreshOverlayShortcutsMainDeps = createBuildRefreshOverlayShortcutsMainDepsHandler( + deps.overlayShortcutsRuntimeMainDeps, + )(); + const refreshOverlayShortcutsHandler = + createRefreshOverlayShortcutsHandler(refreshOverlayShortcutsMainDeps); + + return { + registerOverlayShortcuts: () => registerOverlayShortcutsHandler(), + unregisterOverlayShortcuts: () => unregisterOverlayShortcutsHandler(), + syncOverlayShortcuts: () => syncOverlayShortcutsHandler(), + refreshOverlayShortcuts: () => refreshOverlayShortcutsHandler(), + }; +} diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts new file mode 100644 index 0000000..f83c15c --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './overlay-shortcuts-runtime-main-deps'; + +test('overlay shortcuts runtime main deps builder maps lifecycle and action callbacks', async () => { + const calls: string[] = []; + let shortcutsRegistered = false; + const deps = createBuildOverlayShortcutsRuntimeMainDepsHandler({ + getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never), + getShortcutsRegistered: () => shortcutsRegistered, + setShortcutsRegistered: (registered) => { + shortcutsRegistered = registered; + calls.push(`registered:${registered}`); + }, + isOverlayRuntimeInitialized: () => true, + showMpvOsd: (text) => calls.push(`osd:${text}`), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openJimaku: () => calls.push('jimaku'), + markAudioCard: async () => { + calls.push('mark-audio'); + }, + copySubtitleMultiple: (timeoutMs) => calls.push(`copy-multi:${timeoutMs}`), + copySubtitle: () => calls.push('copy'), + toggleSecondarySubMode: () => calls.push('toggle-sub'), + updateLastCardFromClipboard: async () => { + calls.push('update-last-card'); + }, + triggerFieldGrouping: async () => { + calls.push('field-grouping'); + }, + triggerSubsyncFromConfig: async () => { + calls.push('subsync'); + }, + mineSentenceCard: async () => { + calls.push('mine'); + }, + mineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`), + cancelPendingMultiCopy: () => calls.push('cancel-copy'), + cancelPendingMineSentenceMultiple: () => calls.push('cancel-mine'), + })(); + + assert.equal(deps.isOverlayRuntimeInitialized(), true); + assert.equal(deps.getShortcutsRegistered(), false); + deps.setShortcutsRegistered(true); + assert.equal(shortcutsRegistered, true); + deps.showMpvOsd('x'); + deps.openRuntimeOptionsPalette(); + deps.openJimaku(); + await deps.markAudioCard(); + deps.copySubtitleMultiple(5000); + deps.copySubtitle(); + deps.toggleSecondarySubMode(); + await deps.updateLastCardFromClipboard(); + await deps.triggerFieldGrouping(); + await deps.triggerSubsyncFromConfig(); + await deps.mineSentenceCard(); + deps.mineSentenceMultiple(3000); + deps.cancelPendingMultiCopy(); + deps.cancelPendingMineSentenceMultiple(); + assert.deepEqual(calls, [ + 'registered:true', + 'osd:x', + 'runtime-options', + 'jimaku', + 'mark-audio', + 'copy-multi:5000', + 'copy', + 'toggle-sub', + 'update-last-card', + 'field-grouping', + 'subsync', + 'mine', + 'mine-multi:3000', + 'cancel-copy', + 'cancel-mine', + ]); +}); diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts new file mode 100644 index 0000000..ac0dfa3 --- /dev/null +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts @@ -0,0 +1,26 @@ +import type { OverlayShortcutRuntimeServiceInput } from '../overlay-shortcuts-runtime'; + +export function createBuildOverlayShortcutsRuntimeMainDepsHandler( + deps: OverlayShortcutRuntimeServiceInput, +) { + return (): OverlayShortcutRuntimeServiceInput => ({ + getConfiguredShortcuts: () => deps.getConfiguredShortcuts(), + getShortcutsRegistered: () => deps.getShortcutsRegistered(), + setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered), + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + openJimaku: () => deps.openJimaku(), + markAudioCard: () => deps.markAudioCard(), + copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs), + copySubtitle: () => deps.copySubtitle(), + toggleSecondarySubMode: () => deps.toggleSecondarySubMode(), + updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(), + triggerFieldGrouping: () => deps.triggerFieldGrouping(), + triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), + mineSentenceCard: () => deps.mineSentenceCard(), + mineSentenceMultiple: (timeoutMs: number) => deps.mineSentenceMultiple(timeoutMs), + cancelPendingMultiCopy: () => deps.cancelPendingMultiCopy(), + cancelPendingMineSentenceMultiple: () => deps.cancelPendingMineSentenceMultiple(), + }); +} diff --git a/src/main/runtime/overlay-visibility-actions-main-deps.test.ts b/src/main/runtime/overlay-visibility-actions-main-deps.test.ts new file mode 100644 index 0000000..c2f5f05 --- /dev/null +++ b/src/main/runtime/overlay-visibility-actions-main-deps.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildSetInvisibleOverlayVisibleMainDepsHandler, + createBuildSetVisibleOverlayVisibleMainDepsHandler, + createBuildToggleInvisibleOverlayMainDepsHandler, + createBuildToggleVisibleOverlayMainDepsHandler, +} from './overlay-visibility-actions-main-deps'; + +test('overlay visibility action main deps builders map callbacks', () => { + const calls: string[] = []; + + const setVisible = createBuildSetVisibleOverlayVisibleMainDepsHandler({ + setVisibleOverlayVisibleCore: () => calls.push('visible-core'), + setVisibleOverlayVisibleState: (visible) => calls.push(`visible-state:${visible}`), + updateVisibleOverlayVisibility: () => calls.push('update-visible'), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + syncInvisibleOverlayMousePassthrough: () => calls.push('sync'), + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isMpvConnected: () => true, + setMpvSubVisibility: (visible) => calls.push(`mpv:${visible}`), + })(); + setVisible.setVisibleOverlayVisibleCore({ + visible: true, + setVisibleOverlayVisibleState: () => {}, + updateVisibleOverlayVisibility: () => {}, + updateInvisibleOverlayVisibility: () => {}, + syncInvisibleOverlayMousePassthrough: () => {}, + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isMpvConnected: () => true, + setMpvSubVisibility: () => {}, + }); + setVisible.setVisibleOverlayVisibleState(true); + setVisible.updateVisibleOverlayVisibility(); + setVisible.updateInvisibleOverlayVisibility(); + setVisible.syncInvisibleOverlayMousePassthrough(); + assert.equal(setVisible.shouldBindVisibleOverlayToMpvSubVisibility(), true); + assert.equal(setVisible.isMpvConnected(), true); + setVisible.setMpvSubVisibility(false); + + const setInvisible = createBuildSetInvisibleOverlayVisibleMainDepsHandler({ + setInvisibleOverlayVisibleCore: () => calls.push('invisible-core'), + setInvisibleOverlayVisibleState: (visible) => calls.push(`invisible-state:${visible}`), + updateInvisibleOverlayVisibility: () => calls.push('update-only-invisible'), + syncInvisibleOverlayMousePassthrough: () => calls.push('sync-only'), + })(); + setInvisible.setInvisibleOverlayVisibleCore({ + visible: false, + setInvisibleOverlayVisibleState: () => {}, + updateInvisibleOverlayVisibility: () => {}, + syncInvisibleOverlayMousePassthrough: () => {}, + }); + setInvisible.setInvisibleOverlayVisibleState(false); + setInvisible.updateInvisibleOverlayVisibility(); + setInvisible.syncInvisibleOverlayMousePassthrough(); + + const toggleVisible = createBuildToggleVisibleOverlayMainDepsHandler({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: (visible) => calls.push(`toggle-visible:${visible}`), + })(); + assert.equal(toggleVisible.getVisibleOverlayVisible(), false); + toggleVisible.setVisibleOverlayVisible(true); + + const toggleInvisible = createBuildToggleInvisibleOverlayMainDepsHandler({ + getInvisibleOverlayVisible: () => true, + setInvisibleOverlayVisible: (visible) => calls.push(`toggle-invisible:${visible}`), + })(); + assert.equal(toggleInvisible.getInvisibleOverlayVisible(), true); + toggleInvisible.setInvisibleOverlayVisible(false); + + assert.deepEqual(calls, [ + 'visible-core', + 'visible-state:true', + 'update-visible', + 'update-invisible', + 'sync', + 'mpv:false', + 'invisible-core', + 'invisible-state:false', + 'update-only-invisible', + 'sync-only', + 'toggle-visible:true', + 'toggle-invisible:false', + ]); +}); diff --git a/src/main/runtime/overlay-visibility-actions-main-deps.ts b/src/main/runtime/overlay-visibility-actions-main-deps.ts new file mode 100644 index 0000000..f4b9941 --- /dev/null +++ b/src/main/runtime/overlay-visibility-actions-main-deps.ts @@ -0,0 +1,53 @@ +import type { + createSetInvisibleOverlayVisibleHandler, + createSetVisibleOverlayVisibleHandler, + createToggleInvisibleOverlayHandler, + createToggleVisibleOverlayHandler, +} from './overlay-visibility-actions'; + +type SetVisibleOverlayVisibleMainDeps = Parameters[0]; +type SetInvisibleOverlayVisibleMainDeps = Parameters[0]; +type ToggleVisibleOverlayMainDeps = Parameters[0]; +type ToggleInvisibleOverlayMainDeps = Parameters[0]; + +export function createBuildSetVisibleOverlayVisibleMainDepsHandler( + deps: SetVisibleOverlayVisibleMainDeps, +) { + return (): SetVisibleOverlayVisibleMainDeps => ({ + setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options), + setVisibleOverlayVisibleState: (visible: boolean) => deps.setVisibleOverlayVisibleState(visible), + updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => deps.syncInvisibleOverlayMousePassthrough(), + shouldBindVisibleOverlayToMpvSubVisibility: () => deps.shouldBindVisibleOverlayToMpvSubVisibility(), + isMpvConnected: () => deps.isMpvConnected(), + setMpvSubVisibility: (visible: boolean) => deps.setMpvSubVisibility(visible), + }); +} + +export function createBuildSetInvisibleOverlayVisibleMainDepsHandler( + deps: SetInvisibleOverlayVisibleMainDeps, +) { + return (): SetInvisibleOverlayVisibleMainDeps => ({ + setInvisibleOverlayVisibleCore: (options) => deps.setInvisibleOverlayVisibleCore(options), + setInvisibleOverlayVisibleState: (visible: boolean) => deps.setInvisibleOverlayVisibleState(visible), + updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => deps.syncInvisibleOverlayMousePassthrough(), + }); +} + +export function createBuildToggleVisibleOverlayMainDepsHandler(deps: ToggleVisibleOverlayMainDeps) { + return (): ToggleVisibleOverlayMainDeps => ({ + getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible), + }); +} + +export function createBuildToggleInvisibleOverlayMainDepsHandler( + deps: ToggleInvisibleOverlayMainDeps, +) { + return (): ToggleInvisibleOverlayMainDeps => ({ + getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(), + setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible), + }); +} diff --git a/src/main/runtime/overlay-visibility-actions.test.ts b/src/main/runtime/overlay-visibility-actions.test.ts new file mode 100644 index 0000000..afaae12 --- /dev/null +++ b/src/main/runtime/overlay-visibility-actions.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createSetInvisibleOverlayVisibleHandler, + createSetVisibleOverlayVisibleHandler, + createToggleInvisibleOverlayHandler, + createToggleVisibleOverlayHandler, +} from './overlay-visibility-actions'; + +test('set visible overlay handler forwards dependencies to core', () => { + const calls: string[] = []; + const setVisible = createSetVisibleOverlayVisibleHandler({ + setVisibleOverlayVisibleCore: (options) => { + calls.push(`core:${options.visible}`); + options.setVisibleOverlayVisibleState(options.visible); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + options.setMpvSubVisibility(!options.visible); + }, + setVisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`), + updateVisibleOverlayVisibility: () => calls.push('update-visible'), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'), + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isMpvConnected: () => true, + setMpvSubVisibility: (visible) => calls.push(`mpv-sub:${visible}`), + }); + + setVisible(true); + assert.deepEqual(calls, [ + 'core:true', + 'state:true', + 'update-visible', + 'update-invisible', + 'sync-mouse', + 'mpv-sub:false', + ]); +}); + +test('set invisible overlay handler forwards dependencies to core', () => { + const calls: string[] = []; + const setInvisible = createSetInvisibleOverlayVisibleHandler({ + setInvisibleOverlayVisibleCore: (options) => { + calls.push(`core:${options.visible}`); + options.setInvisibleOverlayVisibleState(options.visible); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + }, + setInvisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'), + }); + + setInvisible(false); + assert.deepEqual(calls, ['core:false', 'state:false', 'update-invisible', 'sync-mouse']); +}); + +test('toggle visible overlay flips current visible state', () => { + const calls: string[] = []; + let current = false; + const toggle = createToggleVisibleOverlayHandler({ + getVisibleOverlayVisible: () => current, + setVisibleOverlayVisible: (visible) => { + current = visible; + calls.push(`set:${visible}`); + }, + }); + + toggle(); + toggle(); + assert.deepEqual(calls, ['set:true', 'set:false']); +}); + +test('toggle invisible overlay flips current invisible state', () => { + const calls: string[] = []; + let current = true; + const toggle = createToggleInvisibleOverlayHandler({ + getInvisibleOverlayVisible: () => current, + setInvisibleOverlayVisible: (visible) => { + current = visible; + calls.push(`set:${visible}`); + }, + }); + + toggle(); + toggle(); + assert.deepEqual(calls, ['set:false', 'set:true']); +}); diff --git a/src/main/runtime/overlay-visibility-actions.ts b/src/main/runtime/overlay-visibility-actions.ts new file mode 100644 index 0000000..a43cd15 --- /dev/null +++ b/src/main/runtime/overlay-visibility-actions.ts @@ -0,0 +1,72 @@ +export function createSetVisibleOverlayVisibleHandler(deps: { + setVisibleOverlayVisibleCore: (options: { + visible: boolean; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isMpvConnected: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; + }) => void; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isMpvConnected: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; +}) { + return (visible: boolean): void => { + deps.setVisibleOverlayVisibleCore({ + visible, + setVisibleOverlayVisibleState: deps.setVisibleOverlayVisibleState, + updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, + updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, + syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough, + shouldBindVisibleOverlayToMpvSubVisibility: + deps.shouldBindVisibleOverlayToMpvSubVisibility, + isMpvConnected: deps.isMpvConnected, + setMpvSubVisibility: deps.setMpvSubVisibility, + }); + }; +} + +export function createSetInvisibleOverlayVisibleHandler(deps: { + setInvisibleOverlayVisibleCore: (options: { + visible: boolean; + setInvisibleOverlayVisibleState: (visible: boolean) => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + }) => void; + setInvisibleOverlayVisibleState: (visible: boolean) => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; +}) { + return (visible: boolean): void => { + deps.setInvisibleOverlayVisibleCore({ + visible, + setInvisibleOverlayVisibleState: deps.setInvisibleOverlayVisibleState, + updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, + syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough, + }); + }; +} + +export function createToggleVisibleOverlayHandler(deps: { + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; +}) { + return (): void => { + deps.setVisibleOverlayVisible(!deps.getVisibleOverlayVisible()); + }; +} + +export function createToggleInvisibleOverlayHandler(deps: { + getInvisibleOverlayVisible: () => boolean; + setInvisibleOverlayVisible: (visible: boolean) => void; +}) { + return (): void => { + deps.setInvisibleOverlayVisible(!deps.getInvisibleOverlayVisible()); + }; +} diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts new file mode 100644 index 0000000..37041ec --- /dev/null +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -0,0 +1,52 @@ +import type { BaseWindowTracker } from '../../window-trackers'; + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOverlayVisibilityRuntimeMainDepsHandler } from './overlay-visibility-runtime-main-deps'; + +test('overlay visibility runtime main deps builder maps state and geometry callbacks', () => { + const calls: string[] = []; + let trackerNotReadyWarningShown = false; + const mainWindow = { id: 'main' } as never; + const invisibleWindow = { id: 'invisible' } as never; + const tracker = { id: 'tracker' } as unknown as BaseWindowTracker; + + const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({ + getMainWindow: () => mainWindow, + getInvisibleWindow: () => invisibleWindow, + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + getWindowTracker: () => tracker, + getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown) => { + trackerNotReadyWarningShown = shown; + calls.push(`tracker-warning:${shown}`); + }, + updateVisibleOverlayBounds: () => calls.push('visible-bounds'), + updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'), + ensureOverlayWindowLevel: () => calls.push('ensure-level'), + enforceOverlayLayerOrder: () => calls.push('enforce-order'), + syncOverlayShortcuts: () => calls.push('sync-shortcuts'), + })(); + + assert.equal(deps.getMainWindow(), mainWindow); + assert.equal(deps.getInvisibleWindow(), invisibleWindow); + assert.equal(deps.getVisibleOverlayVisible(), true); + assert.equal(deps.getInvisibleOverlayVisible(), false); + assert.equal(deps.getTrackerNotReadyWarningShown(), false); + deps.setTrackerNotReadyWarningShown(true); + deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.ensureOverlayWindowLevel(mainWindow); + deps.enforceOverlayLayerOrder(); + deps.syncOverlayShortcuts(); + assert.equal(trackerNotReadyWarningShown, true); + assert.deepEqual(calls, [ + 'tracker-warning:true', + 'visible-bounds', + 'invisible-bounds', + 'ensure-level', + 'enforce-order', + 'sync-shortcuts', + ]); +}); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts new file mode 100644 index 0000000..466ebe3 --- /dev/null +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -0,0 +1,24 @@ +import type { BrowserWindow } from 'electron'; +import type { WindowGeometry } from '../../types'; +import type { OverlayVisibilityRuntimeDeps } from '../overlay-visibility-runtime'; + +export function createBuildOverlayVisibilityRuntimeMainDepsHandler( + deps: OverlayVisibilityRuntimeDeps, +) { + return (): OverlayVisibilityRuntimeDeps => ({ + getMainWindow: () => deps.getMainWindow(), + getInvisibleWindow: () => deps.getInvisibleWindow(), + getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(), + getWindowTracker: () => deps.getWindowTracker(), + getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), + setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), + updateVisibleOverlayBounds: (geometry: WindowGeometry) => + deps.updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => + deps.updateInvisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), + }); +} diff --git a/src/main/runtime/overlay-visibility-runtime.test.ts b/src/main/runtime/overlay-visibility-runtime.test.ts new file mode 100644 index 0000000..de65798 --- /dev/null +++ b/src/main/runtime/overlay-visibility-runtime.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createOverlayVisibilityRuntime } from './overlay-visibility-runtime'; + +test('overlay visibility runtime wires set/toggle handlers through composed deps', () => { + let visible = false; + let invisible = true; + let setVisibleCoreCalls = 0; + let setInvisibleCoreCalls = 0; + let lastBoundSubVisibility: boolean | null = null; + + const runtime = createOverlayVisibilityRuntime({ + setVisibleOverlayVisibleDeps: { + setVisibleOverlayVisibleCore: (options) => { + setVisibleCoreCalls += 1; + options.setVisibleOverlayVisibleState(options.visible); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) { + options.setMpvSubVisibility(options.visible); + } + }, + setVisibleOverlayVisibleState: (nextVisible) => { + visible = nextVisible; + }, + updateVisibleOverlayVisibility: () => {}, + updateInvisibleOverlayVisibility: () => {}, + syncInvisibleOverlayMousePassthrough: () => {}, + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isMpvConnected: () => true, + setMpvSubVisibility: (nextVisible) => { + lastBoundSubVisibility = nextVisible; + }, + }, + setInvisibleOverlayVisibleDeps: { + setInvisibleOverlayVisibleCore: (options) => { + setInvisibleCoreCalls += 1; + options.setInvisibleOverlayVisibleState(options.visible); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + }, + setInvisibleOverlayVisibleState: (nextVisible) => { + invisible = nextVisible; + }, + updateInvisibleOverlayVisibility: () => {}, + syncInvisibleOverlayMousePassthrough: () => {}, + }, + getVisibleOverlayVisible: () => visible, + getInvisibleOverlayVisible: () => invisible, + }); + + runtime.setVisibleOverlayVisible(true); + assert.equal(visible, true); + assert.equal(lastBoundSubVisibility, true); + + runtime.toggleVisibleOverlay(); + assert.equal(visible, false); + + runtime.setOverlayVisible(true); + assert.equal(visible, true); + + runtime.toggleOverlay(); + assert.equal(visible, false); + + runtime.setInvisibleOverlayVisible(false); + assert.equal(invisible, false); + + runtime.toggleInvisibleOverlay(); + assert.equal(invisible, true); + + assert.equal(setVisibleCoreCalls, 4); + assert.equal(setInvisibleCoreCalls, 2); +}); diff --git a/src/main/runtime/overlay-visibility-runtime.ts b/src/main/runtime/overlay-visibility-runtime.ts new file mode 100644 index 0000000..9a6db26 --- /dev/null +++ b/src/main/runtime/overlay-visibility-runtime.ts @@ -0,0 +1,78 @@ +import { + createSetInvisibleOverlayVisibleHandler, + createSetVisibleOverlayVisibleHandler, + createToggleInvisibleOverlayHandler, + createToggleVisibleOverlayHandler, +} from './overlay-visibility-actions'; +import { + createBuildSetInvisibleOverlayVisibleMainDepsHandler, + createBuildSetVisibleOverlayVisibleMainDepsHandler, + createBuildToggleInvisibleOverlayMainDepsHandler, + createBuildToggleVisibleOverlayMainDepsHandler, +} from './overlay-visibility-actions-main-deps'; +import { createSetOverlayVisibleHandler, createToggleOverlayHandler } from './overlay-main-actions'; +import { + createBuildSetOverlayVisibleMainDepsHandler, + createBuildToggleOverlayMainDepsHandler, +} from './overlay-main-actions-main-deps'; + +type SetVisibleOverlayVisibleMainDeps = Parameters< + typeof createBuildSetVisibleOverlayVisibleMainDepsHandler +>[0]; +type SetInvisibleOverlayVisibleMainDeps = Parameters< + typeof createBuildSetInvisibleOverlayVisibleMainDepsHandler +>[0]; + +export type OverlayVisibilityRuntimeDeps = { + setVisibleOverlayVisibleDeps: SetVisibleOverlayVisibleMainDeps; + setInvisibleOverlayVisibleDeps: SetInvisibleOverlayVisibleMainDeps; + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; +}; + +export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) { + const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler( + deps.setVisibleOverlayVisibleDeps, + )(); + const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler( + setVisibleOverlayVisibleMainDeps, + ); + + const setInvisibleOverlayVisibleMainDeps = createBuildSetInvisibleOverlayVisibleMainDepsHandler( + deps.setInvisibleOverlayVisibleDeps, + )(); + const setInvisibleOverlayVisible = createSetInvisibleOverlayVisibleHandler( + setInvisibleOverlayVisibleMainDeps, + ); + + const toggleVisibleOverlayMainDeps = createBuildToggleVisibleOverlayMainDepsHandler({ + getVisibleOverlayVisible: deps.getVisibleOverlayVisible, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + })(); + const toggleVisibleOverlay = createToggleVisibleOverlayHandler(toggleVisibleOverlayMainDeps); + + const toggleInvisibleOverlayMainDeps = createBuildToggleInvisibleOverlayMainDepsHandler({ + getInvisibleOverlayVisible: deps.getInvisibleOverlayVisible, + setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), + })(); + const toggleInvisibleOverlay = createToggleInvisibleOverlayHandler(toggleInvisibleOverlayMainDeps); + + const setOverlayVisibleMainDeps = createBuildSetOverlayVisibleMainDepsHandler({ + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + })(); + const setOverlayVisible = createSetOverlayVisibleHandler(setOverlayVisibleMainDeps); + + const toggleOverlayMainDeps = createBuildToggleOverlayMainDepsHandler({ + toggleVisibleOverlay: () => toggleVisibleOverlay(), + })(); + const toggleOverlay = createToggleOverlayHandler(toggleOverlayMainDeps); + + return { + setVisibleOverlayVisible, + setInvisibleOverlayVisible, + toggleVisibleOverlay, + toggleInvisibleOverlay, + setOverlayVisible, + toggleOverlay, + }; +} diff --git a/src/main/runtime/overlay-window-factory-main-deps.test.ts b/src/main/runtime/overlay-window-factory-main-deps.test.ts new file mode 100644 index 0000000..0203cd1 --- /dev/null +++ b/src/main/runtime/overlay-window-factory-main-deps.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildCreateInvisibleWindowMainDepsHandler, + createBuildCreateMainWindowMainDepsHandler, + createBuildCreateOverlayWindowMainDepsHandler, + createBuildCreateSecondaryWindowMainDepsHandler, +} from './overlay-window-factory-main-deps'; + +test('overlay window factory main deps builders return mapped handlers', () => { + const calls: string[] = []; + const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({ + createOverlayWindowCore: (kind) => ({ kind }), + isDev: true, + getOverlayDebugVisualizationEnabled: () => false, + ensureOverlayWindowLevel: () => calls.push('ensure-level'), + onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), + setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), + isOverlayVisible: (kind) => kind === 'visible', + tryHandleOverlayShortcutLocalFallback: () => false, + onWindowClosed: (kind) => calls.push(`closed:${kind}`), + }); + + const overlayDeps = buildOverlayDeps(); + assert.equal(overlayDeps.isDev, true); + assert.equal(overlayDeps.getOverlayDebugVisualizationEnabled(), false); + assert.equal(overlayDeps.isOverlayVisible('visible'), true); + + const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({ + createOverlayWindow: () => ({ id: 'visible' }), + setMainWindow: () => calls.push('set-main'), + }); + const mainDeps = buildMainDeps(); + mainDeps.setMainWindow(null); + + const buildInvisibleDeps = createBuildCreateInvisibleWindowMainDepsHandler({ + createOverlayWindow: () => ({ id: 'invisible' }), + setInvisibleWindow: () => calls.push('set-invisible'), + }); + const invisibleDeps = buildInvisibleDeps(); + invisibleDeps.setInvisibleWindow(null); + + const buildSecondaryDeps = createBuildCreateSecondaryWindowMainDepsHandler({ + createOverlayWindow: () => ({ id: 'secondary' }), + setSecondaryWindow: () => calls.push('set-secondary'), + }); + const secondaryDeps = buildSecondaryDeps(); + secondaryDeps.setSecondaryWindow(null); + + assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary']); +}); diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts new file mode 100644 index 0000000..fae8f9a --- /dev/null +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -0,0 +1,65 @@ +export function createBuildCreateOverlayWindowMainDepsHandler(deps: { + createOverlayWindowCore: ( + kind: 'visible' | 'invisible' | 'secondary', + options: { + isDev: boolean; + overlayDebugVisualizationEnabled: boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; + }, + ) => TWindow; + isDev: boolean; + getOverlayDebugVisualizationEnabled: () => boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void; +}) { + return () => ({ + createOverlayWindowCore: deps.createOverlayWindowCore, + isDev: deps.isDev, + getOverlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled, + ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, + onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, + setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, + isOverlayVisible: deps.isOverlayVisible, + tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, + onWindowClosed: deps.onWindowClosed, + }); +} + +export function createBuildCreateMainWindowMainDepsHandler(deps: { + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; + setMainWindow: (window: TWindow | null) => void; +}) { + return () => ({ + createOverlayWindow: deps.createOverlayWindow, + setMainWindow: deps.setMainWindow, + }); +} + +export function createBuildCreateInvisibleWindowMainDepsHandler(deps: { + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; + setInvisibleWindow: (window: TWindow | null) => void; +}) { + return () => ({ + createOverlayWindow: deps.createOverlayWindow, + setInvisibleWindow: deps.setInvisibleWindow, + }); +} + +export function createBuildCreateSecondaryWindowMainDepsHandler(deps: { + createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow; + setSecondaryWindow: (window: TWindow | null) => void; +}) { + return () => ({ + createOverlayWindow: deps.createOverlayWindow, + setSecondaryWindow: deps.setSecondaryWindow, + }); +} diff --git a/src/main/runtime/overlay-window-factory.test.ts b/src/main/runtime/overlay-window-factory.test.ts new file mode 100644 index 0000000..0d1b3e3 --- /dev/null +++ b/src/main/runtime/overlay-window-factory.test.ts @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createCreateInvisibleWindowHandler, + createCreateMainWindowHandler, + createCreateOverlayWindowHandler, + createCreateSecondaryWindowHandler, +} from './overlay-window-factory'; + +test('create overlay window handler forwards options and kind', () => { + const calls: string[] = []; + const window = { id: 1 }; + const createOverlayWindow = createCreateOverlayWindowHandler({ + createOverlayWindowCore: (kind, options) => { + calls.push(`kind:${kind}`); + assert.equal(options.isDev, true); + assert.equal(options.overlayDebugVisualizationEnabled, false); + assert.equal(options.isOverlayVisible('visible'), true); + assert.equal(options.isOverlayVisible('invisible'), false); + options.onRuntimeOptionsChanged(); + options.setOverlayDebugVisualizationEnabled(true); + options.onWindowClosed(kind); + return window; + }, + isDev: true, + getOverlayDebugVisualizationEnabled: () => false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => calls.push('runtime-options'), + setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), + isOverlayVisible: (kind) => kind === 'visible', + tryHandleOverlayShortcutLocalFallback: () => false, + onWindowClosed: (kind) => calls.push(`closed:${kind}`), + }); + + assert.equal(createOverlayWindow('visible'), window); + assert.deepEqual(calls, ['kind:visible', 'runtime-options', 'debug:true', 'closed:visible']); +}); + +test('create main window handler stores visible window', () => { + const calls: string[] = []; + const visibleWindow = { id: 'visible' }; + const createMainWindow = createCreateMainWindowHandler({ + createOverlayWindow: (kind) => { + calls.push(`create:${kind}`); + return visibleWindow; + }, + setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), + }); + + assert.equal(createMainWindow(), visibleWindow); + assert.deepEqual(calls, ['create:visible', 'set:visible']); +}); + +test('create invisible window handler stores invisible window', () => { + const calls: string[] = []; + const invisibleWindow = { id: 'invisible' }; + const createInvisibleWindow = createCreateInvisibleWindowHandler({ + createOverlayWindow: (kind) => { + calls.push(`create:${kind}`); + return invisibleWindow; + }, + setInvisibleWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), + }); + + assert.equal(createInvisibleWindow(), invisibleWindow); + assert.deepEqual(calls, ['create:invisible', 'set:invisible']); +}); + +test('create secondary window handler stores secondary window', () => { + const calls: string[] = []; + const secondaryWindow = { id: 'secondary' }; + const createSecondaryWindow = createCreateSecondaryWindowHandler({ + createOverlayWindow: (kind) => { + calls.push(`create:${kind}`); + return secondaryWindow; + }, + setSecondaryWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), + }); + + assert.equal(createSecondaryWindow(), secondaryWindow); + assert.deepEqual(calls, ['create:secondary', 'set:secondary']); +}); diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts new file mode 100644 index 0000000..dcdf639 --- /dev/null +++ b/src/main/runtime/overlay-window-factory.ts @@ -0,0 +1,71 @@ +type OverlayWindowKind = 'visible' | 'invisible' | 'secondary'; + +export function createCreateOverlayWindowHandler(deps: { + createOverlayWindowCore: ( + kind: OverlayWindowKind, + options: { + isDev: boolean; + overlayDebugVisualizationEnabled: boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: OverlayWindowKind) => void; + }, + ) => TWindow; + isDev: boolean; + getOverlayDebugVisualizationEnabled: () => boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: OverlayWindowKind) => void; +}) { + return (kind: OverlayWindowKind): TWindow => { + return deps.createOverlayWindowCore(kind, { + isDev: deps.isDev, + overlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled(), + ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, + onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, + setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, + isOverlayVisible: deps.isOverlayVisible, + tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, + onWindowClosed: deps.onWindowClosed, + }); + }; +} + +export function createCreateMainWindowHandler(deps: { + createOverlayWindow: (kind: OverlayWindowKind) => TWindow; + setMainWindow: (window: TWindow | null) => void; +}) { + return (): TWindow => { + const window = deps.createOverlayWindow('visible'); + deps.setMainWindow(window); + return window; + }; +} + +export function createCreateInvisibleWindowHandler(deps: { + createOverlayWindow: (kind: OverlayWindowKind) => TWindow; + setInvisibleWindow: (window: TWindow | null) => void; +}) { + return (): TWindow => { + const window = deps.createOverlayWindow('invisible'); + deps.setInvisibleWindow(window); + return window; + }; +} + +export function createCreateSecondaryWindowHandler(deps: { + createOverlayWindow: (kind: OverlayWindowKind) => TWindow; + setSecondaryWindow: (window: TWindow | null) => void; +}) { + return (): TWindow => { + const window = deps.createOverlayWindow('secondary'); + deps.setSecondaryWindow(window); + return window; + }; +} diff --git a/src/main/runtime/overlay-window-layout-main-deps.test.ts b/src/main/runtime/overlay-window-layout-main-deps.test.ts new file mode 100644 index 0000000..4ced712 --- /dev/null +++ b/src/main/runtime/overlay-window-layout-main-deps.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildEnforceOverlayLayerOrderMainDepsHandler, + createBuildEnsureOverlayWindowLevelMainDepsHandler, + createBuildUpdateInvisibleOverlayBoundsMainDepsHandler, + createBuildUpdateVisibleOverlayBoundsMainDepsHandler, +} from './overlay-window-layout-main-deps'; + +test('overlay window layout main deps builders map callbacks', () => { + const calls: string[] = []; + + const visible = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ + setOverlayWindowBounds: (layer) => calls.push(`visible:${layer}`), + })(); + visible.setOverlayWindowBounds('visible', { x: 0, y: 0, width: 1, height: 1 }); + + const invisible = createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({ + setOverlayWindowBounds: (layer) => calls.push(`invisible:${layer}`), + })(); + invisible.setOverlayWindowBounds('invisible', { x: 0, y: 0, width: 1, height: 1 }); + + const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({ + ensureOverlayWindowLevelCore: () => calls.push('ensure'), + })(); + level.ensureOverlayWindowLevelCore({}); + + const order = createBuildEnforceOverlayLayerOrderMainDepsHandler({ + enforceOverlayLayerOrderCore: () => calls.push('order'), + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + getMainWindow: () => ({ kind: 'main' }), + getInvisibleWindow: () => ({ kind: 'invisible' }), + ensureOverlayWindowLevel: () => calls.push('order-level'), + })(); + order.enforceOverlayLayerOrderCore({ + visibleOverlayVisible: true, + invisibleOverlayVisible: false, + mainWindow: null, + invisibleWindow: null, + ensureOverlayWindowLevel: () => {}, + }); + assert.equal(order.getVisibleOverlayVisible(), true); + assert.equal(order.getInvisibleOverlayVisible(), false); + assert.deepEqual(order.getMainWindow(), { kind: 'main' }); + assert.deepEqual(order.getInvisibleWindow(), { kind: 'invisible' }); + order.ensureOverlayWindowLevel({}); + + assert.deepEqual(calls, [ + 'visible:visible', + 'invisible:invisible', + 'ensure', + 'order', + 'order-level', + ]); +}); diff --git a/src/main/runtime/overlay-window-layout-main-deps.ts b/src/main/runtime/overlay-window-layout-main-deps.ts new file mode 100644 index 0000000..317cacd --- /dev/null +++ b/src/main/runtime/overlay-window-layout-main-deps.ts @@ -0,0 +1,48 @@ +import type { + createEnforceOverlayLayerOrderHandler, + createEnsureOverlayWindowLevelHandler, + createUpdateInvisibleOverlayBoundsHandler, + createUpdateVisibleOverlayBoundsHandler, +} from './overlay-window-layout'; + +type UpdateVisibleOverlayBoundsMainDeps = Parameters[0]; +type UpdateInvisibleOverlayBoundsMainDeps = Parameters[0]; +type EnsureOverlayWindowLevelMainDeps = Parameters[0]; +type EnforceOverlayLayerOrderMainDeps = Parameters[0]; + +export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler( + deps: UpdateVisibleOverlayBoundsMainDeps, +) { + return (): UpdateVisibleOverlayBoundsMainDeps => ({ + setOverlayWindowBounds: (layer, geometry) => deps.setOverlayWindowBounds(layer, geometry), + }); +} + +export function createBuildUpdateInvisibleOverlayBoundsMainDepsHandler( + deps: UpdateInvisibleOverlayBoundsMainDeps, +) { + return (): UpdateInvisibleOverlayBoundsMainDeps => ({ + setOverlayWindowBounds: (layer, geometry) => deps.setOverlayWindowBounds(layer, geometry), + }); +} + +export function createBuildEnsureOverlayWindowLevelMainDepsHandler( + deps: EnsureOverlayWindowLevelMainDeps, +) { + return (): EnsureOverlayWindowLevelMainDeps => ({ + ensureOverlayWindowLevelCore: (window: unknown) => deps.ensureOverlayWindowLevelCore(window), + }); +} + +export function createBuildEnforceOverlayLayerOrderMainDepsHandler( + deps: EnforceOverlayLayerOrderMainDeps, +) { + return (): EnforceOverlayLayerOrderMainDeps => ({ + enforceOverlayLayerOrderCore: (params) => deps.enforceOverlayLayerOrderCore(params), + getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(), + getMainWindow: () => deps.getMainWindow(), + getInvisibleWindow: () => deps.getInvisibleWindow(), + ensureOverlayWindowLevel: (window: unknown) => deps.ensureOverlayWindowLevel(window), + }); +} diff --git a/src/main/runtime/overlay-window-layout.test.ts b/src/main/runtime/overlay-window-layout.test.ts new file mode 100644 index 0000000..8eeab17 --- /dev/null +++ b/src/main/runtime/overlay-window-layout.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createEnforceOverlayLayerOrderHandler, + createEnsureOverlayWindowLevelHandler, + createUpdateInvisibleOverlayBoundsHandler, + createUpdateVisibleOverlayBoundsHandler, +} from './overlay-window-layout'; + +test('visible bounds handler writes visible layer geometry', () => { + const calls: string[] = []; + const handleVisible = createUpdateVisibleOverlayBoundsHandler({ + setOverlayWindowBounds: (layer) => calls.push(layer), + }); + handleVisible({ x: 0, y: 0, width: 100, height: 50 }); + assert.deepEqual(calls, ['visible']); +}); + +test('invisible bounds handler writes invisible layer geometry', () => { + const calls: string[] = []; + const handleInvisible = createUpdateInvisibleOverlayBoundsHandler({ + setOverlayWindowBounds: (layer) => calls.push(layer), + }); + handleInvisible({ x: 0, y: 0, width: 100, height: 50 }); + assert.deepEqual(calls, ['invisible']); +}); + +test('ensure overlay window level handler delegates to core', () => { + const calls: string[] = []; + const ensureLevel = createEnsureOverlayWindowLevelHandler({ + ensureOverlayWindowLevelCore: () => calls.push('core'), + }); + ensureLevel({}); + assert.deepEqual(calls, ['core']); +}); + +test('enforce overlay layer order handler forwards resolved state', () => { + const calls: string[] = []; + const enforce = createEnforceOverlayLayerOrderHandler({ + enforceOverlayLayerOrderCore: (params) => { + calls.push(params.visibleOverlayVisible ? 'visible-on' : 'visible-off'); + calls.push(params.invisibleOverlayVisible ? 'invisible-on' : 'invisible-off'); + params.ensureOverlayWindowLevel({}); + }, + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + getMainWindow: () => ({}), + getInvisibleWindow: () => ({}), + ensureOverlayWindowLevel: () => calls.push('ensure-level'), + }); + enforce(); + assert.deepEqual(calls, ['visible-on', 'invisible-off', 'ensure-level']); +}); diff --git a/src/main/runtime/overlay-window-layout.ts b/src/main/runtime/overlay-window-layout.ts new file mode 100644 index 0000000..4d62f44 --- /dev/null +++ b/src/main/runtime/overlay-window-layout.ts @@ -0,0 +1,50 @@ +import type { WindowGeometry } from '../../types'; + +export function createUpdateVisibleOverlayBoundsHandler(deps: { + setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void; +}) { + return (geometry: WindowGeometry): void => { + deps.setOverlayWindowBounds('visible', geometry); + }; +} + +export function createUpdateInvisibleOverlayBoundsHandler(deps: { + setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void; +}) { + return (geometry: WindowGeometry): void => { + deps.setOverlayWindowBounds('invisible', geometry); + }; +} + +export function createEnsureOverlayWindowLevelHandler(deps: { + ensureOverlayWindowLevelCore: (window: unknown) => void; +}) { + return (window: unknown): void => { + deps.ensureOverlayWindowLevelCore(window); + }; +} + +export function createEnforceOverlayLayerOrderHandler(deps: { + enforceOverlayLayerOrderCore: (params: { + visibleOverlayVisible: boolean; + invisibleOverlayVisible: boolean; + mainWindow: unknown; + invisibleWindow: unknown; + ensureOverlayWindowLevel: (window: unknown) => void; + }) => void; + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + getMainWindow: () => unknown; + getInvisibleWindow: () => unknown; + ensureOverlayWindowLevel: (window: unknown) => void; +}) { + return (): void => { + deps.enforceOverlayLayerOrderCore({ + visibleOverlayVisible: deps.getVisibleOverlayVisible(), + invisibleOverlayVisible: deps.getInvisibleOverlayVisible(), + mainWindow: deps.getMainWindow(), + invisibleWindow: deps.getInvisibleWindow(), + ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, + }); + }; +} diff --git a/src/main/runtime/overlay-window-runtime-handlers.test.ts b/src/main/runtime/overlay-window-runtime-handlers.test.ts new file mode 100644 index 0000000..39943ea --- /dev/null +++ b/src/main/runtime/overlay-window-runtime-handlers.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createOverlayWindowRuntimeHandlers } from './overlay-window-runtime-handlers'; + +test('overlay window runtime handlers compose create/main/invisible handlers', () => { + let mainWindow: { kind: string } | null = null; + let invisibleWindow: { kind: string } | null = null; + let secondaryWindow: { kind: string } | null = null; + let debugEnabled = false; + const calls: string[] = []; + + const runtime = createOverlayWindowRuntimeHandlers({ + createOverlayWindowDeps: { + createOverlayWindowCore: (kind) => ({ kind }), + isDev: true, + getOverlayDebugVisualizationEnabled: () => debugEnabled, + ensureOverlayWindowLevel: () => calls.push('ensure-level'), + onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), + setOverlayDebugVisualizationEnabled: (enabled) => { + debugEnabled = enabled; + }, + isOverlayVisible: (kind) => kind === 'visible', + tryHandleOverlayShortcutLocalFallback: () => false, + onWindowClosed: (kind) => calls.push(`closed:${kind}`), + }, + setMainWindow: (window) => { + mainWindow = window; + }, + setInvisibleWindow: (window) => { + invisibleWindow = window; + }, + setSecondaryWindow: (window) => { + secondaryWindow = window; + }, + }); + + assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' }); + assert.deepEqual(runtime.createOverlayWindow('invisible'), { kind: 'invisible' }); + assert.deepEqual(runtime.createOverlayWindow('secondary'), { kind: 'secondary' }); + + assert.deepEqual(runtime.createMainWindow(), { kind: 'visible' }); + assert.deepEqual(mainWindow, { kind: 'visible' }); + + assert.deepEqual(runtime.createInvisibleWindow(), { kind: 'invisible' }); + assert.deepEqual(invisibleWindow, { kind: 'invisible' }); + + assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' }); + assert.deepEqual(secondaryWindow, { kind: 'secondary' }); + + assert.equal(debugEnabled, false); + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/overlay-window-runtime-handlers.ts b/src/main/runtime/overlay-window-runtime-handlers.ts new file mode 100644 index 0000000..7e84d5c --- /dev/null +++ b/src/main/runtime/overlay-window-runtime-handlers.ts @@ -0,0 +1,52 @@ +import { + createCreateInvisibleWindowHandler, + createCreateMainWindowHandler, + createCreateOverlayWindowHandler, + createCreateSecondaryWindowHandler, +} from './overlay-window-factory'; +import { + createBuildCreateInvisibleWindowMainDepsHandler, + createBuildCreateMainWindowMainDepsHandler, + createBuildCreateOverlayWindowMainDepsHandler, + createBuildCreateSecondaryWindowMainDepsHandler, +} from './overlay-window-factory-main-deps'; + +type CreateOverlayWindowMainDeps = Parameters< + typeof createBuildCreateOverlayWindowMainDepsHandler +>[0]; + +export function createOverlayWindowRuntimeHandlers(deps: { + createOverlayWindowDeps: CreateOverlayWindowMainDeps; + setMainWindow: (window: TWindow | null) => void; + setInvisibleWindow: (window: TWindow | null) => void; + setSecondaryWindow: (window: TWindow | null) => void; +}) { + const createOverlayWindow = createCreateOverlayWindowHandler( + createBuildCreateOverlayWindowMainDepsHandler(deps.createOverlayWindowDeps)(), + ); + const createMainWindow = createCreateMainWindowHandler( + createBuildCreateMainWindowMainDepsHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setMainWindow: (window) => deps.setMainWindow(window), + })(), + ); + const createInvisibleWindow = createCreateInvisibleWindowHandler( + createBuildCreateInvisibleWindowMainDepsHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setInvisibleWindow: (window) => deps.setInvisibleWindow(window), + })(), + ); + const createSecondaryWindow = createCreateSecondaryWindowHandler( + createBuildCreateSecondaryWindowMainDepsHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setSecondaryWindow: (window) => deps.setSecondaryWindow(window), + })(), + ); + + return { + createOverlayWindow, + createMainWindow, + createInvisibleWindow, + createSecondaryWindow, + }; +} diff --git a/src/main/runtime/protocol-url-handlers-main-deps.test.ts b/src/main/runtime/protocol-url-handlers-main-deps.test.ts new file mode 100644 index 0000000..5a6087a --- /dev/null +++ b/src/main/runtime/protocol-url-handlers-main-deps.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from './protocol-url-handlers-main-deps'; + +test('protocol url handlers main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildRegisterProtocolUrlHandlersMainDepsHandler({ + registerOpenUrl: () => calls.push('open-register'), + registerSecondInstance: () => calls.push('second-register'), + handleAnilistSetupProtocolUrl: () => true, + findAnilistSetupDeepLinkArgvUrl: () => 'subminer://anilist-setup', + logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`), + logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`), + })(); + + deps.registerOpenUrl(() => {}); + deps.registerSecondInstance(() => {}); + assert.equal(deps.handleAnilistSetupProtocolUrl('subminer://anilist-setup'), true); + assert.equal(deps.findAnilistSetupDeepLinkArgvUrl(['x']), 'subminer://anilist-setup'); + deps.logUnhandledOpenUrl('subminer://noop'); + deps.logUnhandledSecondInstanceUrl('subminer://noop'); + + assert.deepEqual(calls, [ + 'open-register', + 'second-register', + 'open:subminer://noop', + 'second:subminer://noop', + ]); +}); diff --git a/src/main/runtime/protocol-url-handlers-main-deps.ts b/src/main/runtime/protocol-url-handlers-main-deps.ts new file mode 100644 index 0000000..a2a0554 --- /dev/null +++ b/src/main/runtime/protocol-url-handlers-main-deps.ts @@ -0,0 +1,16 @@ +import type { registerProtocolUrlHandlers } from './protocol-url-handlers'; + +type RegisterProtocolUrlHandlersMainDeps = Parameters[0]; + +export function createBuildRegisterProtocolUrlHandlersMainDepsHandler( + deps: RegisterProtocolUrlHandlersMainDeps, +) { + return (): RegisterProtocolUrlHandlersMainDeps => ({ + registerOpenUrl: (listener) => deps.registerOpenUrl(listener), + registerSecondInstance: (listener) => deps.registerSecondInstance(listener), + handleAnilistSetupProtocolUrl: (rawUrl: string) => deps.handleAnilistSetupProtocolUrl(rawUrl), + findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => deps.findAnilistSetupDeepLinkArgvUrl(argv), + logUnhandledOpenUrl: (rawUrl: string) => deps.logUnhandledOpenUrl(rawUrl), + logUnhandledSecondInstanceUrl: (rawUrl: string) => deps.logUnhandledSecondInstanceUrl(rawUrl), + }); +} diff --git a/src/main/runtime/protocol-url-handlers.test.ts b/src/main/runtime/protocol-url-handlers.test.ts new file mode 100644 index 0000000..46498f9 --- /dev/null +++ b/src/main/runtime/protocol-url-handlers.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerProtocolUrlHandlers } from './protocol-url-handlers'; + +test('registerProtocolUrlHandlers wires open-url and second-instance handling', () => { + const listeners = new Map void>(); + const calls: string[] = []; + registerProtocolUrlHandlers({ + registerOpenUrl: (listener) => { + listeners.set('open-url', listener as (...args: unknown[]) => void); + }, + registerSecondInstance: (listener) => { + listeners.set('second-instance', listener as (...args: unknown[]) => void); + }, + handleAnilistSetupProtocolUrl: (rawUrl) => rawUrl.includes('anilist-setup'), + findAnilistSetupDeepLinkArgvUrl: (argv) => + argv.find((entry) => entry.startsWith('subminer://')) ?? null, + logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`), + logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`), + }); + + const openUrlListener = listeners.get('open-url'); + const secondInstanceListener = listeners.get('second-instance'); + if (!openUrlListener || !secondInstanceListener) { + throw new Error('expected listeners'); + } + + let prevented = false; + openUrlListener({ preventDefault: () => (prevented = true) }, 'subminer://noop'); + secondInstanceListener({}, ['foo', 'subminer://noop']); + + assert.equal(prevented, true); + assert.deepEqual(calls, ['open:subminer://noop', 'second:subminer://noop']); +}); diff --git a/src/main/runtime/protocol-url-handlers.ts b/src/main/runtime/protocol-url-handlers.ts new file mode 100644 index 0000000..e6b52ab --- /dev/null +++ b/src/main/runtime/protocol-url-handlers.ts @@ -0,0 +1,27 @@ +export function registerProtocolUrlHandlers(deps: { + registerOpenUrl: ( + listener: (event: { preventDefault: () => void }, rawUrl: string) => void, + ) => void; + registerSecondInstance: (listener: (_event: unknown, argv: string[]) => void) => void; + handleAnilistSetupProtocolUrl: (rawUrl: string) => boolean; + findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => string | null; + logUnhandledOpenUrl: (rawUrl: string) => void; + logUnhandledSecondInstanceUrl: (rawUrl: string) => void; +}) { + deps.registerOpenUrl((event, rawUrl) => { + event.preventDefault(); + if (!deps.handleAnilistSetupProtocolUrl(rawUrl)) { + deps.logUnhandledOpenUrl(rawUrl); + } + }); + + deps.registerSecondInstance((_event, argv) => { + const rawUrl = deps.findAnilistSetupDeepLinkArgvUrl(argv); + if (!rawUrl) { + return; + } + if (!deps.handleAnilistSetupProtocolUrl(rawUrl)) { + deps.logUnhandledSecondInstanceUrl(rawUrl); + } + }); +} diff --git a/src/main/runtime/registry.test.ts b/src/main/runtime/registry.test.ts new file mode 100644 index 0000000..526f107 --- /dev/null +++ b/src/main/runtime/registry.test.ts @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +async function loadRegistryOrSkip(t: test.TestContext) { + try { + return await import('./registry'); + } catch (error) { + if (error instanceof Error && error.message.includes('node:sqlite')) { + t.skip('registry import requires node:sqlite support in this runtime'); + return null; + } + throw error; + } +} + +test('createMainRuntimeRegistry exposes expected runtime domains', async (t) => { + const loaded = await loadRegistryOrSkip(t); + if (!loaded) return; + const { createMainRuntimeRegistry } = loaded; + const registry = createMainRuntimeRegistry(); + + assert.ok(registry.anilist); + assert.ok(registry.jellyfin); + assert.ok(registry.overlay); + assert.ok(registry.startup); + assert.ok(registry.mpv); + assert.ok(registry.shortcuts); + assert.ok(registry.ipc); + assert.ok(registry.mining); +}); + +test('registry domains expose representative factories', async (t) => { + const loaded = await loadRegistryOrSkip(t); + if (!loaded) return; + const { createMainRuntimeRegistry } = loaded; + const registry = createMainRuntimeRegistry(); + + assert.equal(typeof registry.anilist.createNotifyAnilistSetupHandler, 'function'); + assert.equal(typeof registry.jellyfin.createRunJellyfinCommandHandler, 'function'); + assert.equal(typeof registry.overlay.createOverlayVisibilityRuntime, 'function'); + assert.equal(typeof registry.startup.createStartupRuntimeHandlers, 'function'); + assert.equal(typeof registry.mpv.createMpvClientRuntimeServiceFactory, 'function'); + assert.equal(typeof registry.shortcuts.createGlobalShortcutsRuntimeHandlers, 'function'); + assert.equal(typeof registry.ipc.createIpcRuntimeHandlers, 'function'); + assert.equal(typeof registry.mining.createMineSentenceCardHandler, 'function'); +}); diff --git a/src/main/runtime/registry.ts b/src/main/runtime/registry.ts new file mode 100644 index 0000000..461d595 --- /dev/null +++ b/src/main/runtime/registry.ts @@ -0,0 +1,7 @@ +import * as domains from './domains'; + +export type MainRuntimeRegistry = typeof domains; + +export function createMainRuntimeRegistry(): MainRuntimeRegistry { + return domains; +} diff --git a/src/main/runtime/runtime-bootstrap-main-deps.test.ts b/src/main/runtime/runtime-bootstrap-main-deps.test.ts new file mode 100644 index 0000000..8b53c16 --- /dev/null +++ b/src/main/runtime/runtime-bootstrap-main-deps.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildAnilistStateRuntimeMainDepsHandler, + createBuildConfigDerivedRuntimeMainDepsHandler, + createBuildImmersionMediaRuntimeMainDepsHandler, + createBuildMainSubsyncRuntimeMainDepsHandler, +} from './runtime-bootstrap-main-deps'; + +test('immersion media runtime main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildImmersionMediaRuntimeMainDepsHandler({ + getResolvedConfig: () => ({ immersionTracking: { dbPath: '/tmp/db.sqlite' } }), + defaultImmersionDbPath: '/tmp/default.sqlite', + getTracker: () => ({ handleMediaChange: () => calls.push('track') }), + getMpvClient: () => ({ connected: true }), + getCurrentMediaPath: () => '/tmp/media.mkv', + getCurrentMediaTitle: () => 'Title', + sleep: async () => { + calls.push('sleep'); + }, + seedWaitMs: 25, + seedAttempts: 3, + logDebug: (message) => calls.push(`debug:${message}`), + logInfo: (message) => calls.push(`info:${message}`), + })(); + + assert.equal(deps.defaultImmersionDbPath, '/tmp/default.sqlite'); + assert.deepEqual(deps.getResolvedConfig(), { immersionTracking: { dbPath: '/tmp/db.sqlite' } }); + assert.deepEqual(deps.getMpvClient(), { connected: true }); + assert.equal(deps.getCurrentMediaPath(), '/tmp/media.mkv'); + assert.equal(deps.getCurrentMediaTitle(), 'Title'); + assert.equal(deps.seedWaitMs, 25); + assert.equal(deps.seedAttempts, 3); + await deps.sleep?.(1); + deps.logDebug('a'); + deps.logInfo('b'); + assert.deepEqual(calls, ['sleep', 'debug:a', 'info:b']); +}); + +test('anilist state runtime main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildAnilistStateRuntimeMainDepsHandler({ + getClientSecretState: () => ({ status: 'resolved' } as never), + setClientSecretState: () => calls.push('set-client'), + getRetryQueueState: () => ({ pending: 1 } as never), + setRetryQueueState: () => calls.push('set-queue'), + getUpdateQueueSnapshot: () => ({ pending: 2 } as never), + clearStoredToken: () => calls.push('clear-stored'), + clearCachedAccessToken: () => calls.push('clear-cached'), + })(); + + assert.deepEqual(deps.getClientSecretState(), { status: 'resolved' }); + assert.deepEqual(deps.getRetryQueueState(), { pending: 1 }); + assert.deepEqual(deps.getUpdateQueueSnapshot(), { pending: 2 }); + deps.setClientSecretState({} as never); + deps.setRetryQueueState({} as never); + deps.clearStoredToken(); + deps.clearCachedAccessToken(); + assert.deepEqual(calls, ['set-client', 'set-queue', 'clear-stored', 'clear-cached']); +}); + +test('config derived runtime main deps builder maps callbacks', () => { + const deps = createBuildConfigDerivedRuntimeMainDepsHandler({ + getResolvedConfig: () => ({ jimaku: {} } as never), + getRuntimeOptionsManager: () => null, + platform: 'darwin', + defaultJimakuLanguagePreference: 'ja', + defaultJimakuMaxEntryResults: 20, + defaultJimakuApiBaseUrl: 'https://api.example.com', + })(); + + assert.deepEqual(deps.getResolvedConfig(), { jimaku: {} }); + assert.equal(deps.getRuntimeOptionsManager(), null); + assert.equal(deps.platform, 'darwin'); + assert.equal(deps.defaultJimakuLanguagePreference, 'ja'); + assert.equal(deps.defaultJimakuMaxEntryResults, 20); + assert.equal(deps.defaultJimakuApiBaseUrl, 'https://api.example.com'); +}); + +test('main subsync runtime main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildMainSubsyncRuntimeMainDepsHandler({ + getMpvClient: () => ({ connected: true }) as never, + getResolvedConfig: () => ({ subsync: {} } as never), + getSubsyncInProgress: () => true, + setSubsyncInProgress: () => calls.push('set-progress'), + showMpvOsd: (text) => calls.push(`osd:${text}`), + openManualPicker: () => calls.push('open-picker'), + })(); + + assert.deepEqual(deps.getMpvClient(), { connected: true }); + assert.deepEqual(deps.getResolvedConfig(), { subsync: {} }); + assert.equal(deps.getSubsyncInProgress(), true); + deps.setSubsyncInProgress(false); + deps.showMpvOsd('ready'); + deps.openManualPicker({} as never); + assert.deepEqual(calls, ['set-progress', 'osd:ready', 'open-picker']); +}); diff --git a/src/main/runtime/runtime-bootstrap-main-deps.ts b/src/main/runtime/runtime-bootstrap-main-deps.ts new file mode 100644 index 0000000..015f55f --- /dev/null +++ b/src/main/runtime/runtime-bootstrap-main-deps.ts @@ -0,0 +1,54 @@ +import type { AnilistStateRuntimeDeps } from './anilist-state'; +import type { ConfigDerivedRuntimeDeps } from './config-derived'; +import type { ImmersionMediaRuntimeDeps } from './immersion-media'; +import type { MainSubsyncRuntimeDeps } from './subsync-runtime'; + +export function createBuildImmersionMediaRuntimeMainDepsHandler(deps: ImmersionMediaRuntimeDeps) { + return (): ImmersionMediaRuntimeDeps => ({ + getResolvedConfig: () => deps.getResolvedConfig(), + defaultImmersionDbPath: deps.defaultImmersionDbPath, + getTracker: () => deps.getTracker(), + getMpvClient: () => deps.getMpvClient(), + getCurrentMediaPath: () => deps.getCurrentMediaPath(), + getCurrentMediaTitle: () => deps.getCurrentMediaTitle(), + sleep: deps.sleep, + seedWaitMs: deps.seedWaitMs, + seedAttempts: deps.seedAttempts, + logDebug: (message: string) => deps.logDebug(message), + logInfo: (message: string) => deps.logInfo(message), + }); +} + +export function createBuildAnilistStateRuntimeMainDepsHandler(deps: AnilistStateRuntimeDeps) { + return (): AnilistStateRuntimeDeps => ({ + getClientSecretState: () => deps.getClientSecretState(), + setClientSecretState: (next) => deps.setClientSecretState(next), + getRetryQueueState: () => deps.getRetryQueueState(), + setRetryQueueState: (next) => deps.setRetryQueueState(next), + getUpdateQueueSnapshot: () => deps.getUpdateQueueSnapshot(), + clearStoredToken: () => deps.clearStoredToken(), + clearCachedAccessToken: () => deps.clearCachedAccessToken(), + }); +} + +export function createBuildConfigDerivedRuntimeMainDepsHandler(deps: ConfigDerivedRuntimeDeps) { + return (): ConfigDerivedRuntimeDeps => ({ + getResolvedConfig: () => deps.getResolvedConfig(), + getRuntimeOptionsManager: () => deps.getRuntimeOptionsManager(), + platform: deps.platform, + defaultJimakuLanguagePreference: deps.defaultJimakuLanguagePreference, + defaultJimakuMaxEntryResults: deps.defaultJimakuMaxEntryResults, + defaultJimakuApiBaseUrl: deps.defaultJimakuApiBaseUrl, + }); +} + +export function createBuildMainSubsyncRuntimeMainDepsHandler(deps: MainSubsyncRuntimeDeps) { + return (): MainSubsyncRuntimeDeps => ({ + getMpvClient: () => deps.getMpvClient(), + getResolvedConfig: () => deps.getResolvedConfig(), + getSubsyncInProgress: () => deps.getSubsyncInProgress(), + setSubsyncInProgress: (inProgress: boolean) => deps.setSubsyncInProgress(inProgress), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + openManualPicker: (payload) => deps.openManualPicker(payload), + }); +} diff --git a/src/main/runtime/secondary-sub-mode-main-deps.test.ts b/src/main/runtime/secondary-sub-mode-main-deps.test.ts new file mode 100644 index 0000000..4858580 --- /dev/null +++ b/src/main/runtime/secondary-sub-mode-main-deps.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildCycleSecondarySubModeMainDepsHandler } from './secondary-sub-mode-main-deps'; +import type { SecondarySubMode } from '../../types'; + +test('cycle secondary sub mode main deps builder maps state and broadcasts with channel', () => { + const calls: string[] = []; + let mode: SecondarySubMode = 'hover'; + let lastToggleAt = 100; + const deps = createBuildCycleSecondarySubModeMainDepsHandler({ + getSecondarySubMode: () => mode, + setSecondarySubMode: (nextMode) => { + mode = nextMode; + calls.push(`set-mode:${nextMode}`); + }, + getLastSecondarySubToggleAtMs: () => lastToggleAt, + setLastSecondarySubToggleAtMs: (timestampMs) => { + lastToggleAt = timestampMs; + calls.push(`set-ts:${timestampMs}`); + }, + broadcastToOverlayWindows: (channel, nextMode) => calls.push(`broadcast:${channel}:${nextMode}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + })(); + + assert.equal(deps.getSecondarySubMode(), 'hover'); + assert.equal(deps.getLastSecondarySubToggleAtMs(), 100); + deps.setSecondarySubMode('visible'); + deps.setLastSecondarySubToggleAtMs(200); + deps.broadcastSecondarySubMode('visible'); + deps.showMpvOsd('Secondary subtitle: visible'); + assert.equal(mode, 'visible'); + assert.equal(lastToggleAt, 200); + assert.deepEqual(calls, [ + 'set-mode:visible', + 'set-ts:200', + 'broadcast:secondary-subtitle:mode:visible', + 'osd:Secondary subtitle: visible', + ]); +}); diff --git a/src/main/runtime/secondary-sub-mode-main-deps.ts b/src/main/runtime/secondary-sub-mode-main-deps.ts new file mode 100644 index 0000000..d996000 --- /dev/null +++ b/src/main/runtime/secondary-sub-mode-main-deps.ts @@ -0,0 +1,21 @@ +import type { SecondarySubMode } from '../../types'; + +export function createBuildCycleSecondarySubModeMainDepsHandler(deps: { + getSecondarySubMode: () => SecondarySubMode; + setSecondarySubMode: (mode: SecondarySubMode) => void; + getLastSecondarySubToggleAtMs: () => number; + setLastSecondarySubToggleAtMs: (timestampMs: number) => void; + broadcastToOverlayWindows: (channel: string, mode: SecondarySubMode) => void; + showMpvOsd: (text: string) => void; +}) { + return () => ({ + getSecondarySubMode: () => deps.getSecondarySubMode(), + setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), + getLastSecondarySubToggleAtMs: () => deps.getLastSecondarySubToggleAtMs(), + setLastSecondarySubToggleAtMs: (timestampMs: number) => + deps.setLastSecondarySubToggleAtMs(timestampMs), + broadcastSecondarySubMode: (mode: SecondarySubMode) => + deps.broadcastToOverlayWindows('secondary-subtitle:mode', mode), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + }); +} diff --git a/src/main/runtime/secondary-sub-mode-runtime-handler.test.ts b/src/main/runtime/secondary-sub-mode-runtime-handler.test.ts new file mode 100644 index 0000000..a3c25d8 --- /dev/null +++ b/src/main/runtime/secondary-sub-mode-runtime-handler.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCycleSecondarySubModeRuntimeHandler } from './secondary-sub-mode-runtime-handler'; + +test('secondary sub mode runtime handler composes deps for runtime call', () => { + const calls: string[] = []; + const handleCycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ + cycleSecondarySubModeMainDeps: { + getSecondarySubMode: () => 'hidden', + setSecondarySubMode: () => calls.push('set-mode'), + getLastSecondarySubToggleAtMs: () => 10, + setLastSecondarySubToggleAtMs: () => calls.push('set-ts'), + broadcastToOverlayWindows: (channel, mode) => calls.push(`broadcast:${channel}:${mode}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + }, + cycleSecondarySubMode: (deps) => { + deps.setSecondarySubMode('romaji' as never); + deps.broadcastSecondarySubMode('romaji' as never); + deps.showMpvOsd('romaji'); + }, + }); + + handleCycleSecondarySubMode(); + assert.deepEqual(calls, [ + 'set-mode', + 'broadcast:secondary-subtitle:mode:romaji', + 'osd:romaji', + ]); +}); diff --git a/src/main/runtime/secondary-sub-mode-runtime-handler.ts b/src/main/runtime/secondary-sub-mode-runtime-handler.ts new file mode 100644 index 0000000..201e8ec --- /dev/null +++ b/src/main/runtime/secondary-sub-mode-runtime-handler.ts @@ -0,0 +1,17 @@ +import { createBuildCycleSecondarySubModeMainDepsHandler } from './secondary-sub-mode-main-deps'; + +type CycleSecondarySubModeMainDeps = Parameters< + typeof createBuildCycleSecondarySubModeMainDepsHandler +>[0]; +type CycleSecondarySubModeDeps = ReturnType< + ReturnType +>; + +export function createCycleSecondarySubModeRuntimeHandler(deps: { + cycleSecondarySubModeMainDeps: CycleSecondarySubModeMainDeps; + cycleSecondarySubMode: (deps: CycleSecondarySubModeDeps) => void; +}) { + const buildCycleSecondarySubModeMainDepsHandler = + createBuildCycleSecondarySubModeMainDepsHandler(deps.cycleSecondarySubModeMainDeps); + return () => deps.cycleSecondarySubMode(buildCycleSecondarySubModeMainDepsHandler()); +} diff --git a/src/main/runtime/startup-bootstrap-deps-builder.test.ts b/src/main/runtime/startup-bootstrap-deps-builder.test.ts new file mode 100644 index 0000000..d21b49e --- /dev/null +++ b/src/main/runtime/startup-bootstrap-deps-builder.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './startup-bootstrap-deps-builder'; + +test('startup bootstrap deps builder returns mapped runtime factory deps', () => { + const calls: string[] = []; + const factory = createBuildStartupBootstrapRuntimeFactoryDepsHandler({ + argv: ['node', 'main.js'], + parseArgs: () => ({}) as never, + setLogLevel: (level) => calls.push(`log:${level}`), + forceX11Backend: () => calls.push('force-x11'), + enforceUnsupportedWaylandMode: () => calls.push('wayland-guard'), + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + onConfigGenerated: (exitCode) => calls.push(`generated:${exitCode}`), + onGenerateConfigError: (error) => calls.push(`error:${error.message}`), + startAppLifecycle: () => calls.push('start-lifecycle'), + }); + + const deps = factory(); + assert.deepEqual(deps.argv, ['node', 'main.js']); + assert.equal(deps.getDefaultSocketPath(), '/tmp/mpv.sock'); + assert.equal(deps.defaultTexthookerPort, 5174); + deps.setLogLevel('debug', 'config'); + deps.forceX11Backend({} as never); + deps.enforceUnsupportedWaylandMode({} as never); + deps.onConfigGenerated(0); + deps.onGenerateConfigError(new Error('oops')); + deps.startAppLifecycle({} as never); + + assert.deepEqual(calls, [ + 'log:debug', + 'force-x11', + 'wayland-guard', + 'generated:0', + 'error:oops', + 'start-lifecycle', + ]); +}); diff --git a/src/main/runtime/startup-bootstrap-deps-builder.ts b/src/main/runtime/startup-bootstrap-deps-builder.ts new file mode 100644 index 0000000..5cd0364 --- /dev/null +++ b/src/main/runtime/startup-bootstrap-deps-builder.ts @@ -0,0 +1,32 @@ +import type { CliArgs } from '../../cli/args'; +import type { ResolvedConfig } from '../../types'; +import type { LogLevelSource } from '../../logger'; +import type { StartupBootstrapRuntimeFactoryDeps } from '../startup'; + +export function createBuildStartupBootstrapRuntimeFactoryDepsHandler( + deps: StartupBootstrapRuntimeFactoryDeps, +) { + return (): StartupBootstrapRuntimeFactoryDeps => ({ + argv: deps.argv, + parseArgs: deps.parseArgs, + setLogLevel: deps.setLogLevel, + forceX11Backend: deps.forceX11Backend, + enforceUnsupportedWaylandMode: deps.enforceUnsupportedWaylandMode, + shouldStartApp: deps.shouldStartApp, + getDefaultSocketPath: deps.getDefaultSocketPath, + defaultTexthookerPort: deps.defaultTexthookerPort, + configDir: deps.configDir, + defaultConfig: deps.defaultConfig, + generateConfigTemplate: deps.generateConfigTemplate, + generateDefaultConfigFile: deps.generateDefaultConfigFile, + onConfigGenerated: deps.onConfigGenerated, + onGenerateConfigError: deps.onGenerateConfigError, + startAppLifecycle: deps.startAppLifecycle, + }); +} + +export type { + CliArgs as StartupBuilderCliArgs, + ResolvedConfig as StartupBuilderResolvedConfig, + LogLevelSource as StartupBuilderLogLevelSource, +}; diff --git a/src/main/runtime/startup-bootstrap-main-deps.test.ts b/src/main/runtime/startup-bootstrap-main-deps.test.ts new file mode 100644 index 0000000..08bb8a3 --- /dev/null +++ b/src/main/runtime/startup-bootstrap-main-deps.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps'; + +test('startup bootstrap main deps builder maps deps and handles generate-config callbacks', () => { + const calls: string[] = []; + let exitCode = 0; + const deps = createBuildStartupBootstrapMainDepsHandler({ + argv: ['node', 'main.js'], + parseArgs: () => ({}) as never, + setLogLevel: (level) => calls.push(`log:${level}`), + forceX11Backend: () => calls.push('force-x11'), + enforceUnsupportedWaylandMode: () => calls.push('guard-wayland'), + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: (code) => { + exitCode = code; + calls.push(`exit:${code}`); + }, + quitApp: () => calls.push('quit'), + logGenerateConfigError: (message) => calls.push(`error:${message}`), + startAppLifecycle: () => calls.push('start-lifecycle'), + })(); + + assert.deepEqual(deps.argv, ['node', 'main.js']); + assert.equal(deps.getDefaultSocketPath(), '/tmp/mpv.sock'); + deps.setLogLevel('debug', 'config'); + deps.forceX11Backend({} as never); + deps.enforceUnsupportedWaylandMode({} as never); + deps.startAppLifecycle({} as never); + deps.onConfigGenerated(7); + assert.equal(exitCode, 7); + deps.onGenerateConfigError(new Error('boom')); + assert.equal(exitCode, 1); + + assert.deepEqual(calls, [ + 'log:debug', + 'force-x11', + 'guard-wayland', + 'start-lifecycle', + 'exit:7', + 'quit', + 'error:Failed to generate config: boom', + 'exit:1', + 'quit', + ]); +}); diff --git a/src/main/runtime/startup-bootstrap-main-deps.ts b/src/main/runtime/startup-bootstrap-main-deps.ts new file mode 100644 index 0000000..03b7d05 --- /dev/null +++ b/src/main/runtime/startup-bootstrap-main-deps.ts @@ -0,0 +1,62 @@ +import type { CliArgs } from '../../cli/args'; +import type { LogLevelSource } from '../../logger'; +import type { ResolvedConfig } from '../../types'; +import type { StartupBootstrapRuntimeFactoryDeps } from '../startup'; + +export function createBuildStartupBootstrapMainDepsHandler(deps: { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevel: (level: string, source: LogLevelSource) => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + shouldStartApp: (args: CliArgs) => boolean; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + configDir: string; + defaultConfig: ResolvedConfig; + generateConfigTemplate: (config: ResolvedConfig) => string; + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => Promise; + setExitCode: (code: number) => void; + quitApp: () => void; + logGenerateConfigError: (message: string) => void; + startAppLifecycle: (args: CliArgs) => void; +}) { + return (): StartupBootstrapRuntimeFactoryDeps => ({ + argv: deps.argv, + parseArgs: (argv: string[]) => deps.parseArgs(argv), + setLogLevel: (level: string, source: LogLevelSource) => deps.setLogLevel(level, source), + forceX11Backend: (args: CliArgs) => deps.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args: CliArgs) => deps.enforceUnsupportedWaylandMode(args), + shouldStartApp: (args: CliArgs) => deps.shouldStartApp(args), + getDefaultSocketPath: () => deps.getDefaultSocketPath(), + defaultTexthookerPort: deps.defaultTexthookerPort, + configDir: deps.configDir, + defaultConfig: deps.defaultConfig, + generateConfigTemplate: (config: ResolvedConfig) => deps.generateConfigTemplate(config), + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => deps.generateDefaultConfigFile(args, options), + onConfigGenerated: (exitCode: number) => { + deps.setExitCode(exitCode); + deps.quitApp(); + }, + onGenerateConfigError: (error: Error) => { + deps.logGenerateConfigError(`Failed to generate config: ${error.message}`); + deps.setExitCode(1); + deps.quitApp(); + }, + startAppLifecycle: (args: CliArgs) => deps.startAppLifecycle(args), + }); +} diff --git a/src/main/runtime/startup-config-main-deps.test.ts b/src/main/runtime/startup-config-main-deps.test.ts new file mode 100644 index 0000000..229ff93 --- /dev/null +++ b/src/main/runtime/startup-config-main-deps.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildCriticalConfigErrorMainDepsHandler, + createBuildReloadConfigMainDepsHandler, +} from './startup-config-main-deps'; + +test('reload config main deps builder maps callbacks and fail handlers', async () => { + const calls: string[] = []; + const deps = createBuildReloadConfigMainDepsHandler({ + reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }), + logInfo: (message) => calls.push(`info:${message}`), + logWarning: (message) => calls.push(`warn:${message}`), + showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), + startConfigHotReload: () => calls.push('start-hot-reload'), + refreshAnilistClientSecretState: async (options) => { + calls.push(`refresh:${options.force}`); + return true; + }, + failHandlers: { + logError: (details) => calls.push(`error:${details}`), + showErrorBox: (title, details) => calls.push(`error-box:${title}:${details}`), + quit: () => calls.push('quit'), + }, + })(); + + assert.deepEqual(deps.reloadConfigStrict(), { + ok: true, + path: '/tmp/config.jsonc', + warnings: [], + }); + deps.logInfo('x'); + deps.logWarning('y'); + deps.showDesktopNotification('SubMiner', { body: 'warn' }); + deps.startConfigHotReload(); + await deps.refreshAnilistClientSecretState({ force: true }); + deps.failHandlers.logError('bad'); + deps.failHandlers.showErrorBox('Oops', 'Details'); + deps.failHandlers.quit(); + assert.deepEqual(calls, [ + 'info:x', + 'warn:y', + 'notify:SubMiner:warn', + 'start-hot-reload', + 'refresh:true', + 'error:bad', + 'error-box:Oops:Details', + 'quit', + ]); +}); + +test('critical config main deps builder maps config path and fail handlers', () => { + const calls: string[] = []; + const deps = createBuildCriticalConfigErrorMainDepsHandler({ + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: (details) => calls.push(`error:${details}`), + showErrorBox: (title, details) => calls.push(`error-box:${title}:${details}`), + quit: () => calls.push('quit'), + }, + })(); + + assert.equal(deps.getConfigPath(), '/tmp/config.jsonc'); + deps.failHandlers.logError('bad'); + deps.failHandlers.showErrorBox('Oops', 'Details'); + deps.failHandlers.quit(); + assert.deepEqual(calls, ['error:bad', 'error-box:Oops:Details', 'quit']); +}); diff --git a/src/main/runtime/startup-config-main-deps.ts b/src/main/runtime/startup-config-main-deps.ts new file mode 100644 index 0000000..9670549 --- /dev/null +++ b/src/main/runtime/startup-config-main-deps.ts @@ -0,0 +1,35 @@ +import type { createCriticalConfigErrorHandler, createReloadConfigHandler } from './startup-config'; + +type ReloadConfigMainDeps = Parameters[0]; +type CriticalConfigErrorMainDeps = Parameters[0]; + +export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDeps) { + return (): ReloadConfigMainDeps => ({ + reloadConfigStrict: () => deps.reloadConfigStrict(), + logInfo: (message: string) => deps.logInfo(message), + logWarning: (message: string) => deps.logWarning(message), + showDesktopNotification: (title: string, options: { body: string }) => + deps.showDesktopNotification(title, options), + startConfigHotReload: () => deps.startConfigHotReload(), + refreshAnilistClientSecretState: (options: { force: boolean }) => + deps.refreshAnilistClientSecretState(options), + failHandlers: { + logError: (details: string) => deps.failHandlers.logError(details), + showErrorBox: (title: string, details: string) => + deps.failHandlers.showErrorBox(title, details), + quit: () => deps.failHandlers.quit(), + }, + }); +} + +export function createBuildCriticalConfigErrorMainDepsHandler(deps: CriticalConfigErrorMainDeps) { + return (): CriticalConfigErrorMainDeps => ({ + getConfigPath: () => deps.getConfigPath(), + failHandlers: { + logError: (details: string) => deps.failHandlers.logError(details), + showErrorBox: (title: string, details: string) => + deps.failHandlers.showErrorBox(title, details), + quit: () => deps.failHandlers.quit(), + }, + }); +} diff --git a/src/main/runtime/startup-config.test.ts b/src/main/runtime/startup-config.test.ts new file mode 100644 index 0000000..742ece7 --- /dev/null +++ b/src/main/runtime/startup-config.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createCriticalConfigErrorHandler, createReloadConfigHandler } from './startup-config'; + +test('createReloadConfigHandler runs success flow with warnings', async () => { + const calls: string[] = []; + const refreshCalls: { force: boolean }[] = []; + + const reloadConfig = createReloadConfigHandler({ + reloadConfigStrict: () => ({ + ok: true, + path: '/tmp/config.jsonc', + warnings: [ + { + path: 'ankiConnect.pollingRate', + message: 'must be >= 50', + value: 10, + fallback: 250, + }, + ], + }), + logInfo: (message) => calls.push(`info:${message}`), + logWarning: (message) => calls.push(`warn:${message}`), + showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), + startConfigHotReload: () => calls.push('hotReload:start'), + refreshAnilistClientSecretState: async (options) => { + refreshCalls.push(options); + }, + failHandlers: { + logError: (details) => calls.push(`error:${details}`), + showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`), + quit: () => calls.push('quit'), + }, + }); + + reloadConfig(); + await Promise.resolve(); + + assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc'))); + assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)'))); + assert.ok( + calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')), + ); + assert.ok(calls.some((entry) => entry.includes('1. ankiConnect.pollingRate: must be >= 50'))); + assert.ok(calls.includes('hotReload:start')); + assert.deepEqual(refreshCalls, [{ force: true }]); +}); + +test('createReloadConfigHandler fails startup for parse errors', () => { + const calls: string[] = []; + const previousExitCode = process.exitCode; + process.exitCode = 0; + + const reloadConfig = createReloadConfigHandler({ + reloadConfigStrict: () => ({ + ok: false, + path: '/tmp/config.jsonc', + error: 'unexpected token', + }), + logInfo: (message) => calls.push(`info:${message}`), + logWarning: (message) => calls.push(`warn:${message}`), + showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), + startConfigHotReload: () => calls.push('hotReload:start'), + refreshAnilistClientSecretState: async () => { + calls.push('refresh'); + }, + failHandlers: { + logError: (details) => calls.push(`error:${details}`), + showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`), + quit: () => calls.push('quit'), + }, + }); + + assert.throws(() => reloadConfig(), /Failed to parse config file at:/); + assert.equal(process.exitCode, 1); + assert.ok(calls.some((entry) => entry.startsWith('error:Failed to parse config file at:'))); + assert.ok(calls.some((entry) => entry.includes('/tmp/config.jsonc'))); + assert.ok(calls.some((entry) => entry.includes('Error: unexpected token'))); + assert.ok(calls.some((entry) => entry.includes('Fix the config file and restart SubMiner.'))); + assert.ok( + calls.some((entry) => + entry.startsWith('dialog:SubMiner config parse error:Failed to parse config file at:'), + ), + ); + assert.ok(calls.includes('quit')); + assert.equal(calls.includes('hotReload:start'), false); + + process.exitCode = previousExitCode; +}); + +test('createCriticalConfigErrorHandler formats and fails', () => { + const calls: string[] = []; + const previousExitCode = process.exitCode; + process.exitCode = 0; + + const handleCriticalErrors = createCriticalConfigErrorHandler({ + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: (details) => calls.push(`error:${details}`), + showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`), + quit: () => calls.push('quit'), + }, + }); + + assert.throws( + () => handleCriticalErrors(['foo invalid', 'bar invalid']), + /Critical config validation failed/, + ); + + assert.equal(process.exitCode, 1); + assert.ok(calls.some((entry) => entry.includes('/tmp/config.jsonc'))); + assert.ok(calls.some((entry) => entry.includes('1. foo invalid'))); + assert.ok(calls.some((entry) => entry.includes('2. bar invalid'))); + assert.ok(calls.includes('quit')); + + process.exitCode = previousExitCode; +}); diff --git a/src/main/runtime/startup-config.ts b/src/main/runtime/startup-config.ts new file mode 100644 index 0000000..74f26e6 --- /dev/null +++ b/src/main/runtime/startup-config.ts @@ -0,0 +1,84 @@ +import type { ConfigValidationWarning } from '../../types'; +import { + buildConfigParseErrorDetails, + buildConfigWarningNotificationBody, + buildConfigWarningSummary, + failStartupFromConfig, +} from '../config-validation'; + +type ReloadConfigFailure = { + ok: false; + path: string; + error: string; +}; + +type ReloadConfigSuccess = { + ok: true; + path: string; + warnings: ConfigValidationWarning[]; +}; + +type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess; + +export type ReloadConfigRuntimeDeps = { + reloadConfigStrict: () => ReloadConfigStrictResult; + logInfo: (message: string) => void; + logWarning: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + startConfigHotReload: () => void; + refreshAnilistClientSecretState: (options: { force: boolean }) => Promise; + failHandlers: { + logError: (details: string) => void; + showErrorBox: (title: string, details: string) => void; + quit: () => void; + }; +}; + +export type CriticalConfigErrorRuntimeDeps = { + getConfigPath: () => string; + failHandlers: { + logError: (details: string) => void; + showErrorBox: (title: string, details: string) => void; + quit: () => void; + }; +}; + +export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () => void { + return () => { + const result = deps.reloadConfigStrict(); + if (!result.ok) { + failStartupFromConfig( + 'SubMiner config parse error', + buildConfigParseErrorDetails(result.path, result.error), + deps.failHandlers, + ); + } + + deps.logInfo(`Using config file: ${result.path}`); + if (result.warnings.length > 0) { + deps.logWarning(buildConfigWarningSummary(result.path, result.warnings)); + deps.showDesktopNotification('SubMiner', { + body: buildConfigWarningNotificationBody(result.path, result.warnings), + }); + } + + deps.startConfigHotReload(); + void deps.refreshAnilistClientSecretState({ force: true }); + }; +} + +export function createCriticalConfigErrorHandler( + deps: CriticalConfigErrorRuntimeDeps, +): (errors: string[]) => never { + return (errors: string[]) => { + const configPath = deps.getConfigPath(); + const details = [ + `Critical config validation failed. File: ${configPath}`, + '', + ...errors.map((error, index) => `${index + 1}. ${error}`), + '', + 'Fix the config file and restart SubMiner.', + ].join('\n'); + return failStartupFromConfig('SubMiner config validation error', details, deps.failHandlers); + }; +} diff --git a/src/main/runtime/startup-lifecycle-main-deps.test.ts b/src/main/runtime/startup-lifecycle-main-deps.test.ts new file mode 100644 index 0000000..29c2b71 --- /dev/null +++ b/src/main/runtime/startup-lifecycle-main-deps.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './startup-lifecycle-main-deps'; + +test('app lifecycle runtime runner main deps builder maps lifecycle callbacks', async () => { + const calls: string[] = []; + const deps = createBuildAppLifecycleRuntimeRunnerMainDepsHandler({ + app: {} as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => calls.push('handle-cli'), + printHelp: () => calls.push('help'), + logNoRunningInstance: () => calls.push('no-instance'), + onReady: async () => { + calls.push('ready'); + }, + onWillQuitCleanup: () => calls.push('cleanup'), + shouldRestoreWindowsOnActivate: () => true, + restoreWindowsOnActivate: () => calls.push('restore'), + shouldQuitOnWindowAllClosed: () => false, + })(); + + assert.equal(deps.platform, 'darwin'); + assert.equal(deps.shouldStartApp({} as never), true); + deps.handleCliCommand({} as never, 'initial'); + deps.printHelp(); + deps.logNoRunningInstance(); + await deps.onReady(); + deps.onWillQuitCleanup(); + deps.restoreWindowsOnActivate(); + assert.equal(deps.shouldRestoreWindowsOnActivate(), true); + assert.equal(deps.shouldQuitOnWindowAllClosed(), false); + assert.deepEqual(calls, ['handle-cli', 'help', 'no-instance', 'ready', 'cleanup', 'restore']); +}); diff --git a/src/main/runtime/startup-lifecycle-main-deps.ts b/src/main/runtime/startup-lifecycle-main-deps.ts new file mode 100644 index 0000000..ebe63c4 --- /dev/null +++ b/src/main/runtime/startup-lifecycle-main-deps.ts @@ -0,0 +1,20 @@ +import type { AppLifecycleRuntimeRunnerParams } from '../startup-lifecycle'; + +export function createBuildAppLifecycleRuntimeRunnerMainDepsHandler( + deps: AppLifecycleRuntimeRunnerParams, +) { + return (): AppLifecycleRuntimeRunnerParams => ({ + app: deps.app, + platform: deps.platform, + shouldStartApp: deps.shouldStartApp, + parseArgs: deps.parseArgs, + handleCliCommand: deps.handleCliCommand, + printHelp: deps.printHelp, + logNoRunningInstance: deps.logNoRunningInstance, + onReady: deps.onReady, + onWillQuitCleanup: deps.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: deps.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: deps.shouldQuitOnWindowAllClosed, + }); +} diff --git a/src/main/runtime/startup-runtime-handlers.test.ts b/src/main/runtime/startup-runtime-handlers.test.ts new file mode 100644 index 0000000..5c9617a --- /dev/null +++ b/src/main/runtime/startup-runtime-handlers.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { CliArgs } from '../../cli/args'; +import { createStartupRuntimeHandlers } from './startup-runtime-handlers'; + +test('startup runtime handlers compose lifecycle runner and startup bootstrap apply flow', () => { + const calls: string[] = []; + const appliedStates: Array<{ mode: string }> = []; + + const runtime = createStartupRuntimeHandlers< + { command: string }, + { mode: string }, + { startAppLifecycle: (args: CliArgs) => void } + >({ + appLifecycleRuntimeRunnerMainDeps: { + app: { on: () => {} } as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => calls.push('handle-cli'), + printHelp: () => calls.push('help'), + logNoRunningInstance: () => calls.push('no-instance'), + onReady: async () => { + calls.push('ready'); + }, + onWillQuitCleanup: () => calls.push('cleanup'), + shouldRestoreWindowsOnActivate: () => true, + restoreWindowsOnActivate: () => calls.push('restore'), + shouldQuitOnWindowAllClosed: () => false, + }, + createAppLifecycleRuntimeRunner: () => (args) => { + calls.push(`lifecycle:${args.command}`); + }, + buildStartupBootstrapMainDeps: (startAppLifecycle) => ({ + argv: ['node', 'main.js'], + parseArgs: () => ({ command: 'start' }) as never, + setLogLevel: () => {}, + forceX11Backend: () => {}, + enforceUnsupportedWaylandMode: () => {}, + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: () => {}, + quitApp: () => {}, + logGenerateConfigError: () => {}, + startAppLifecycle: (args) => { + const typedArgs = args as unknown as { command: string }; + calls.push(`start-lifecycle:${typedArgs.command}`); + startAppLifecycle(typedArgs); + }, + }), + createStartupBootstrapRuntimeDeps: (deps) => ({ + startAppLifecycle: deps.startAppLifecycle, + }), + runStartupBootstrapRuntime: (deps) => { + deps.startAppLifecycle({ command: 'start' } as never); + return { mode: 'started' }; + }, + applyStartupState: (state) => { + appliedStates.push(state); + }, + }); + + const state = runtime.runAndApplyStartupState(); + assert.deepEqual(state, { mode: 'started' }); + assert.deepEqual(appliedStates, [{ mode: 'started' }]); + assert.deepEqual(calls, ['start-lifecycle:start', 'lifecycle:start']); +}); diff --git a/src/main/runtime/startup-runtime-handlers.ts b/src/main/runtime/startup-runtime-handlers.ts new file mode 100644 index 0000000..7edf96f --- /dev/null +++ b/src/main/runtime/startup-runtime-handlers.ts @@ -0,0 +1,55 @@ +import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './startup-bootstrap-deps-builder'; +import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './startup-lifecycle-main-deps'; +import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps'; + +type AppLifecycleRuntimeRunnerMainDeps = Parameters< + typeof createBuildAppLifecycleRuntimeRunnerMainDepsHandler +>[0]; +type StartupBootstrapMainDeps = Parameters[0]; +type StartupBootstrapRuntimeFactoryDeps = Parameters< + typeof createBuildStartupBootstrapRuntimeFactoryDepsHandler +>[0]; + +export function createStartupRuntimeHandlers< + TCliArgs, + TStartupState, + TStartupBootstrapRuntimeDeps = unknown, +>(deps: { + appLifecycleRuntimeRunnerMainDeps: AppLifecycleRuntimeRunnerMainDeps; + createAppLifecycleRuntimeRunner: ( + params: AppLifecycleRuntimeRunnerMainDeps, + ) => (args: TCliArgs) => void; + buildStartupBootstrapMainDeps: ( + startAppLifecycle: (args: TCliArgs) => void, + ) => StartupBootstrapMainDeps; + createStartupBootstrapRuntimeDeps: ( + deps: StartupBootstrapRuntimeFactoryDeps, + ) => TStartupBootstrapRuntimeDeps; + runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState; + applyStartupState: (startupState: TStartupState) => void; +}) { + const appLifecycleRuntimeRunnerMainDeps = + createBuildAppLifecycleRuntimeRunnerMainDepsHandler(deps.appLifecycleRuntimeRunnerMainDeps)(); + const appLifecycleRuntimeRunner = deps.createAppLifecycleRuntimeRunner( + appLifecycleRuntimeRunnerMainDeps, + ); + + const startupBootstrapMainDeps = createBuildStartupBootstrapMainDepsHandler( + deps.buildStartupBootstrapMainDeps(appLifecycleRuntimeRunner), + )(); + const startupBootstrapRuntimeFactoryDeps = + createBuildStartupBootstrapRuntimeFactoryDepsHandler(startupBootstrapMainDeps)(); + + const runAndApplyStartupState = (): TStartupState => { + const startupState = deps.runStartupBootstrapRuntime( + deps.createStartupBootstrapRuntimeDeps(startupBootstrapRuntimeFactoryDeps), + ); + deps.applyStartupState(startupState); + return startupState; + }; + + return { + appLifecycleRuntimeRunner, + runAndApplyStartupState, + }; +} diff --git a/src/main/runtime/startup-warmups-main-deps.test.ts b/src/main/runtime/startup-warmups-main-deps.test.ts new file mode 100644 index 0000000..aca4ba8 --- /dev/null +++ b/src/main/runtime/startup-warmups-main-deps.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildLaunchBackgroundWarmupTaskMainDepsHandler, + createBuildStartBackgroundWarmupsMainDepsHandler, +} from './startup-warmups-main-deps'; + +test('startup warmups main deps builders map callbacks', async () => { + const calls: string[] = []; + + const launch = createBuildLaunchBackgroundWarmupTaskMainDepsHandler({ + now: () => 11, + logDebug: (message) => calls.push(`debug:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + })(); + assert.equal(launch.now(), 11); + launch.logDebug('x'); + launch.logWarn('y'); + + const start = createBuildStartBackgroundWarmupsMainDepsHandler({ + getStarted: () => false, + setStarted: (started) => calls.push(`started:${started}`), + isTexthookerOnlyMode: () => false, + launchTask: (label, task) => { + calls.push(`launch:${label}`); + void task(); + }, + createMecabTokenizerAndCheck: async () => { + calls.push('mecab'); + }, + ensureYomitanExtensionLoaded: async () => { + calls.push('yomitan'); + }, + prewarmSubtitleDictionaries: async () => { + calls.push('dict'); + }, + shouldAutoConnectJellyfinRemote: () => true, + startJellyfinRemoteSession: async () => { + calls.push('jellyfin'); + }, + })(); + assert.equal(start.getStarted(), false); + start.setStarted(true); + assert.equal(start.isTexthookerOnlyMode(), false); + start.launchTask('demo', async () => { + calls.push('task'); + }); + await start.createMecabTokenizerAndCheck(); + await start.ensureYomitanExtensionLoaded(); + await start.prewarmSubtitleDictionaries(); + assert.equal(start.shouldAutoConnectJellyfinRemote(), true); + await start.startJellyfinRemoteSession(); + + assert.deepEqual(calls, [ + 'debug:x', + 'warn:y', + 'started:true', + 'launch:demo', + 'task', + 'mecab', + 'yomitan', + 'dict', + 'jellyfin', + ]); +}); diff --git a/src/main/runtime/startup-warmups-main-deps.ts b/src/main/runtime/startup-warmups-main-deps.ts new file mode 100644 index 0000000..23795a4 --- /dev/null +++ b/src/main/runtime/startup-warmups-main-deps.ts @@ -0,0 +1,31 @@ +import type { + createLaunchBackgroundWarmupTaskHandler, + createStartBackgroundWarmupsHandler, +} from './startup-warmups'; + +type LaunchBackgroundWarmupTaskMainDeps = Parameters[0]; +type StartBackgroundWarmupsMainDeps = Parameters[0]; + +export function createBuildLaunchBackgroundWarmupTaskMainDepsHandler( + deps: LaunchBackgroundWarmupTaskMainDeps, +) { + return (): LaunchBackgroundWarmupTaskMainDeps => ({ + now: () => deps.now(), + logDebug: (message: string) => deps.logDebug(message), + logWarn: (message: string) => deps.logWarn(message), + }); +} + +export function createBuildStartBackgroundWarmupsMainDepsHandler(deps: StartBackgroundWarmupsMainDeps) { + return (): StartBackgroundWarmupsMainDeps => ({ + getStarted: () => deps.getStarted(), + setStarted: (started: boolean) => deps.setStarted(started), + isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(), + launchTask: (label: string, task: () => Promise) => deps.launchTask(label, task), + createMecabTokenizerAndCheck: () => deps.createMecabTokenizerAndCheck(), + ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(), + prewarmSubtitleDictionaries: () => deps.prewarmSubtitleDictionaries(), + shouldAutoConnectJellyfinRemote: () => deps.shouldAutoConnectJellyfinRemote(), + startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(), + }); +} diff --git a/src/main/runtime/startup-warmups.test.ts b/src/main/runtime/startup-warmups.test.ts new file mode 100644 index 0000000..3d30162 --- /dev/null +++ b/src/main/runtime/startup-warmups.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createLaunchBackgroundWarmupTaskHandler, + createStartBackgroundWarmupsHandler, +} from './startup-warmups'; + +function shouldAutoConnectJellyfinRemote(config: { + enabled: boolean; + remoteControlEnabled: boolean; + remoteControlAutoConnect: boolean; +}): boolean { + return config.enabled && config.remoteControlEnabled && config.remoteControlAutoConnect; +} + +test('launchBackgroundWarmupTask logs completion timing', async () => { + const debugLogs: string[] = []; + const launchTask = createLaunchBackgroundWarmupTaskHandler({ + now: (() => { + let tick = 0; + return () => ++tick * 10; + })(), + logDebug: (message) => debugLogs.push(message), + logWarn: () => {}, + }); + + launchTask('demo', async () => {}); + await Promise.resolve(); + assert.ok(debugLogs.some((line) => line.includes('[startup-warmup] demo completed in'))); +}); + +test('startBackgroundWarmups no-ops when already started', () => { + let launches = 0; + const startWarmups = createStartBackgroundWarmupsHandler({ + getStarted: () => true, + setStarted: () => {}, + isTexthookerOnlyMode: () => false, + launchTask: () => { + launches += 1; + }, + createMecabTokenizerAndCheck: async () => {}, + ensureYomitanExtensionLoaded: async () => {}, + prewarmSubtitleDictionaries: async () => {}, + shouldAutoConnectJellyfinRemote: () => false, + startJellyfinRemoteSession: async () => {}, + }); + + startWarmups(); + assert.equal(launches, 0); +}); + +test('startBackgroundWarmups does not schedule jellyfin warmup when jellyfin.enabled is false', () => { + const labels: string[] = []; + let started = false; + const startWarmups = createStartBackgroundWarmupsHandler({ + getStarted: () => started, + setStarted: (value) => { + started = value; + }, + isTexthookerOnlyMode: () => false, + launchTask: (label) => { + labels.push(label); + }, + createMecabTokenizerAndCheck: async () => {}, + ensureYomitanExtensionLoaded: async () => {}, + prewarmSubtitleDictionaries: async () => {}, + shouldAutoConnectJellyfinRemote: () => + shouldAutoConnectJellyfinRemote({ + enabled: false, + remoteControlEnabled: true, + remoteControlAutoConnect: true, + }), + startJellyfinRemoteSession: async () => {}, + }); + + startWarmups(); + assert.equal(started, true); + assert.deepEqual(labels, ['mecab', 'yomitan-extension', 'subtitle-dictionaries']); +}); + +test('startBackgroundWarmups schedules jellyfin warmup when all jellyfin flags are enabled', () => { + const labels: string[] = []; + let started = false; + const startWarmups = createStartBackgroundWarmupsHandler({ + getStarted: () => started, + setStarted: (value) => { + started = value; + }, + isTexthookerOnlyMode: () => false, + launchTask: (label) => { + labels.push(label); + }, + createMecabTokenizerAndCheck: async () => {}, + ensureYomitanExtensionLoaded: async () => {}, + prewarmSubtitleDictionaries: async () => {}, + shouldAutoConnectJellyfinRemote: () => + shouldAutoConnectJellyfinRemote({ + enabled: true, + remoteControlEnabled: true, + remoteControlAutoConnect: true, + }), + startJellyfinRemoteSession: async () => {}, + }); + + startWarmups(); + assert.equal(started, true); + assert.deepEqual(labels, [ + 'mecab', + 'yomitan-extension', + 'subtitle-dictionaries', + 'jellyfin-remote-session', + ]); +}); diff --git a/src/main/runtime/startup-warmups.ts b/src/main/runtime/startup-warmups.ts new file mode 100644 index 0000000..87996ac --- /dev/null +++ b/src/main/runtime/startup-warmups.ts @@ -0,0 +1,50 @@ +export function createLaunchBackgroundWarmupTaskHandler(deps: { + now: () => number; + logDebug: (message: string) => void; + logWarn: (message: string) => void; +}) { + return (label: string, task: () => Promise): void => { + const startedAtMs = deps.now(); + void task() + .then(() => { + deps.logDebug(`[startup-warmup] ${label} completed in ${deps.now() - startedAtMs}ms`); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + deps.logWarn(`[startup-warmup] ${label} failed: ${message}`); + }); + }; +} + +export function createStartBackgroundWarmupsHandler(deps: { + getStarted: () => boolean; + setStarted: (started: boolean) => void; + isTexthookerOnlyMode: () => boolean; + launchTask: (label: string, task: () => Promise) => void; + createMecabTokenizerAndCheck: () => Promise; + ensureYomitanExtensionLoaded: () => Promise; + prewarmSubtitleDictionaries: () => Promise; + shouldAutoConnectJellyfinRemote: () => boolean; + startJellyfinRemoteSession: () => Promise; +}) { + return (): void => { + if (deps.getStarted()) return; + if (deps.isTexthookerOnlyMode()) return; + + deps.setStarted(true); + deps.launchTask('mecab', async () => { + await deps.createMecabTokenizerAndCheck(); + }); + deps.launchTask('yomitan-extension', async () => { + await deps.ensureYomitanExtensionLoaded(); + }); + deps.launchTask('subtitle-dictionaries', async () => { + await deps.prewarmSubtitleDictionaries(); + }); + if (deps.shouldAutoConnectJellyfinRemote()) { + deps.launchTask('jellyfin-remote-session', async () => { + await deps.startJellyfinRemoteSession(); + }); + } + }; +} diff --git a/src/main/runtime/subsync-runtime.ts b/src/main/runtime/subsync-runtime.ts new file mode 100644 index 0000000..b0dcdb4 --- /dev/null +++ b/src/main/runtime/subsync-runtime.ts @@ -0,0 +1,37 @@ +import type { MpvIpcClient } from '../../core/services'; +import { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from '../../core/services'; +import type { SubsyncResult, SubsyncManualPayload, SubsyncManualRunRequest, ResolvedConfig } from '../../types'; +import { getSubsyncConfig } from '../../subsync/utils'; +import { createSubsyncRuntimeServiceInputFromState } from '../subsync-runtime'; + +export type MainSubsyncRuntimeDeps = { + getMpvClient: () => MpvIpcClient | null; + getResolvedConfig: () => ResolvedConfig; + getSubsyncInProgress: () => boolean; + setSubsyncInProgress: (inProgress: boolean) => void; + showMpvOsd: (text: string) => void; + openManualPicker: (payload: SubsyncManualPayload) => void; +}; + +export function createMainSubsyncRuntime(deps: MainSubsyncRuntimeDeps): { + triggerFromConfig: () => Promise; + runManualFromIpc: (request: SubsyncManualRunRequest) => Promise; +} { + const getRuntimeServiceParams = () => + createSubsyncRuntimeServiceInputFromState({ + getMpvClient: () => deps.getMpvClient(), + getResolvedSubsyncConfig: () => getSubsyncConfig(deps.getResolvedConfig().subsync), + getSubsyncInProgress: () => deps.getSubsyncInProgress(), + setSubsyncInProgress: (inProgress: boolean) => deps.setSubsyncInProgress(inProgress), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + openManualPicker: (payload: SubsyncManualPayload) => deps.openManualPicker(payload), + }); + + return { + triggerFromConfig: async (): Promise => { + await triggerSubsyncFromConfigRuntime(getRuntimeServiceParams()); + }, + runManualFromIpc: async (request: SubsyncManualRunRequest): Promise => + runSubsyncManualFromIpcRuntime(request, getRuntimeServiceParams()), + }; +} diff --git a/src/main/runtime/subtitle-position-main-deps.test.ts b/src/main/runtime/subtitle-position-main-deps.test.ts new file mode 100644 index 0000000..e2e2ee6 --- /dev/null +++ b/src/main/runtime/subtitle-position-main-deps.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildLoadSubtitlePositionMainDepsHandler, + createBuildSaveSubtitlePositionMainDepsHandler, +} from './subtitle-position-main-deps'; + +test('load subtitle position main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildLoadSubtitlePositionMainDepsHandler({ + loadSubtitlePositionCore: () => ({ x: 1, y: 2 } as never), + setSubtitlePosition: () => calls.push('set'), + })(); + + assert.deepEqual(deps.loadSubtitlePositionCore(), { x: 1, y: 2 }); + deps.setSubtitlePosition({ x: 3, y: 4 } as never); + assert.deepEqual(calls, ['set']); +}); + +test('save subtitle position main deps builder maps callbacks', () => { + const calls: string[] = []; + const deps = createBuildSaveSubtitlePositionMainDepsHandler({ + saveSubtitlePositionCore: () => calls.push('persist'), + setSubtitlePosition: () => calls.push('set'), + })(); + + deps.setSubtitlePosition({ x: 1, y: 2 } as never); + deps.saveSubtitlePositionCore({ x: 1, y: 2 } as never); + assert.deepEqual(calls, ['set', 'persist']); +}); diff --git a/src/main/runtime/subtitle-position-main-deps.ts b/src/main/runtime/subtitle-position-main-deps.ts new file mode 100644 index 0000000..9e01ba8 --- /dev/null +++ b/src/main/runtime/subtitle-position-main-deps.ts @@ -0,0 +1,21 @@ +import type { + createLoadSubtitlePositionHandler, + createSaveSubtitlePositionHandler, +} from './subtitle-position'; + +type LoadSubtitlePositionMainDeps = Parameters[0]; +type SaveSubtitlePositionMainDeps = Parameters[0]; + +export function createBuildLoadSubtitlePositionMainDepsHandler(deps: LoadSubtitlePositionMainDeps) { + return (): LoadSubtitlePositionMainDeps => ({ + loadSubtitlePositionCore: () => deps.loadSubtitlePositionCore(), + setSubtitlePosition: (position) => deps.setSubtitlePosition(position), + }); +} + +export function createBuildSaveSubtitlePositionMainDepsHandler(deps: SaveSubtitlePositionMainDeps) { + return (): SaveSubtitlePositionMainDeps => ({ + saveSubtitlePositionCore: (position) => deps.saveSubtitlePositionCore(position), + setSubtitlePosition: (position) => deps.setSubtitlePosition(position), + }); +} diff --git a/src/main/runtime/subtitle-position.test.ts b/src/main/runtime/subtitle-position.test.ts new file mode 100644 index 0000000..95f4736 --- /dev/null +++ b/src/main/runtime/subtitle-position.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createLoadSubtitlePositionHandler, + createSaveSubtitlePositionHandler, +} from './subtitle-position'; + +test('createLoadSubtitlePositionHandler stores loaded value', () => { + let stored: unknown = null; + const position = { x: 10, y: 20 }; + const load = createLoadSubtitlePositionHandler({ + loadSubtitlePositionCore: () => position as unknown as never, + setSubtitlePosition: (value) => { + stored = value; + }, + }); + const result = load(); + assert.equal(result, position); + assert.equal(stored, position); +}); + +test('createSaveSubtitlePositionHandler stores then persists value', () => { + const calls: string[] = []; + const position = { x: 5, y: 7 } as unknown as never; + const save = createSaveSubtitlePositionHandler({ + saveSubtitlePositionCore: () => { + calls.push('persist'); + }, + setSubtitlePosition: () => { + calls.push('store'); + }, + }); + save(position); + assert.deepEqual(calls, ['store', 'persist']); +}); diff --git a/src/main/runtime/subtitle-position.ts b/src/main/runtime/subtitle-position.ts new file mode 100644 index 0000000..e7f324e --- /dev/null +++ b/src/main/runtime/subtitle-position.ts @@ -0,0 +1,22 @@ +import type { SubtitlePosition } from '../../types'; + +export function createLoadSubtitlePositionHandler(deps: { + loadSubtitlePositionCore: () => SubtitlePosition | null; + setSubtitlePosition: (position: SubtitlePosition | null) => void; +}) { + return (): SubtitlePosition | null => { + const position = deps.loadSubtitlePositionCore(); + deps.setSubtitlePosition(position); + return position; + }; +} + +export function createSaveSubtitlePositionHandler(deps: { + saveSubtitlePositionCore: (position: SubtitlePosition) => void; + setSubtitlePosition: (position: SubtitlePosition) => void; +}) { + return (position: SubtitlePosition): void => { + deps.setSubtitlePosition(position); + deps.saveSubtitlePositionCore(position); + }; +} diff --git a/src/main/runtime/subtitle-processing-main-deps.test.ts b/src/main/runtime/subtitle-processing-main-deps.test.ts new file mode 100644 index 0000000..adc5666 --- /dev/null +++ b/src/main/runtime/subtitle-processing-main-deps.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildSubtitleProcessingControllerMainDepsHandler } from './subtitle-processing-main-deps'; + +test('subtitle processing main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildSubtitleProcessingControllerMainDepsHandler({ + tokenizeSubtitle: async (text) => { + calls.push(`tokenize:${text}`); + return { text, tokens: null }; + }, + emitSubtitle: (payload) => calls.push(`emit:${payload.text}`), + logDebug: (message) => calls.push(`log:${message}`), + now: () => 42, + })(); + + const tokenized = await deps.tokenizeSubtitle('line'); + deps.emitSubtitle({ text: 'line', tokens: null }); + deps.logDebug?.('ok'); + assert.equal(deps.now?.(), 42); + assert.deepEqual(tokenized, { text: 'line', tokens: null }); + assert.deepEqual(calls, ['tokenize:line', 'emit:line', 'log:ok']); +}); + +test('subtitle processing main deps builder preserves optional callbacks when absent', () => { + const deps = createBuildSubtitleProcessingControllerMainDepsHandler({ + tokenizeSubtitle: async () => null, + emitSubtitle: () => {}, + })(); + + assert.equal(deps.logDebug, undefined); + assert.equal(deps.now, undefined); +}); diff --git a/src/main/runtime/subtitle-processing-main-deps.ts b/src/main/runtime/subtitle-processing-main-deps.ts new file mode 100644 index 0000000..7d971e4 --- /dev/null +++ b/src/main/runtime/subtitle-processing-main-deps.ts @@ -0,0 +1,12 @@ +import type { SubtitleProcessingControllerDeps } from '../../core/services/subtitle-processing-controller'; + +export function createBuildSubtitleProcessingControllerMainDepsHandler( + deps: SubtitleProcessingControllerDeps, +) { + return (): SubtitleProcessingControllerDeps => ({ + tokenizeSubtitle: (text: string) => deps.tokenizeSubtitle(text), + emitSubtitle: (payload) => deps.emitSubtitle(payload), + logDebug: deps.logDebug, + now: deps.now, + }); +} diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-main-deps.test.ts new file mode 100644 index 0000000..fc82152 --- /dev/null +++ b/src/main/runtime/subtitle-tokenization-main-deps.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildTokenizerDepsMainHandler, + createCreateMecabTokenizerAndCheckMainHandler, + createPrewarmSubtitleDictionariesMainHandler, +} from './subtitle-tokenization-main-deps'; + +test('tokenizer deps builder records known-word lookups and maps readers', () => { + const calls: string[] = []; + const deps = createBuildTokenizerDepsMainHandler({ + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => calls.push('set-window'), + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => calls.push('set-ready'), + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => calls.push('set-init'), + isKnownWord: (text) => text === 'known', + recordLookup: (hit) => calls.push(`lookup:${hit}`), + getKnownWordMatchMode: () => 'surface', + getMinSentenceWordsForNPlusOne: () => 3, + getJlptLevel: () => 'N2', + getJlptEnabled: () => true, + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: () => 5, + getYomitanGroupDebugEnabled: () => false, + getMecabTokenizer: () => null, + })(); + + assert.equal(deps.isKnownWord('known'), true); + assert.equal(deps.isKnownWord('unknown'), false); + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + deps.setYomitanParserInitPromise(null); + assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3); + assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']); +}); + +test('mecab tokenizer check creates tokenizer once and runs availability check', async () => { + const calls: string[] = []; + type Tokenizer = { id: string }; + let tokenizer: Tokenizer | null = null; + const run = createCreateMecabTokenizerAndCheckMainHandler({ + getMecabTokenizer: () => tokenizer, + setMecabTokenizer: (next) => { + tokenizer = next; + calls.push('set'); + }, + createMecabTokenizer: () => { + calls.push('create'); + return { id: 'mecab' }; + }, + checkAvailability: async () => { + calls.push('check'); + }, + }); + + await run(); + await run(); + assert.deepEqual(calls, ['create', 'set', 'check', 'check']); +}); + +test('dictionary prewarm runs both dictionary loaders', async () => { + const calls: string[] = []; + const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + ensureJlptDictionaryLookup: async () => { + calls.push('jlpt'); + }, + ensureFrequencyDictionaryLookup: async () => { + calls.push('freq'); + }, + }); + + await prewarm(); + assert.deepEqual(calls.sort(), ['freq', 'jlpt']); +}); diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts new file mode 100644 index 0000000..7370298 --- /dev/null +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -0,0 +1,68 @@ +import type { TokenizerDepsRuntimeOptions } from '../../core/services/tokenizer'; + +type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { + getJlptEnabled: NonNullable; + getFrequencyDictionaryEnabled: NonNullable< + TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled'] + >; + getFrequencyRank: NonNullable; + getMinSentenceWordsForNPlusOne: NonNullable< + TokenizerDepsRuntimeOptions['getMinSentenceWordsForNPlusOne'] + >; + getYomitanGroupDebugEnabled: NonNullable< + TokenizerDepsRuntimeOptions['getYomitanGroupDebugEnabled'] + >; + recordLookup: (hit: boolean) => void; +}; + +export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { + return (): TokenizerDepsRuntimeOptions => ({ + getYomitanExt: () => deps.getYomitanExt(), + getYomitanParserWindow: () => deps.getYomitanParserWindow(), + setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), + getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), + setYomitanParserReadyPromise: (promise: Promise | null) => + deps.setYomitanParserReadyPromise(promise), + getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(), + setYomitanParserInitPromise: (promise: Promise | null) => + deps.setYomitanParserInitPromise(promise), + isKnownWord: (text: string) => { + const hit = deps.isKnownWord(text); + deps.recordLookup(hit); + return hit; + }, + getKnownWordMatchMode: () => deps.getKnownWordMatchMode(), + getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(), + getJlptLevel: (text: string) => deps.getJlptLevel(text), + getJlptEnabled: () => deps.getJlptEnabled(), + getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(), + getFrequencyRank: (text: string) => deps.getFrequencyRank(text), + getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(), + getMecabTokenizer: () => deps.getMecabTokenizer(), + }); +} + +export function createCreateMecabTokenizerAndCheckMainHandler(deps: { + getMecabTokenizer: () => TMecab | null; + setMecabTokenizer: (tokenizer: TMecab) => void; + createMecabTokenizer: () => TMecab; + checkAvailability: (tokenizer: TMecab) => Promise; +}) { + return async (): Promise => { + let tokenizer = deps.getMecabTokenizer(); + if (!tokenizer) { + tokenizer = deps.createMecabTokenizer(); + deps.setMecabTokenizer(tokenizer); + } + await deps.checkAvailability(tokenizer); + }; +} + +export function createPrewarmSubtitleDictionariesMainHandler(deps: { + ensureJlptDictionaryLookup: () => Promise; + ensureFrequencyDictionaryLookup: () => Promise; +}) { + return async (): Promise => { + await Promise.all([deps.ensureJlptDictionaryLookup(), deps.ensureFrequencyDictionaryLookup()]); + }; +} diff --git a/src/main/runtime/tray-lifecycle.test.ts b/src/main/runtime/tray-lifecycle.test.ts new file mode 100644 index 0000000..551e69a --- /dev/null +++ b/src/main/runtime/tray-lifecycle.test.ts @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle'; + +test('ensure tray updates menu when tray already exists', () => { + const calls: string[] = []; + const tray = { + setContextMenu: () => calls.push('set-menu'), + setToolTip: () => calls.push('set-tooltip'), + on: () => calls.push('bind-click'), + destroy: () => calls.push('destroy'), + }; + + const ensureTray = createEnsureTrayHandler({ + getTray: () => tray, + setTray: () => calls.push('set-tray'), + buildTrayMenu: () => ({}), + resolveTrayIconPath: () => null, + createImageFromPath: () => + ({ + isEmpty: () => false, + resize: () => { + throw new Error('should not resize'); + }, + setTemplateImage: () => {}, + }) as never, + createEmptyImage: () => + ({ + isEmpty: () => true, + resize: () => { + throw new Error('should not resize'); + }, + setTemplateImage: () => {}, + }) as never, + createTray: () => tray as never, + trayTooltip: 'SubMiner', + platform: 'darwin', + logWarn: () => calls.push('warn'), + ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'), + }); + + ensureTray(); + assert.deepEqual(calls, ['set-menu']); +}); + +test('ensure tray creates new tray and binds click handler', () => { + const calls: string[] = []; + let trayRef: unknown = null; + + const ensureTray = createEnsureTrayHandler({ + getTray: () => null, + setTray: (tray) => { + trayRef = tray; + calls.push('set-tray'); + }, + buildTrayMenu: () => ({ id: 'menu' }), + resolveTrayIconPath: () => '/tmp/icon.png', + createImageFromPath: () => + ({ + isEmpty: () => false, + resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => { + calls.push(`resize:${options.width}x${options.height}`); + return { + isEmpty: () => false, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => calls.push('template'), + }; + }, + setTemplateImage: () => calls.push('template'), + }) as never, + createEmptyImage: () => + ({ + isEmpty: () => true, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => {}, + }) as never, + createTray: () => + ({ + setContextMenu: () => calls.push('set-menu'), + setToolTip: () => calls.push('set-tooltip'), + on: (_event: 'click', _handler: () => void) => { + calls.push('bind-click'); + }, + destroy: () => calls.push('destroy'), + }) as never, + trayTooltip: 'SubMiner', + platform: 'darwin', + logWarn: () => calls.push('warn'), + ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'), + }); + + ensureTray(); + assert.ok(trayRef); + assert.ok(calls.includes('set-tray')); + assert.ok(calls.includes('set-menu')); + assert.ok(calls.includes('bind-click')); +}); + +test('destroy tray handler destroys active tray and clears ref', () => { + const calls: string[] = []; + let tray: { destroy: () => void } | null = { + destroy: () => calls.push('destroy'), + }; + const destroyTray = createDestroyTrayHandler({ + getTray: () => tray as never, + setTray: (next) => { + tray = next as never; + calls.push('set-null'); + }, + }); + + destroyTray(); + assert.deepEqual(calls, ['destroy', 'set-null']); + assert.equal(tray, null); +}); diff --git a/src/main/runtime/tray-lifecycle.ts b/src/main/runtime/tray-lifecycle.ts new file mode 100644 index 0000000..fc78295 --- /dev/null +++ b/src/main/runtime/tray-lifecycle.ts @@ -0,0 +1,67 @@ +type TrayIconLike = { + isEmpty: () => boolean; + resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => TrayIconLike; + setTemplateImage: (enabled: boolean) => void; +}; + +type TrayLike = { + setContextMenu: (menu: any) => void; + setToolTip: (tooltip: string) => void; + on: (event: 'click', handler: () => void) => void; + destroy: () => void; +}; + +export function createEnsureTrayHandler(deps: { + getTray: () => TrayLike | null; + setTray: (tray: TrayLike | null) => void; + buildTrayMenu: () => any; + resolveTrayIconPath: () => string | null; + createImageFromPath: (iconPath: string) => TrayIconLike; + createEmptyImage: () => TrayIconLike; + createTray: (icon: TrayIconLike) => TrayLike; + trayTooltip: string; + platform: string; + logWarn: (message: string) => void; + ensureOverlayVisibleFromTrayClick: () => void; +}) { + return (): void => { + const existingTray = deps.getTray(); + if (existingTray) { + existingTray.setContextMenu(deps.buildTrayMenu()); + return; + } + + const iconPath = deps.resolveTrayIconPath(); + let trayIcon = iconPath ? deps.createImageFromPath(iconPath) : deps.createEmptyImage(); + if (trayIcon.isEmpty()) { + deps.logWarn('Tray icon asset not found; using empty icon placeholder.'); + } + if (deps.platform === 'darwin' && !trayIcon.isEmpty()) { + trayIcon = trayIcon.resize({ width: 18, height: 18, quality: 'best' }); + trayIcon.setTemplateImage(true); + } + if (deps.platform === 'linux' && !trayIcon.isEmpty()) { + trayIcon = trayIcon.resize({ width: 20, height: 20 }); + } + + const tray = deps.createTray(trayIcon); + tray.setToolTip(deps.trayTooltip); + tray.setContextMenu(deps.buildTrayMenu()); + tray.on('click', () => { + deps.ensureOverlayVisibleFromTrayClick(); + }); + deps.setTray(tray); + }; +} + +export function createDestroyTrayHandler(deps: { + getTray: () => TrayLike | null; + setTray: (tray: TrayLike | null) => void; +}) { + return (): void => { + const tray = deps.getTray(); + if (!tray) return; + tray.destroy(); + deps.setTray(null); + }; +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts new file mode 100644 index 0000000..2140061 --- /dev/null +++ b/src/main/runtime/tray-main-actions.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createBuildTrayMenuTemplateHandler, + createResolveTrayIconPathHandler, +} from './tray-main-actions'; + +test('resolve tray icon path handler forwards runtime dependencies', () => { + const calls: string[] = []; + const resolveTrayIconPath = createResolveTrayIconPathHandler({ + resolveTrayIconPathRuntime: (options) => { + calls.push(`platform:${options.platform}`); + calls.push(`resources:${options.resourcesPath}`); + calls.push(`app:${options.appPath}`); + calls.push(`dir:${options.dirname}`); + calls.push(`join:${options.joinPath('a', 'b')}`); + calls.push(`exists:${options.fileExists('/tmp/icon.png')}`); + return '/tmp/icon.png'; + }, + platform: 'darwin', + resourcesPath: '/resources', + appPath: '/app', + dirname: '/dir', + joinPath: (...parts) => parts.join('/'), + fileExists: () => true, + }); + + assert.equal(resolveTrayIconPath(), '/tmp/icon.png'); + assert.deepEqual(calls, [ + 'platform:darwin', + 'resources:/resources', + 'app:/app', + 'dir:/dir', + 'join:a/b', + 'exists:true', + ]); +}); + +test('build tray template handler wires actions and init guards', () => { + const calls: string[] = []; + let initialized = false; + const buildTemplate = createBuildTrayMenuTemplateHandler({ + buildTrayMenuTemplateRuntime: (handlers) => { + handlers.openOverlay(); + handlers.openYomitanSettings(); + handlers.openRuntimeOptions(); + handlers.openJellyfinSetup(); + handlers.openAnilistSetup(); + handlers.quitApp(); + return [{ label: 'ok' }] as never; + }, + initializeOverlayRuntime: () => { + initialized = true; + calls.push('init'); + }, + isOverlayRuntimeInitialized: () => initialized, + setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + openYomitanSettings: () => calls.push('yomitan'), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openJellyfinSetupWindow: () => calls.push('jellyfin'), + openAnilistSetupWindow: () => calls.push('anilist'), + quitApp: () => calls.push('quit'), + }); + + const template = buildTemplate(); + assert.deepEqual(template, [{ label: 'ok' }]); + assert.deepEqual(calls, [ + 'init', + 'visible:true', + 'yomitan', + 'runtime-options', + 'jellyfin', + 'anilist', + 'quit', + ]); +}); diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts new file mode 100644 index 0000000..e624f21 --- /dev/null +++ b/src/main/runtime/tray-main-actions.ts @@ -0,0 +1,75 @@ +export function createResolveTrayIconPathHandler(deps: { + resolveTrayIconPathRuntime: (options: { + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; + }) => string | null; + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; +}) { + return (): string | null => { + return deps.resolveTrayIconPathRuntime({ + platform: deps.platform, + resourcesPath: deps.resourcesPath, + appPath: deps.appPath, + dirname: deps.dirname, + joinPath: deps.joinPath, + fileExists: deps.fileExists, + }); + }; +} + +export function createBuildTrayMenuTemplateHandler(deps: { + buildTrayMenuTemplateRuntime: (handlers: { + openOverlay: () => void; + openYomitanSettings: () => void; + openRuntimeOptions: () => void; + openJellyfinSetup: () => void; + openAnilistSetup: () => void; + quitApp: () => void; + }) => TMenuItem[]; + initializeOverlayRuntime: () => void; + isOverlayRuntimeInitialized: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + openYomitanSettings: () => void; + openRuntimeOptionsPalette: () => void; + openJellyfinSetupWindow: () => void; + openAnilistSetupWindow: () => void; + quitApp: () => void; +}) { + return (): TMenuItem[] => { + return deps.buildTrayMenuTemplateRuntime({ + openOverlay: () => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.setVisibleOverlayVisible(true); + }, + openYomitanSettings: () => { + deps.openYomitanSettings(); + }, + openRuntimeOptions: () => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.openRuntimeOptionsPalette(); + }, + openJellyfinSetup: () => { + deps.openJellyfinSetupWindow(); + }, + openAnilistSetup: () => { + deps.openAnilistSetupWindow(); + }, + quitApp: () => { + deps.quitApp(); + }, + }); + }; +} diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts new file mode 100644 index 0000000..ec697a5 --- /dev/null +++ b/src/main/runtime/tray-main-deps.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildResolveTrayIconPathMainDepsHandler, + createBuildTrayMenuTemplateMainDepsHandler, +} from './tray-main-deps'; + +test('tray main deps builders return mapped handlers', () => { + const calls: string[] = []; + const resolveDeps = createBuildResolveTrayIconPathMainDepsHandler({ + resolveTrayIconPathRuntime: () => '/tmp/icon.png', + platform: 'darwin', + resourcesPath: '/resources', + appPath: '/app', + dirname: '/dir', + joinPath: (...parts) => parts.join('/'), + fileExists: () => true, + })(); + + assert.equal(resolveDeps.platform, 'darwin'); + assert.equal(resolveDeps.joinPath('a', 'b'), 'a/b'); + + const menuDeps = createBuildTrayMenuTemplateMainDepsHandler({ + buildTrayMenuTemplateRuntime: () => [{ label: 'tray' }] as never, + initializeOverlayRuntime: () => calls.push('init'), + isOverlayRuntimeInitialized: () => false, + setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + openYomitanSettings: () => calls.push('yomitan'), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openJellyfinSetupWindow: () => calls.push('jellyfin'), + openAnilistSetupWindow: () => calls.push('anilist'), + quitApp: () => calls.push('quit'), + })(); + + const template = menuDeps.buildTrayMenuTemplateRuntime({ + openOverlay: () => calls.push('open-overlay'), + openYomitanSettings: () => calls.push('open-yomitan'), + openRuntimeOptions: () => calls.push('open-runtime-options'), + openJellyfinSetup: () => calls.push('open-jellyfin'), + openAnilistSetup: () => calls.push('open-anilist'), + quitApp: () => calls.push('quit-app'), + }); + + assert.deepEqual(template, [{ label: 'tray' }]); +}); diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts new file mode 100644 index 0000000..4172768 --- /dev/null +++ b/src/main/runtime/tray-main-deps.ts @@ -0,0 +1,57 @@ +export function createBuildResolveTrayIconPathMainDepsHandler(deps: { + resolveTrayIconPathRuntime: (options: { + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; + }) => string | null; + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; +}) { + return () => ({ + resolveTrayIconPathRuntime: deps.resolveTrayIconPathRuntime, + platform: deps.platform, + resourcesPath: deps.resourcesPath, + appPath: deps.appPath, + dirname: deps.dirname, + joinPath: deps.joinPath, + fileExists: deps.fileExists, + }); +} + +export function createBuildTrayMenuTemplateMainDepsHandler(deps: { + buildTrayMenuTemplateRuntime: (handlers: { + openOverlay: () => void; + openYomitanSettings: () => void; + openRuntimeOptions: () => void; + openJellyfinSetup: () => void; + openAnilistSetup: () => void; + quitApp: () => void; + }) => TMenuItem[]; + initializeOverlayRuntime: () => void; + isOverlayRuntimeInitialized: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + openYomitanSettings: () => void; + openRuntimeOptionsPalette: () => void; + openJellyfinSetupWindow: () => void; + openAnilistSetupWindow: () => void; + quitApp: () => void; +}) { + return () => ({ + buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime, + initializeOverlayRuntime: deps.initializeOverlayRuntime, + isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, + setVisibleOverlayVisible: deps.setVisibleOverlayVisible, + openYomitanSettings: deps.openYomitanSettings, + openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + openJellyfinSetupWindow: deps.openJellyfinSetupWindow, + openAnilistSetupWindow: deps.openAnilistSetupWindow, + quitApp: deps.quitApp, + }); +} diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts new file mode 100644 index 0000000..75374b5 --- /dev/null +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createTrayRuntimeHandlers } from './tray-runtime-handlers'; + +test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => { + let tray: { destroyed: boolean } | null = null; + let overlayInitialized = false; + let visibleOverlay = false; + const calls: string[] = []; + + const runtime = createTrayRuntimeHandlers({ + resolveTrayIconPathDeps: { + resolveTrayIconPathRuntime: () => '/tmp/SubMiner.png', + platform: 'darwin', + resourcesPath: '/resources', + appPath: '/app', + dirname: '/dirname', + joinPath: (...parts) => parts.join('/'), + fileExists: () => true, + }, + buildTrayMenuTemplateDeps: { + buildTrayMenuTemplateRuntime: () => [{ label: 'Open Overlay' }], + initializeOverlayRuntime: () => { + overlayInitialized = true; + }, + isOverlayRuntimeInitialized: () => overlayInitialized, + setVisibleOverlayVisible: (visible) => { + visibleOverlay = visible; + }, + openYomitanSettings: () => {}, + openRuntimeOptionsPalette: () => {}, + openJellyfinSetupWindow: () => {}, + openAnilistSetupWindow: () => {}, + quitApp: () => {}, + }, + ensureTrayDeps: { + getTray: () => tray as never, + setTray: (next) => { + tray = next as { destroyed: boolean } | null; + }, + createImageFromPath: () => ({ + isEmpty: () => false, + resize: () => ({ + isEmpty: () => false, + resize: () => ({} as never), + setTemplateImage: () => {}, + }), + setTemplateImage: () => {}, + }), + createEmptyImage: () => ({ + isEmpty: () => true, + resize: () => ({} as never), + setTemplateImage: () => {}, + }), + createTray: () => ({ + setContextMenu: () => calls.push('setContextMenu'), + setToolTip: () => calls.push('setToolTip'), + on: (_event: 'click', handler: () => void) => { + calls.push('on-click'); + handler(); + }, + destroy: () => { + calls.push('destroy'); + if (tray) tray.destroyed = true; + }, + }), + trayTooltip: 'SubMiner', + platform: 'darwin', + logWarn: (message) => calls.push(`warn:${message}`), + initializeOverlayRuntime: () => { + overlayInitialized = true; + }, + isOverlayRuntimeInitialized: () => overlayInitialized, + setVisibleOverlayVisible: (visible) => { + visibleOverlay = visible; + }, + }, + destroyTrayDeps: { + getTray: () => tray as never, + setTray: (next) => { + tray = next as { destroyed: boolean } | null; + }, + }, + buildMenuFromTemplate: (template) => ({ template }), + }); + + assert.equal(runtime.resolveTrayIconPath(), '/tmp/SubMiner.png'); + assert.deepEqual(runtime.buildTrayMenu(), { template: [{ label: 'Open Overlay' }] }); + runtime.ensureTray(); + assert.equal(overlayInitialized, true); + assert.equal(visibleOverlay, true); + assert.ok(tray); + runtime.destroyTray(); + assert.equal(tray, null); + assert.deepEqual(calls, ['setToolTip', 'setContextMenu', 'on-click', 'destroy']); +}); diff --git a/src/main/runtime/tray-runtime-handlers.ts b/src/main/runtime/tray-runtime-handlers.ts new file mode 100644 index 0000000..92f60c1 --- /dev/null +++ b/src/main/runtime/tray-runtime-handlers.ts @@ -0,0 +1,60 @@ +import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle'; +import { + createBuildDestroyTrayMainDepsHandler, + createBuildEnsureTrayMainDepsHandler, +} from './app-runtime-main-deps'; +import { + createBuildTrayMenuTemplateHandler, + createResolveTrayIconPathHandler, +} from './tray-main-actions'; +import { + createBuildResolveTrayIconPathMainDepsHandler, + createBuildTrayMenuTemplateMainDepsHandler, +} from './tray-main-deps'; + +type ResolveTrayIconPathMainDeps = Parameters< + typeof createBuildResolveTrayIconPathMainDepsHandler +>[0]; +type BuildTrayMenuTemplateMainDeps = Parameters< + typeof createBuildTrayMenuTemplateMainDepsHandler +>[0]; +type EnsureTrayMainDeps = Parameters< + typeof createBuildEnsureTrayMainDepsHandler +>[0]; +type TrayLike = NonNullable[0]['getTray']>>; +type TrayIconLike = Parameters[0]['createTray']>[0]; +type DestroyTrayMainDeps = Parameters>[0]; + +export function createTrayRuntimeHandlers(deps: { + resolveTrayIconPathDeps: ResolveTrayIconPathMainDeps; + buildTrayMenuTemplateDeps: BuildTrayMenuTemplateMainDeps; + ensureTrayDeps: Omit, 'buildTrayMenu' | 'resolveTrayIconPath'>; + destroyTrayDeps: DestroyTrayMainDeps; + buildMenuFromTemplate: (template: TMenuItem[]) => TMenu; +}) { + const resolveTrayIconPath = createResolveTrayIconPathHandler( + createBuildResolveTrayIconPathMainDepsHandler(deps.resolveTrayIconPathDeps)(), + ); + const buildTrayMenuTemplate = createBuildTrayMenuTemplateHandler( + createBuildTrayMenuTemplateMainDepsHandler(deps.buildTrayMenuTemplateDeps)(), + ); + const buildTrayMenu = () => deps.buildMenuFromTemplate(buildTrayMenuTemplate()); + + const ensureTray = createEnsureTrayHandler( + createBuildEnsureTrayMainDepsHandler({ + ...deps.ensureTrayDeps, + buildTrayMenu: () => buildTrayMenu(), + resolveTrayIconPath: () => resolveTrayIconPath(), + })(), + ); + const destroyTray = createDestroyTrayHandler( + createBuildDestroyTrayMainDepsHandler(deps.destroyTrayDeps)(), + ); + + return { + resolveTrayIconPath, + buildTrayMenu, + ensureTray, + destroyTray, + }; +} diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts new file mode 100644 index 0000000..6d9821c --- /dev/null +++ b/src/main/runtime/tray-runtime.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildTrayMenuTemplateRuntime, resolveTrayIconPathRuntime } from './tray-runtime'; + +test('resolve tray icon picks template icon first on darwin', () => { + const path = resolveTrayIconPathRuntime({ + platform: 'darwin', + resourcesPath: '/res', + appPath: '/app', + dirname: '/dist/main', + joinPath: (...parts) => parts.join('/'), + fileExists: (candidate) => candidate.endsWith('/res/assets/SubMinerTemplate.png'), + }); + assert.equal(path, '/res/assets/SubMinerTemplate.png'); +}); + +test('resolve tray icon returns null when no asset exists', () => { + const path = resolveTrayIconPathRuntime({ + platform: 'linux', + resourcesPath: '/res', + appPath: '/app', + dirname: '/dist/main', + joinPath: (...parts) => parts.join('/'), + fileExists: () => false, + }); + assert.equal(path, null); +}); + +test('tray menu template contains expected entries and handlers', () => { + const calls: string[] = []; + const template = buildTrayMenuTemplateRuntime({ + openOverlay: () => calls.push('overlay'), + openYomitanSettings: () => calls.push('yomitan'), + openRuntimeOptions: () => calls.push('runtime'), + openJellyfinSetup: () => calls.push('jellyfin'), + openAnilistSetup: () => calls.push('anilist'), + quitApp: () => calls.push('quit'), + }); + + assert.equal(template.length, 7); + template[0]!.click?.(); + template[5]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); + template[6]!.click?.(); + assert.deepEqual(calls, ['overlay', 'separator', 'quit']); +}); diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts new file mode 100644 index 0000000..50362f6 --- /dev/null +++ b/src/main/runtime/tray-runtime.ts @@ -0,0 +1,73 @@ +export function resolveTrayIconPathRuntime(deps: { + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; +}): string | null { + const iconNames = + deps.platform === 'darwin' + ? ['SubMinerTemplate.png', 'SubMinerTemplate@2x.png', 'SubMiner.png'] + : ['SubMiner.png']; + + const baseDirs = [ + deps.joinPath(deps.resourcesPath, 'assets'), + deps.joinPath(deps.appPath, 'assets'), + deps.joinPath(deps.dirname, '..', 'assets'), + deps.joinPath(deps.dirname, '..', '..', 'assets'), + ]; + + for (const baseDir of baseDirs) { + for (const iconName of iconNames) { + const candidate = deps.joinPath(baseDir, iconName); + if (deps.fileExists(candidate)) { + return candidate; + } + } + } + return null; +} + +export type TrayMenuActionHandlers = { + openOverlay: () => void; + openYomitanSettings: () => void; + openRuntimeOptions: () => void; + openJellyfinSetup: () => void; + openAnilistSetup: () => void; + quitApp: () => void; +}; + +export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{ + label?: string; + type?: 'separator'; + click?: () => void; +}> { + return [ + { + label: 'Open Overlay', + click: handlers.openOverlay, + }, + { + label: 'Open Yomitan Settings', + click: handlers.openYomitanSettings, + }, + { + label: 'Open Runtime Options', + click: handlers.openRuntimeOptions, + }, + { + label: 'Configure Jellyfin', + click: handlers.openJellyfinSetup, + }, + { + label: 'Configure AniList', + click: handlers.openAnilistSetup, + }, + { type: 'separator' }, + { + label: 'Quit', + click: handlers.quitApp, + }, + ]; +} diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.test.ts b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts new file mode 100644 index 0000000..da97222 --- /dev/null +++ b/src/main/runtime/yomitan-extension-loader-main-deps.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildEnsureYomitanExtensionLoadedMainDepsHandler, + createBuildLoadYomitanExtensionMainDepsHandler, +} from './yomitan-extension-loader-main-deps'; + +test('load yomitan extension main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildLoadYomitanExtensionMainDepsHandler({ + loadYomitanExtensionCore: async () => { + calls.push('load-core'); + return null; + }, + userDataPath: '/tmp/subminer', + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => calls.push('set-window'), + setYomitanParserReadyPromise: () => calls.push('set-ready'), + setYomitanParserInitPromise: () => calls.push('set-init'), + setYomitanExtension: () => calls.push('set-ext'), + })(); + + assert.equal(deps.userDataPath, '/tmp/subminer'); + await deps.loadYomitanExtensionCore({} as never); + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + deps.setYomitanParserInitPromise(null); + deps.setYomitanExtension(null); + assert.deepEqual(calls, ['load-core', 'set-window', 'set-ready', 'set-init', 'set-ext']); +}); + +test('ensure yomitan extension loaded main deps builder maps callbacks', async () => { + const calls: string[] = []; + const deps = createBuildEnsureYomitanExtensionLoadedMainDepsHandler({ + getYomitanExtension: () => null, + getLoadInFlight: () => null, + setLoadInFlight: () => calls.push('set-inflight'), + loadYomitanExtension: async () => { + calls.push('load'); + return null; + }, + })(); + + assert.equal(deps.getYomitanExtension(), null); + assert.equal(deps.getLoadInFlight(), null); + deps.setLoadInFlight(null); + await deps.loadYomitanExtension(); + assert.deepEqual(calls, ['set-inflight', 'load']); +}); diff --git a/src/main/runtime/yomitan-extension-loader-main-deps.ts b/src/main/runtime/yomitan-extension-loader-main-deps.ts new file mode 100644 index 0000000..cb28a29 --- /dev/null +++ b/src/main/runtime/yomitan-extension-loader-main-deps.ts @@ -0,0 +1,34 @@ +import type { + createEnsureYomitanExtensionLoadedHandler, + createLoadYomitanExtensionHandler, +} from './yomitan-extension-loader'; + +type LoadYomitanExtensionMainDeps = Parameters[0]; +type EnsureYomitanExtensionLoadedMainDeps = Parameters< + typeof createEnsureYomitanExtensionLoadedHandler +>[0]; + +export function createBuildLoadYomitanExtensionMainDepsHandler( + deps: LoadYomitanExtensionMainDeps, +) { + return (): LoadYomitanExtensionMainDeps => ({ + loadYomitanExtensionCore: (options) => deps.loadYomitanExtensionCore(options), + userDataPath: deps.userDataPath, + getYomitanParserWindow: () => deps.getYomitanParserWindow(), + setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), + setYomitanParserReadyPromise: (promise) => deps.setYomitanParserReadyPromise(promise), + setYomitanParserInitPromise: (promise) => deps.setYomitanParserInitPromise(promise), + setYomitanExtension: (extension) => deps.setYomitanExtension(extension), + }); +} + +export function createBuildEnsureYomitanExtensionLoadedMainDepsHandler( + deps: EnsureYomitanExtensionLoadedMainDeps, +) { + return (): EnsureYomitanExtensionLoadedMainDeps => ({ + getYomitanExtension: () => deps.getYomitanExtension(), + getLoadInFlight: () => deps.getLoadInFlight(), + setLoadInFlight: (promise) => deps.setLoadInFlight(promise), + loadYomitanExtension: () => deps.loadYomitanExtension(), + }); +} diff --git a/src/main/runtime/yomitan-extension-loader.test.ts b/src/main/runtime/yomitan-extension-loader.test.ts new file mode 100644 index 0000000..5de56fa --- /dev/null +++ b/src/main/runtime/yomitan-extension-loader.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createEnsureYomitanExtensionLoadedHandler, + createLoadYomitanExtensionHandler, +} from './yomitan-extension-loader'; + +test('load yomitan extension handler forwards parser state dependencies', async () => { + const calls: string[] = []; + const parserWindow = {} as never; + const extension = { id: 'ext' } as never; + const loadYomitanExtension = createLoadYomitanExtensionHandler({ + loadYomitanExtensionCore: async (options) => { + calls.push(`path:${options.userDataPath}`); + assert.equal(options.getYomitanParserWindow(), parserWindow); + options.setYomitanParserWindow(null); + options.setYomitanParserReadyPromise(null); + options.setYomitanParserInitPromise(null); + options.setYomitanExtension(extension); + return extension; + }, + userDataPath: '/tmp/subminer', + getYomitanParserWindow: () => parserWindow, + setYomitanParserWindow: () => calls.push('set-window'), + setYomitanParserReadyPromise: () => calls.push('set-ready'), + setYomitanParserInitPromise: () => calls.push('set-init'), + setYomitanExtension: () => calls.push('set-ext'), + }); + + assert.equal(await loadYomitanExtension(), extension); + assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']); +}); + +test('ensure yomitan loader returns existing extension when available', async () => { + const extension = { id: 'ext' } as never; + const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({ + getYomitanExtension: () => extension, + getLoadInFlight: () => null, + setLoadInFlight: () => { + throw new Error('unexpected'); + }, + loadYomitanExtension: async () => { + throw new Error('unexpected'); + }, + }); + + assert.equal(await ensureLoaded(), extension); +}); + +test('ensure yomitan loader reuses in-flight promise', async () => { + const extension = { id: 'ext' } as never; + const inflight = Promise.resolve(extension); + const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({ + getYomitanExtension: () => null, + getLoadInFlight: () => inflight, + setLoadInFlight: () => { + throw new Error('unexpected'); + }, + loadYomitanExtension: async () => { + throw new Error('unexpected'); + }, + }); + + assert.equal(await ensureLoaded(), extension); +}); + +test('ensure yomitan loader starts load and clears in-flight when done', async () => { + const calls: string[] = []; + let inFlight: Promise | null = null; + const extension = { id: 'ext' } as never; + const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({ + getYomitanExtension: () => null, + getLoadInFlight: () => inFlight, + setLoadInFlight: (promise) => { + inFlight = promise; + calls.push(promise ? 'set:promise' : 'set:null'); + }, + loadYomitanExtension: async () => { + calls.push('load'); + return extension; + }, + }); + + assert.equal(await ensureLoaded(), extension); + assert.deepEqual(calls, ['load', 'set:promise', 'set:null']); +}); diff --git a/src/main/runtime/yomitan-extension-loader.ts b/src/main/runtime/yomitan-extension-loader.ts new file mode 100644 index 0000000..e668e8c --- /dev/null +++ b/src/main/runtime/yomitan-extension-loader.ts @@ -0,0 +1,48 @@ +import type { Extension } from 'electron'; +import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-extension-loader'; + +export function createLoadYomitanExtensionHandler(deps: { + loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise; + userDataPath: YomitanExtensionLoaderDeps['userDataPath']; + getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow']; + setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow']; + setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise']; + setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise']; + setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension']; +}) { + return async (): Promise => { + return deps.loadYomitanExtensionCore({ + userDataPath: deps.userDataPath, + getYomitanParserWindow: deps.getYomitanParserWindow, + setYomitanParserWindow: deps.setYomitanParserWindow, + setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, + setYomitanParserInitPromise: deps.setYomitanParserInitPromise, + setYomitanExtension: deps.setYomitanExtension, + }); + }; +} + +export function createEnsureYomitanExtensionLoadedHandler(deps: { + getYomitanExtension: () => Extension | null; + getLoadInFlight: () => Promise | null; + setLoadInFlight: (promise: Promise | null) => void; + loadYomitanExtension: () => Promise; +}) { + return async (): Promise => { + const existing = deps.getYomitanExtension(); + if (existing) { + return existing; + } + + const inFlight = deps.getLoadInFlight(); + if (inFlight) { + return inFlight; + } + + const promise = deps.loadYomitanExtension().finally(() => { + deps.setLoadInFlight(null); + }); + deps.setLoadInFlight(promise); + return promise; + }; +} diff --git a/src/main/runtime/yomitan-extension-runtime.test.ts b/src/main/runtime/yomitan-extension-runtime.test.ts new file mode 100644 index 0000000..bc0d26e --- /dev/null +++ b/src/main/runtime/yomitan-extension-runtime.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Extension } from 'electron'; +import { createYomitanExtensionRuntime } from './yomitan-extension-runtime'; + +test('yomitan extension runtime reuses in-flight ensure load and clears it after resolve', async () => { + let extension: Extension | null = null; + let inFlight: Promise | null = null; + let parserWindow: unknown = null; + let readyPromise: Promise | null = null; + let initPromise: Promise | null = null; + let loadCalls = 0; + const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = { + releaseLoad: null, + }; + + const runtime = createYomitanExtensionRuntime({ + loadYomitanExtensionCore: async (options) => { + loadCalls += 1; + options.setYomitanParserWindow(null); + options.setYomitanParserReadyPromise(Promise.resolve()); + options.setYomitanParserInitPromise(Promise.resolve(true)); + return await new Promise((resolve) => { + releaseLoadState.releaseLoad = (value) => { + options.setYomitanExtension(value); + resolve(value); + }; + }); + }, + userDataPath: '/tmp', + getYomitanParserWindow: () => parserWindow as never, + setYomitanParserWindow: (window) => { + parserWindow = window; + }, + setYomitanParserReadyPromise: (promise) => { + readyPromise = promise as Promise | null; + }, + setYomitanParserInitPromise: (promise) => { + initPromise = promise as Promise | null; + }, + setYomitanExtension: (next) => { + extension = next; + }, + getYomitanExtension: () => extension, + getLoadInFlight: () => inFlight, + setLoadInFlight: (promise) => { + inFlight = promise; + }, + }); + + const first = runtime.ensureYomitanExtensionLoaded(); + const second = runtime.ensureYomitanExtensionLoaded(); + assert.equal(loadCalls, 1); + assert.ok(inFlight); + assert.equal(parserWindow, null); + assert.ok(readyPromise); + assert.ok(initPromise); + + const fakeExtension = { id: 'yomitan' } as Extension; + const releaseLoad = releaseLoadState.releaseLoad; + if (!releaseLoad) { + throw new Error('expected in-flight yomitan load resolver'); + } + releaseLoad(fakeExtension); + assert.equal(await first, fakeExtension); + assert.equal(await second, fakeExtension); + assert.equal(extension, fakeExtension); + assert.equal(inFlight, null); + + const third = await runtime.ensureYomitanExtensionLoaded(); + assert.equal(third, fakeExtension); + assert.equal(loadCalls, 1); +}); + +test('yomitan extension runtime direct load delegates to core', async () => { + let loadCalls = 0; + + const runtime = createYomitanExtensionRuntime({ + loadYomitanExtensionCore: async () => { + loadCalls += 1; + return null; + }, + userDataPath: '/tmp', + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + setYomitanParserReadyPromise: () => {}, + setYomitanParserInitPromise: () => {}, + setYomitanExtension: () => {}, + getYomitanExtension: () => null, + getLoadInFlight: () => null, + setLoadInFlight: () => {}, + }); + + assert.equal(await runtime.loadYomitanExtension(), null); + assert.equal(loadCalls, 1); +}); diff --git a/src/main/runtime/yomitan-extension-runtime.ts b/src/main/runtime/yomitan-extension-runtime.ts new file mode 100644 index 0000000..51830d0 --- /dev/null +++ b/src/main/runtime/yomitan-extension-runtime.ts @@ -0,0 +1,50 @@ +import { createEnsureYomitanExtensionLoadedHandler, createLoadYomitanExtensionHandler } from './yomitan-extension-loader'; +import { + createBuildEnsureYomitanExtensionLoadedMainDepsHandler, + createBuildLoadYomitanExtensionMainDepsHandler, +} from './yomitan-extension-loader-main-deps'; + +type LoadYomitanExtensionMainDeps = Parameters< + typeof createBuildLoadYomitanExtensionMainDepsHandler +>[0]; + +type EnsureYomitanExtensionLoadedMainDeps = Omit< + Parameters[0], + 'loadYomitanExtension' +>; + +export type YomitanExtensionRuntimeDeps = LoadYomitanExtensionMainDeps & + EnsureYomitanExtensionLoadedMainDeps; + +export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) { + const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({ + loadYomitanExtensionCore: deps.loadYomitanExtensionCore, + userDataPath: deps.userDataPath, + getYomitanParserWindow: deps.getYomitanParserWindow, + setYomitanParserWindow: deps.setYomitanParserWindow, + setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, + setYomitanParserInitPromise: deps.setYomitanParserInitPromise, + setYomitanExtension: deps.setYomitanExtension, + }); + const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler( + buildLoadYomitanExtensionMainDepsHandler(), + ); + + const buildEnsureYomitanExtensionLoadedMainDepsHandler = + createBuildEnsureYomitanExtensionLoadedMainDepsHandler({ + getYomitanExtension: deps.getYomitanExtension, + getLoadInFlight: deps.getLoadInFlight, + setLoadInFlight: deps.setLoadInFlight, + loadYomitanExtension: () => loadYomitanExtensionHandler(), + }); + const ensureYomitanExtensionLoadedHandler = createEnsureYomitanExtensionLoadedHandler( + buildEnsureYomitanExtensionLoadedMainDepsHandler(), + ); + + return { + loadYomitanExtension: (): Promise> => + loadYomitanExtensionHandler(), + ensureYomitanExtensionLoaded: (): Promise> => + ensureYomitanExtensionLoadedHandler(), + }; +} diff --git a/src/main/runtime/yomitan-settings-opener.test.ts b/src/main/runtime/yomitan-settings-opener.test.ts new file mode 100644 index 0000000..2bc9728 --- /dev/null +++ b/src/main/runtime/yomitan-settings-opener.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener'; + +test('yomitan opener warns when extension cannot be loaded', async () => { + const logs: string[] = []; + const openSettings = createOpenYomitanSettingsHandler({ + ensureYomitanExtensionLoaded: async () => null, + openYomitanSettingsWindow: () => { + throw new Error('should not open'); + }, + getExistingWindow: () => null, + setWindow: () => {}, + logWarn: (message) => logs.push(message), + logError: () => logs.push('error'), + }); + + openSettings(); + await Promise.resolve(); + await Promise.resolve(); + assert.ok(logs.includes('Unable to open Yomitan settings: extension failed to load.')); +}); + +test('yomitan opener opens settings window when extension is available', async () => { + let opened = false; + const openSettings = createOpenYomitanSettingsHandler({ + ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), + openYomitanSettingsWindow: () => { + opened = true; + }, + getExistingWindow: () => null, + setWindow: () => {}, + logWarn: () => {}, + logError: () => {}, + }); + + openSettings(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(opened, true); +}); diff --git a/src/main/runtime/yomitan-settings-opener.ts b/src/main/runtime/yomitan-settings-opener.ts new file mode 100644 index 0000000..7d8b88a --- /dev/null +++ b/src/main/runtime/yomitan-settings-opener.ts @@ -0,0 +1,32 @@ +type YomitanExtensionLike = unknown; +type BrowserWindowLike = unknown; + +export function createOpenYomitanSettingsHandler(deps: { + ensureYomitanExtensionLoaded: () => Promise; + openYomitanSettingsWindow: (params: { + yomitanExt: YomitanExtensionLike; + getExistingWindow: () => BrowserWindowLike | null; + setWindow: (window: BrowserWindowLike | null) => void; + }) => void; + getExistingWindow: () => BrowserWindowLike | null; + setWindow: (window: BrowserWindowLike | null) => void; + logWarn: (message: string) => void; + logError: (message: string, error: unknown) => void; +}) { + return (): void => { + void (async () => { + const extension = await deps.ensureYomitanExtensionLoaded(); + if (!extension) { + deps.logWarn('Unable to open Yomitan settings: extension failed to load.'); + return; + } + deps.openYomitanSettingsWindow({ + yomitanExt: extension, + getExistingWindow: deps.getExistingWindow, + setWindow: deps.setWindow, + }); + })().catch((error) => { + deps.logError('Failed to open Yomitan settings window.', error); + }); + }; +} diff --git a/src/main/runtime/yomitan-settings-runtime.test.ts b/src/main/runtime/yomitan-settings-runtime.test.ts new file mode 100644 index 0000000..f5b843b --- /dev/null +++ b/src/main/runtime/yomitan-settings-runtime.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createYomitanSettingsRuntime } from './yomitan-settings-runtime'; + +test('yomitan settings runtime composes opener with built deps', async () => { + let existingWindow: { id: string } | null = null; + const calls: string[] = []; + + const runtime = createYomitanSettingsRuntime({ + ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), + openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => { + calls.push('open-window'); + const current = getExistingWindow(); + if (!current) { + setWindow({ id: 'settings' }); + } + }, + getExistingWindow: () => existingWindow as never, + setWindow: (window) => { + existingWindow = window as { id: string } | null; + }, + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + }); + + runtime.openYomitanSettings(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(existingWindow, { id: 'settings' }); + assert.deepEqual(calls, ['open-window']); +}); diff --git a/src/main/runtime/yomitan-settings-runtime.ts b/src/main/runtime/yomitan-settings-runtime.ts new file mode 100644 index 0000000..4671678 --- /dev/null +++ b/src/main/runtime/yomitan-settings-runtime.ts @@ -0,0 +1,13 @@ +import { createBuildOpenYomitanSettingsMainDepsHandler } from './app-runtime-main-deps'; +import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener'; + +type OpenYomitanSettingsMainDeps = Parameters[0]; + +export function createYomitanSettingsRuntime(deps: OpenYomitanSettingsMainDeps) { + const openYomitanSettingsMainDeps = createBuildOpenYomitanSettingsMainDepsHandler(deps)(); + const openYomitanSettings = createOpenYomitanSettingsHandler(openYomitanSettingsMainDeps); + + return { + openYomitanSettings, + }; +} diff --git a/src/main/startup-lifecycle.ts b/src/main/startup-lifecycle.ts new file mode 100644 index 0000000..4444726 --- /dev/null +++ b/src/main/startup-lifecycle.ts @@ -0,0 +1,46 @@ +import { CliArgs, CliCommandSource } from '../cli/args'; +import { createAppLifecycleDepsRuntime } from '../core/services'; +import { startAppLifecycle } from '../core/services/app-lifecycle'; +import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle'; +import { createAppLifecycleRuntimeDeps } from './app-lifecycle'; + +export interface AppLifecycleRuntimeRunnerParams { + app: AppLifecycleDepsRuntimeOptions['app']; + platform: NodeJS.Platform; + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; + shouldQuitOnWindowAllClosed: () => boolean; +} + +export function createAppLifecycleRuntimeRunner( + params: AppLifecycleRuntimeRunnerParams, +): (args: CliArgs) => void { + return (args: CliArgs): void => { + startAppLifecycle( + args, + createAppLifecycleDepsRuntime( + createAppLifecycleRuntimeDeps({ + app: params.app, + platform: params.platform, + shouldStartApp: params.shouldStartApp, + parseArgs: params.parseArgs, + handleCliCommand: params.handleCliCommand, + printHelp: params.printHelp, + logNoRunningInstance: params.logNoRunningInstance, + onReady: params.onReady, + onWillQuitCleanup: params.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: params.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed, + }), + ), + ); + }; +} diff --git a/src/main/startup.ts b/src/main/startup.ts new file mode 100644 index 0000000..48d908b --- /dev/null +++ b/src/main/startup.ts @@ -0,0 +1,61 @@ +import { CliArgs } from '../cli/args'; +import type { ResolvedConfig } from '../types'; +import type { StartupBootstrapRuntimeDeps } from '../core/services/startup'; +import type { LogLevelSource } from '../logger'; + +export interface StartupBootstrapRuntimeFactoryDeps { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevel: (level: string, source: LogLevelSource) => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + shouldStartApp: (args: CliArgs) => boolean; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + configDir: string; + defaultConfig: ResolvedConfig; + generateConfigTemplate: (config: ResolvedConfig) => string; + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => Promise; + onConfigGenerated: (exitCode: number) => void; + onGenerateConfigError: (error: Error) => void; + startAppLifecycle: (args: CliArgs) => void; +} + +export function createStartupBootstrapRuntimeDeps( + params: StartupBootstrapRuntimeFactoryDeps, +): StartupBootstrapRuntimeDeps { + return { + argv: params.argv, + parseArgs: params.parseArgs, + setLogLevel: params.setLogLevel, + forceX11Backend: (args: CliArgs) => params.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args: CliArgs) => params.enforceUnsupportedWaylandMode(args), + getDefaultSocketPath: params.getDefaultSocketPath, + defaultTexthookerPort: params.defaultTexthookerPort, + runGenerateConfigFlow: (args: CliArgs) => { + if (!args.generateConfig || params.shouldStartApp(args)) { + return false; + } + params + .generateDefaultConfigFile(args, { + configDir: params.configDir, + defaultConfig: params.defaultConfig, + generateTemplate: (config: unknown) => + params.generateConfigTemplate(config as ResolvedConfig), + }) + .then((exitCode) => { + params.onConfigGenerated(exitCode); + }) + .catch(params.onGenerateConfigError); + return true; + }, + startAppLifecycle: params.startAppLifecycle, + }; +} diff --git a/src/main/state.test.ts b/src/main/state.test.ts new file mode 100644 index 0000000..aaa9f44 --- /dev/null +++ b/src/main/state.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createInitialAnilistMediaGuessRuntimeState, + createInitialAnilistUpdateInFlightState, + transitionAnilistClientSecretState, + transitionAnilistMediaGuessRuntimeState, + transitionAnilistRetryQueueLastAttemptAt, + transitionAnilistRetryQueueLastError, + transitionAnilistUpdateInFlightState, +} from './state'; + +test('transitionAnilistClientSecretState replaces state object', () => { + const current = { + status: 'not_checked', + source: 'none', + message: null, + resolvedAt: null, + errorAt: null, + } as const; + const next = { + status: 'resolved', + source: 'stored', + message: 'ok', + resolvedAt: 123, + errorAt: null, + } as const; + + const transitioned = transitionAnilistClientSecretState(current, next); + + assert.deepEqual(transitioned, next); + assert.equal(transitioned, next); +}); + +test('retry queue metadata transitions preserve queue counts', () => { + const queue = { + pending: 2, + ready: 1, + deadLetter: 4, + lastAttemptAt: null, + lastError: null, + }; + + const attempted = transitionAnilistRetryQueueLastAttemptAt(queue, 999); + const failed = transitionAnilistRetryQueueLastError(attempted, 'boom'); + + assert.deepEqual(attempted, { + pending: 2, + ready: 1, + deadLetter: 4, + lastAttemptAt: 999, + lastError: null, + }); + assert.deepEqual(failed, { + pending: 2, + ready: 1, + deadLetter: 4, + lastAttemptAt: 999, + lastError: 'boom', + }); + assert.notEqual(attempted, queue); + assert.notEqual(failed, attempted); +}); + +test('transitionAnilistMediaGuessRuntimeState applies partial updates', () => { + const current = createInitialAnilistMediaGuessRuntimeState(); + const promise = Promise.resolve(null); + + const transitioned = transitionAnilistMediaGuessRuntimeState(current, { + mediaKey: '/tmp/media.mkv', + mediaGuessPromise: promise, + lastDurationProbeAtMs: 500, + }); + + assert.deepEqual(transitioned, { + mediaKey: '/tmp/media.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: promise, + lastDurationProbeAtMs: 500, + }); + assert.notEqual(transitioned, current); +}); + +test('transitionAnilistUpdateInFlightState updates inFlight only', () => { + const current = createInitialAnilistUpdateInFlightState(); + const transitioned = transitionAnilistUpdateInFlightState(current, true); + + assert.deepEqual(current, { inFlight: false }); + assert.deepEqual(transitioned, { inFlight: true }); + assert.notEqual(transitioned, current); +}); diff --git a/src/main/state.ts b/src/main/state.ts new file mode 100644 index 0000000..433b260 --- /dev/null +++ b/src/main/state.ts @@ -0,0 +1,283 @@ +import type { BrowserWindow, Extension } from 'electron'; + +import type { + Keybinding, + MpvSubtitleRenderMetrics, + SecondarySubMode, + SubtitleData, + SubtitlePosition, + KikuFieldGroupingChoice, + JlptLevel, + FrequencyDictionaryLookup, +} from '../types'; +import type { CliArgs } from '../cli/args'; +import type { SubtitleTimingTracker } from '../subtitle-timing-tracker'; +import type { AnkiIntegration } from '../anki-integration'; +import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service'; +import type { MpvIpcClient } from '../core/services/mpv'; +import type { JellyfinRemoteSessionService } from '../core/services/jellyfin-remote'; +import type { createDiscordPresenceService } from '../core/services/discord-presence'; +import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services/mpv-render-metrics'; +import type { RuntimeOptionsManager } from '../runtime-options'; +import type { MecabTokenizer } from '../mecab-tokenizer'; +import type { BaseWindowTracker } from '../window-trackers'; +import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater'; + +export interface AnilistSecretResolutionState { + status: 'not_checked' | 'resolved' | 'error'; + source: 'none' | 'literal' | 'stored'; + message: string | null; + resolvedAt: number | null; + errorAt: number | null; +} + +export interface AnilistRetryQueueState { + pending: number; + ready: number; + deadLetter: number; + lastAttemptAt: number | null; + lastError: string | null; +} + +export interface AnilistMediaGuessRuntimeState { + mediaKey: string | null; + mediaDurationSec: number | null; + mediaGuess: AnilistMediaGuess | null; + mediaGuessPromise: Promise | null; + lastDurationProbeAtMs: number; +} + +export interface AnilistUpdateInFlightState { + inFlight: boolean; +} + +export function createInitialAnilistSecretResolutionState(): AnilistSecretResolutionState { + return { + status: 'not_checked', + source: 'none', + message: null, + resolvedAt: null, + errorAt: null, + }; +} + +export function createInitialAnilistRetryQueueState(): AnilistRetryQueueState { + return { + pending: 0, + ready: 0, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }; +} + +export function createInitialAnilistMediaGuessRuntimeState(): AnilistMediaGuessRuntimeState { + return { + mediaKey: null, + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; +} + +export function createInitialAnilistUpdateInFlightState(): AnilistUpdateInFlightState { + return { + inFlight: false, + }; +} + +export function transitionAnilistClientSecretState( + _current: AnilistSecretResolutionState, + next: AnilistSecretResolutionState, +): AnilistSecretResolutionState { + return next; +} + +export function transitionAnilistRetryQueueState( + _current: AnilistRetryQueueState, + next: AnilistRetryQueueState, +): AnilistRetryQueueState { + return next; +} + +export function transitionAnilistRetryQueueLastAttemptAt( + current: AnilistRetryQueueState, + lastAttemptAt: number | null, +): AnilistRetryQueueState { + return { + ...current, + lastAttemptAt, + }; +} + +export function transitionAnilistRetryQueueLastError( + current: AnilistRetryQueueState, + lastError: string | null, +): AnilistRetryQueueState { + return { + ...current, + lastError, + }; +} + +export function transitionAnilistMediaGuessRuntimeState( + current: AnilistMediaGuessRuntimeState, + partial: Partial, +): AnilistMediaGuessRuntimeState { + return { + ...current, + ...partial, + }; +} + +export function transitionAnilistUpdateInFlightState( + current: AnilistUpdateInFlightState, + inFlight: boolean, +): AnilistUpdateInFlightState { + return { + ...current, + inFlight, + }; +} + +export interface AppState { + yomitanExt: Extension | null; + yomitanSettingsWindow: BrowserWindow | null; + yomitanParserWindow: BrowserWindow | null; + anilistSetupWindow: BrowserWindow | null; + jellyfinSetupWindow: BrowserWindow | null; + yomitanParserReadyPromise: Promise | null; + yomitanParserInitPromise: Promise | null; + mpvClient: MpvIpcClient | null; + jellyfinRemoteSession: JellyfinRemoteSessionService | null; + discordPresenceService: ReturnType | null; + reconnectTimer: ReturnType | null; + currentSubText: string; + currentSubAssText: string; + currentSubtitleData: SubtitleData | null; + hoveredSubtitleTokenIndex: number | null; + hoveredSubtitleRevision: number; + windowTracker: BaseWindowTracker | null; + subtitlePosition: SubtitlePosition | null; + currentMediaPath: string | null; + currentMediaTitle: string | null; + playbackPaused: boolean | null; + pendingSubtitlePosition: SubtitlePosition | null; + anilistClientSecretState: AnilistSecretResolutionState; + mecabTokenizer: MecabTokenizer | null; + keybindings: Keybinding[]; + subtitleTimingTracker: SubtitleTimingTracker | null; + immersionTracker: ImmersionTrackerService | null; + ankiIntegration: AnkiIntegration | null; + secondarySubMode: SecondarySubMode; + lastSecondarySubToggleAtMs: number; + previousSecondarySubVisibility: boolean | null; + mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics; + shortcutsRegistered: boolean; + overlayRuntimeInitialized: boolean; + fieldGroupingResolver: ((choice: KikuFieldGroupingChoice) => void) | null; + fieldGroupingResolverSequence: number; + runtimeOptionsManager: RuntimeOptionsManager | null; + trackerNotReadyWarningShown: boolean; + overlayDebugVisualizationEnabled: boolean; + subsyncInProgress: boolean; + initialArgs: CliArgs | null; + mpvSocketPath: string; + texthookerPort: number; + backendOverride: string | null; + autoStartOverlay: boolean; + texthookerOnlyMode: boolean; + backgroundMode: boolean; + jlptLevelLookup: (term: string) => JlptLevel | null; + frequencyRankLookup: FrequencyDictionaryLookup; + anilistSetupPageOpened: boolean; + anilistRetryQueueState: AnilistRetryQueueState; +} + +export interface AppStateInitialValues { + mpvSocketPath: string; + texthookerPort: number; + backendOverride?: string | null; + autoStartOverlay?: boolean; + texthookerOnlyMode?: boolean; + backgroundMode?: boolean; +} + +export interface StartupState { + initialArgs: Exclude; + mpvSocketPath: AppState['mpvSocketPath']; + texthookerPort: AppState['texthookerPort']; + backendOverride: AppState['backendOverride']; + autoStartOverlay: AppState['autoStartOverlay']; + texthookerOnlyMode: AppState['texthookerOnlyMode']; + backgroundMode: AppState['backgroundMode']; +} + +export function createAppState(values: AppStateInitialValues): AppState { + return { + yomitanExt: null, + yomitanSettingsWindow: null, + yomitanParserWindow: null, + anilistSetupWindow: null, + jellyfinSetupWindow: null, + yomitanParserReadyPromise: null, + yomitanParserInitPromise: null, + mpvClient: null, + jellyfinRemoteSession: null, + discordPresenceService: null, + reconnectTimer: null, + currentSubText: '', + currentSubAssText: '', + currentSubtitleData: null, + hoveredSubtitleTokenIndex: null, + hoveredSubtitleRevision: 0, + windowTracker: null, + subtitlePosition: null, + currentMediaPath: null, + currentMediaTitle: null, + playbackPaused: null, + pendingSubtitlePosition: null, + anilistClientSecretState: createInitialAnilistSecretResolutionState(), + mecabTokenizer: null, + keybindings: [], + subtitleTimingTracker: null, + immersionTracker: null, + ankiIntegration: null, + secondarySubMode: 'hover', + lastSecondarySubToggleAtMs: 0, + previousSecondarySubVisibility: null, + mpvSubtitleRenderMetrics: { + ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, + }, + runtimeOptionsManager: null, + trackerNotReadyWarningShown: false, + overlayDebugVisualizationEnabled: false, + shortcutsRegistered: false, + overlayRuntimeInitialized: false, + fieldGroupingResolver: null, + fieldGroupingResolverSequence: 0, + subsyncInProgress: false, + initialArgs: null, + mpvSocketPath: values.mpvSocketPath, + texthookerPort: values.texthookerPort, + backendOverride: values.backendOverride ?? null, + autoStartOverlay: values.autoStartOverlay ?? false, + texthookerOnlyMode: values.texthookerOnlyMode ?? false, + backgroundMode: values.backgroundMode ?? false, + jlptLevelLookup: () => null, + frequencyRankLookup: () => null, + anilistSetupPageOpened: false, + anilistRetryQueueState: createInitialAnilistRetryQueueState(), + }; +} + +export function applyStartupState(appState: AppState, startupState: StartupState): void { + appState.initialArgs = startupState.initialArgs; + appState.mpvSocketPath = startupState.mpvSocketPath; + appState.texthookerPort = startupState.texthookerPort; + appState.backendOverride = startupState.backendOverride; + appState.autoStartOverlay = startupState.autoStartOverlay; + appState.texthookerOnlyMode = startupState.texthookerOnlyMode; + appState.backgroundMode = startupState.backgroundMode; +} diff --git a/src/main/subsync-runtime.ts b/src/main/subsync-runtime.ts new file mode 100644 index 0000000..eb274fb --- /dev/null +++ b/src/main/subsync-runtime.ts @@ -0,0 +1,63 @@ +import { SubsyncResolvedConfig } from '../subsync/utils'; +import type { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from '../types'; +import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner'; +import { createSubsyncRuntimeDeps } from './dependencies'; +import { + runSubsyncManualFromIpcRuntime as runSubsyncManualFromIpcRuntimeCore, + triggerSubsyncFromConfigRuntime as triggerSubsyncFromConfigRuntimeCore, +} from '../core/services'; + +export interface SubsyncRuntimeServiceInput { + getMpvClient: SubsyncRuntimeDeps['getMpvClient']; + getResolvedSubsyncConfig: () => SubsyncResolvedConfig; + isSubsyncInProgress: SubsyncRuntimeDeps['isSubsyncInProgress']; + setSubsyncInProgress: SubsyncRuntimeDeps['setSubsyncInProgress']; + showMpvOsd: SubsyncRuntimeDeps['showMpvOsd']; + openManualPicker: (payload: SubsyncManualPayload) => void; +} + +export interface SubsyncRuntimeServiceStateInput { + getMpvClient: SubsyncRuntimeServiceInput['getMpvClient']; + getResolvedSubsyncConfig: SubsyncRuntimeServiceInput['getResolvedSubsyncConfig']; + getSubsyncInProgress: () => boolean; + setSubsyncInProgress: SubsyncRuntimeServiceInput['setSubsyncInProgress']; + showMpvOsd: SubsyncRuntimeServiceInput['showMpvOsd']; + openManualPicker: SubsyncRuntimeServiceInput['openManualPicker']; +} + +export function createSubsyncRuntimeServiceInputFromState( + input: SubsyncRuntimeServiceStateInput, +): SubsyncRuntimeServiceInput { + return { + getMpvClient: input.getMpvClient, + getResolvedSubsyncConfig: input.getResolvedSubsyncConfig, + isSubsyncInProgress: input.getSubsyncInProgress, + setSubsyncInProgress: input.setSubsyncInProgress, + showMpvOsd: input.showMpvOsd, + openManualPicker: input.openManualPicker, + }; +} + +export function createSubsyncRuntimeServiceDeps( + params: SubsyncRuntimeServiceInput, +): SubsyncRuntimeDeps { + return createSubsyncRuntimeDeps({ + getMpvClient: params.getMpvClient, + getResolvedSubsyncConfig: params.getResolvedSubsyncConfig, + isSubsyncInProgress: params.isSubsyncInProgress, + setSubsyncInProgress: params.setSubsyncInProgress, + showMpvOsd: params.showMpvOsd, + openManualPicker: params.openManualPicker, + }); +} + +export function triggerSubsyncFromConfigRuntime(params: SubsyncRuntimeServiceInput): Promise { + return triggerSubsyncFromConfigRuntimeCore(createSubsyncRuntimeServiceDeps(params)); +} + +export async function runSubsyncManualFromIpcRuntime( + request: SubsyncManualRunRequest, + params: SubsyncRuntimeServiceInput, +): Promise { + return runSubsyncManualFromIpcRuntimeCore(request, createSubsyncRuntimeServiceDeps(params)); +} diff --git a/src/mecab-tokenizer.ts b/src/mecab-tokenizer.ts new file mode 100644 index 0000000..e4560c6 --- /dev/null +++ b/src/mecab-tokenizer.ts @@ -0,0 +1,212 @@ +/* + * SubMiner - All-in-one sentence mining overlay + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { spawn, execSync } from 'child_process'; +import { PartOfSpeech, Token, MecabStatus } from './types'; +import { createLogger } from './logger'; + +export { PartOfSpeech }; + +const log = createLogger('mecab'); + +function mapPartOfSpeech(pos1: string): PartOfSpeech { + switch (pos1) { + case '名詞': + return PartOfSpeech.noun; + case '動詞': + return PartOfSpeech.verb; + case '形容詞': + return PartOfSpeech.i_adjective; + case '形状詞': + case '形容動詞': + return PartOfSpeech.na_adjective; + case '助詞': + return PartOfSpeech.particle; + case '助動詞': + return PartOfSpeech.bound_auxiliary; + case '記号': + case '補助記号': + return PartOfSpeech.symbol; + default: + return PartOfSpeech.other; + } +} + +export function parseMecabLine(line: string): Token | null { + if (!line || line === 'EOS' || line.trim() === '') { + return null; + } + + const tabIndex = line.indexOf('\t'); + if (tabIndex === -1) { + return null; + } + + const surface = line.substring(0, tabIndex); + const featureString = line.substring(tabIndex + 1); + const features = featureString.split(','); + + const pos1 = features[0] || ''; + const pos2 = features[1] || ''; + const pos3 = features[2] || ''; + const pos4 = features[3] || ''; + const inflectionType = features[4] || ''; + const inflectionForm = features[5] || ''; + const lemma = features[6] || surface; + const reading = features[7] || ''; + const pronunciation = features[8] || ''; + + return { + word: surface, + partOfSpeech: mapPartOfSpeech(pos1), + pos1, + pos2, + pos3, + pos4, + inflectionType, + inflectionForm, + headword: lemma !== '*' ? lemma : surface, + katakanaReading: reading !== '*' ? reading : '', + pronunciation: pronunciation !== '*' ? pronunciation : '', + }; +} + +export interface MecabTokenizerOptions { + mecabCommand?: string; + dictionaryPath?: string; +} + +export class MecabTokenizer { + private mecabPath: string | null = null; + private mecabCommand: string; + private dictionaryPath: string | null; + private available: boolean = false; + private enabled: boolean = true; + + constructor(options: MecabTokenizerOptions = {}) { + this.mecabCommand = options.mecabCommand?.trim() || 'mecab'; + this.dictionaryPath = options.dictionaryPath?.trim() || null; + } + + async checkAvailability(): Promise { + try { + const command = this.mecabCommand; + const result = command.includes('/') + ? command + : execSync(`which ${command}`, { encoding: 'utf-8' }).trim(); + if (result) { + this.mecabPath = result; + this.available = true; + log.info('MeCab found at:', this.mecabPath); + return true; + } + } catch (err) { + log.info('MeCab not found on system'); + } + + this.available = false; + return false; + } + + async tokenize(text: string): Promise { + if (!this.available || !this.enabled || !text) { + return null; + } + + return new Promise((resolve) => { + const mecabArgs: string[] = []; + if (this.dictionaryPath) { + mecabArgs.push('-d', this.dictionaryPath); + } + const mecab = spawn(this.mecabPath ?? this.mecabCommand, mecabArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + mecab.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + mecab.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + mecab.on('close', (code: number | null) => { + if (code !== 0) { + log.error('MeCab process exited with code:', code); + if (stderr) { + log.error('MeCab stderr:', stderr); + } + resolve(null); + return; + } + + const lines = stdout.split('\n'); + const tokens: Token[] = []; + + for (const line of lines) { + const token = parseMecabLine(line); + if (token) { + tokens.push(token); + } + } + + if (tokens.length === 0 && text.trim().length > 0) { + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + if (trimmedStdout) { + log.warn( + 'MeCab returned no parseable tokens.', + `command=${this.mecabPath ?? this.mecabCommand}`, + `stdout=${trimmedStdout.slice(0, 1024)}`, + ); + } + if (trimmedStderr) { + log.warn('MeCab stderr while tokenizing:', trimmedStderr); + } + } + + resolve(tokens); + }); + + mecab.on('error', (err: Error) => { + log.error('Failed to spawn MeCab:', err.message); + resolve(null); + }); + + mecab.stdin.write(text); + mecab.stdin.end(); + }); + } + + getStatus(): MecabStatus { + return { + available: this.available, + enabled: this.enabled, + path: this.mecabPath, + }; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } +} + +export { mapPartOfSpeech }; diff --git a/src/media-generator.ts b/src/media-generator.ts new file mode 100644 index 0000000..8268b27 --- /dev/null +++ b/src/media-generator.ts @@ -0,0 +1,374 @@ +/* + * SubMiner - Subtitle mining overlay for mpv + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { ExecFileException, execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createLogger } from './logger'; + +const log = createLogger('media'); + +export class MediaGenerator { + private tempDir: string; + private notifyIconDir: string; + private av1EncoderPromise: Promise | null = null; + + constructor(tempDir?: string) { + this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media'); + this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify'); + if (!fs.existsSync(this.tempDir)) { + fs.mkdirSync(this.tempDir, { recursive: true }); + } + if (!fs.existsSync(this.notifyIconDir)) { + fs.mkdirSync(this.notifyIconDir, { recursive: true }); + } + // Clean up old notification icons on startup (older than 1 hour) + this.cleanupOldNotificationIcons(); + } + + /** + * Clean up notification icons older than 1 hour. + * Called on startup to prevent accumulation of temp files. + */ + private cleanupOldNotificationIcons(): void { + try { + if (!fs.existsSync(this.notifyIconDir)) return; + + const files = fs.readdirSync(this.notifyIconDir); + const oneHourAgo = Date.now() - 60 * 60 * 1000; + + for (const file of files) { + if (!file.endsWith('.png')) continue; + const filePath = path.join(this.notifyIconDir, file); + try { + const stat = fs.statSync(filePath); + if (stat.mtimeMs < oneHourAgo) { + fs.unlinkSync(filePath); + } + } catch (err) { + log.debug(`Failed to clean up ${filePath}:`, (err as Error).message); + } + } + } catch (err) { + log.error('Failed to cleanup old notification icons:', err); + } + } + + /** + * Write a notification icon buffer to a temp file and return the file path. + * The file path can be passed directly to Electron Notification for better + * compatibility with Linux/Wayland notification daemons. + */ + writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string { + const filename = `icon_${noteId}_${Date.now()}.png`; + const filePath = path.join(this.notifyIconDir, filename); + fs.writeFileSync(filePath, iconBuffer); + return filePath; + } + + scheduleNotificationIconCleanup(filePath: string, delayMs = 10000): void { + setTimeout(() => { + try { + fs.unlinkSync(filePath); + } catch {} + }, delayMs); + } + + private ffmpegError(label: string, error: ExecFileException): Error { + if (error.code === 'ENOENT') { + return new Error('FFmpeg not found. Install FFmpeg to enable media generation.'); + } + return new Error(`FFmpeg ${label} failed: ${error.message}`); + } + + private detectAv1Encoder(): Promise { + if (this.av1EncoderPromise) return this.av1EncoderPromise; + + this.av1EncoderPromise = new Promise((resolve) => { + execFile( + 'ffmpeg', + ['-hide_banner', '-encoders'], + { timeout: 10000 }, + (error, stdout, stderr) => { + if (error) { + resolve(null); + return; + } + + const output = `${stdout || ''}\n${stderr || ''}`; + const candidates = ['libaom-av1', 'libsvtav1', 'librav1e']; + for (const encoder of candidates) { + if (output.includes(encoder)) { + resolve(encoder); + return; + } + } + resolve(null); + }, + ); + }); + + return this.av1EncoderPromise; + } + + async generateAudio( + videoPath: string, + startTime: number, + endTime: number, + padding: number = 0.5, + audioStreamIndex: number | null = null, + ): Promise { + const start = Math.max(0, startTime - padding); + const duration = endTime - startTime + 2 * padding; + + return new Promise((resolve, reject) => { + const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`); + const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath]; + + if ( + typeof audioStreamIndex === 'number' && + Number.isInteger(audioStreamIndex) && + audioStreamIndex >= 0 + ) { + args.push('-map', `0:${audioStreamIndex}`); + } + + args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath); + + execFile('ffmpeg', args, { timeout: 30000 }, (error) => { + if (error) { + reject(this.ffmpegError('audio generation', error)); + return; + } + + try { + const data = fs.readFileSync(outputPath); + fs.unlinkSync(outputPath); + resolve(data); + } catch (err) { + reject(err); + } + }); + }); + } + + async generateScreenshot( + videoPath: string, + timestamp: number, + options: { + format: 'jpg' | 'png' | 'webp'; + quality?: number; + maxWidth?: number; + maxHeight?: number; + }, + ): Promise { + const { format, quality = 92, maxWidth, maxHeight } = options; + const ext = format === 'webp' ? 'webp' : format === 'png' ? 'png' : 'jpg'; + const codecMap: Record<'jpg' | 'png' | 'webp', string> = { + jpg: 'mjpeg', + png: 'png', + webp: 'webp', + }; + + const args: string[] = ['-ss', timestamp.toString(), '-i', videoPath, '-vframes', '1']; + + const vfParts: string[] = []; + if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) { + vfParts.push(`scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`); + } else if (maxWidth && maxWidth > 0) { + vfParts.push(`scale=w=${maxWidth}:h=-2`); + } else if (maxHeight && maxHeight > 0) { + vfParts.push(`scale=w=-2:h=${maxHeight}`); + } + if (vfParts.length > 0) { + args.push('-vf', vfParts.join(',')); + } + + args.push('-c:v', codecMap[format]); + + if (format !== 'png') { + const clampedQuality = Math.max(1, Math.min(100, quality)); + if (format === 'jpg') { + const qv = Math.round(2 + (100 - clampedQuality) * (29 / 99)); + args.push('-q:v', qv.toString()); + } else { + args.push('-q:v', clampedQuality.toString()); + } + } + + args.push('-y'); + + return new Promise((resolve, reject) => { + const outputPath = path.join(this.tempDir, `screenshot_${Date.now()}.${ext}`); + args.push(outputPath); + + execFile('ffmpeg', args, { timeout: 30000 }, (error) => { + if (error) { + reject(this.ffmpegError('screenshot generation', error)); + return; + } + + try { + const data = fs.readFileSync(outputPath); + fs.unlinkSync(outputPath); + resolve(data); + } catch (err) { + reject(err); + } + }); + }); + } + + /** + * Generate a small PNG icon suitable for desktop notifications. + * Always outputs PNG format (known-good for Electron + Linux notification daemons). + * Scaled to 256px width for fast encoding and small file size. + */ + async generateNotificationIcon(videoPath: string, timestamp: number): Promise { + return new Promise((resolve, reject) => { + const outputPath = path.join(this.tempDir, `notify_icon_${Date.now()}.png`); + + execFile( + 'ffmpeg', + [ + '-ss', + timestamp.toString(), + '-i', + videoPath, + '-vframes', + '1', + '-vf', + 'scale=256:256:force_original_aspect_ratio=decrease,pad=256:256:(ow-iw)/2:(oh-ih)/2:black', + '-c:v', + 'png', + '-y', + outputPath, + ], + { timeout: 30000 }, + (error) => { + if (error) { + reject(this.ffmpegError('notification icon generation', error)); + return; + } + + try { + const data = fs.readFileSync(outputPath); + fs.unlinkSync(outputPath); + resolve(data); + } catch (err) { + reject(err); + } + }, + ); + }); + } + + async generateAnimatedImage( + videoPath: string, + startTime: number, + endTime: number, + padding: number = 0.5, + options: { + fps?: number; + maxWidth?: number; + maxHeight?: number; + crf?: number; + } = {}, + ): Promise { + const start = Math.max(0, startTime - padding); + const duration = endTime - startTime + 2 * padding; + const { fps = 10, maxWidth = 640, maxHeight, crf = 35 } = options; + + const clampedFps = Math.max(1, Math.min(60, fps)); + const clampedCrf = Math.max(0, Math.min(63, crf)); + + const vfParts: string[] = []; + vfParts.push(`fps=${clampedFps}`); + if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) { + vfParts.push(`scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`); + } else if (maxWidth && maxWidth > 0) { + vfParts.push(`scale=w=${maxWidth}:h=-2`); + } else if (maxHeight && maxHeight > 0) { + vfParts.push(`scale=w=-2:h=${maxHeight}`); + } + + const av1Encoder = await this.detectAv1Encoder(); + if (!av1Encoder) { + throw new Error( + 'No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).', + ); + } + + return new Promise((resolve, reject) => { + const outputPath = path.join(this.tempDir, `animation_${Date.now()}.avif`); + + const encoderArgs: string[] = ['-c:v', av1Encoder]; + if (av1Encoder === 'libaom-av1') { + encoderArgs.push('-crf', clampedCrf.toString(), '-b:v', '0', '-cpu-used', '8'); + } else if (av1Encoder === 'libsvtav1') { + encoderArgs.push('-crf', clampedCrf.toString(), '-preset', '8'); + } else { + // librav1e + encoderArgs.push('-qp', clampedCrf.toString(), '-speed', '8'); + } + + execFile( + 'ffmpeg', + [ + '-ss', + start.toString(), + '-t', + duration.toString(), + '-i', + videoPath, + '-vf', + vfParts.join(','), + ...encoderArgs, + '-y', + outputPath, + ], + { timeout: 60000 }, + (error) => { + if (error) { + reject(this.ffmpegError('animation generation', error)); + return; + } + + try { + const data = fs.readFileSync(outputPath); + fs.unlinkSync(outputPath); + resolve(data); + } catch (err) { + reject(err); + } + }, + ); + }); + } + + cleanup(): void { + try { + if (fs.existsSync(this.tempDir)) { + fs.rmSync(this.tempDir, { recursive: true, force: true }); + } + } catch (err) { + log.error('Failed to cleanup media generator temp directory:', err); + } + } +} diff --git a/src/preload.ts b/src/preload.ts new file mode 100644 index 0000000..7c37390 --- /dev/null +++ b/src/preload.ts @@ -0,0 +1,275 @@ +/* + * SubMiner - All-in-one sentence mining overlay + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; +import type { + SubtitleData, + SubtitlePosition, + MecabStatus, + Keybinding, + ElectronAPI, + SecondarySubMode, + SubtitleStyleConfig, + JimakuMediaInfo, + JimakuSearchQuery, + JimakuFilesQuery, + JimakuDownloadQuery, + JimakuEntry, + JimakuFileEntry, + JimakuApiResponse, + JimakuDownloadResult, + SubsyncManualPayload, + SubsyncManualRunRequest, + SubsyncResult, + ClipboardAppendResult, + KikuFieldGroupingRequestData, + KikuFieldGroupingChoice, + KikuMergePreviewRequest, + KikuMergePreviewResponse, + RuntimeOptionApplyResult, + RuntimeOptionId, + RuntimeOptionState, + RuntimeOptionValue, + MpvSubtitleRenderMetrics, + OverlayContentMeasurement, + ShortcutsConfig, + ConfigHotReloadPayload, +} from './types'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; + +const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer=')); +const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length); +const overlayLayer = + overlayLayerFromArg === 'visible' || + overlayLayerFromArg === 'invisible' || + overlayLayerFromArg === 'secondary' + ? overlayLayerFromArg + : null; + +const electronAPI: ElectronAPI = { + getOverlayLayer: () => overlayLayer, + onSubtitle: (callback: (data: SubtitleData) => void) => { + ipcRenderer.on(IPC_CHANNELS.event.subtitleSet, (_event: IpcRendererEvent, data: SubtitleData) => + callback(data), + ); + }, + + onVisibility: (callback: (visible: boolean) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.subtitleVisibility, + (_event: IpcRendererEvent, visible: boolean) => callback(visible), + ); + }, + + onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.subtitlePositionSet, + (_event: IpcRendererEvent, position: SubtitlePosition | null) => { + callback(position); + }, + ); + }, + + getOverlayVisibility: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getOverlayVisibility), + getCurrentSubtitle: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitle), + getCurrentSubtitleRaw: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), + getCurrentSubtitleAss: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), + getMpvSubtitleRenderMetrics: () => + ipcRenderer.invoke(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics), + onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.mpvSubtitleRenderMetricsSet, + (_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => { + callback(metrics); + }, + ); + }, + onSubtitleAss: (callback: (assText: string) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.subtitleAssSet, + (_event: IpcRendererEvent, assText: string) => { + callback(assText); + }, + ); + }, + onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.overlayDebugVisualizationSet, + (_event: IpcRendererEvent, enabled: boolean) => { + callback(enabled); + }, + ); + }, + + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options); + }, + + openYomitanSettings: () => { + ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings); + }, + + getSubtitlePosition: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitlePosition), + saveSubtitlePosition: (position: SubtitlePosition) => { + ipcRenderer.send(IPC_CHANNELS.command.saveSubtitlePosition, position); + }, + + getMecabStatus: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getMecabStatus), + setMecabEnabled: (enabled: boolean) => { + ipcRenderer.send(IPC_CHANNELS.command.setMecabEnabled, enabled); + }, + + sendMpvCommand: (command: (string | number)[]) => { + ipcRenderer.send(IPC_CHANNELS.command.mpvCommand, command); + }, + + getKeybindings: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), + getConfiguredShortcuts: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), + + getJimakuMediaInfo: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo), + jimakuSearchEntries: (query: JimakuSearchQuery): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuSearchEntries, query), + jimakuListFiles: (query: JimakuFilesQuery): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuListFiles, query), + jimakuDownloadFile: (query: JimakuDownloadQuery): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuDownloadFile, query), + + quitApp: () => { + ipcRenderer.send(IPC_CHANNELS.command.quitApp); + }, + + toggleDevTools: () => { + ipcRenderer.send(IPC_CHANNELS.command.toggleDevTools); + }, + + toggleOverlay: () => { + ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay); + }, + + getAnkiConnectStatus: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus), + setAnkiConnectEnabled: (enabled: boolean) => { + ipcRenderer.send(IPC_CHANNELS.command.setAnkiConnectEnabled, enabled); + }, + clearAnkiConnectHistory: () => { + ipcRenderer.send(IPC_CHANNELS.command.clearAnkiConnectHistory); + }, + + onSecondarySub: (callback: (text: string) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.secondarySubtitleSet, + (_event: IpcRendererEvent, text: string) => callback(text), + ); + }, + + onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.secondarySubtitleMode, + (_event: IpcRendererEvent, mode: SecondarySubMode) => callback(mode), + ); + }, + + getSecondarySubMode: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getSecondarySubMode), + getCurrentSecondarySub: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSecondarySub), + focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise, + getSubtitleStyle: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle), + onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.subsyncOpenManual, + (_event: IpcRendererEvent, payload: SubsyncManualPayload) => { + callback(payload); + }, + ); + }, + runSubsyncManual: (request: SubsyncManualRunRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request), + + onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.kikuFieldGroupingRequest, + (_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) => callback(data), + ); + }, + kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request), + + kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => { + ipcRenderer.send(IPC_CHANNELS.command.kikuFieldGroupingRespond, choice); + }, + + getRuntimeOptions: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getRuntimeOptions), + setRuntimeOptionValue: ( + id: RuntimeOptionId, + value: RuntimeOptionValue, + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.setRuntimeOption, id, value), + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.cycleRuntimeOption, id, direction), + onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.runtimeOptionsChanged, + (_event: IpcRendererEvent, options: RuntimeOptionState[]) => { + callback(options); + }, + ); + }, + onOpenRuntimeOptions: (callback: () => void) => { + ipcRenderer.on(IPC_CHANNELS.event.runtimeOptionsOpen, () => { + callback(); + }); + }, + onOpenJimaku: (callback: () => void) => { + ipcRenderer.on(IPC_CHANNELS.event.jimakuOpen, () => { + callback(); + }); + }, + appendClipboardVideoToQueue: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), + notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => { + ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); + }, + reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { + ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement); + }, + reportHoveredSubtitleToken: (tokenIndex: number | null) => { + ipcRenderer.send('subtitle-token-hover:set', tokenIndex); + }, + onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { + ipcRenderer.on( + IPC_CHANNELS.event.configHotReload, + (_event: IpcRendererEvent, payload: ConfigHotReloadPayload) => { + callback(payload); + }, + ); + }, +}; + +contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/renderer/context.ts b/src/renderer/context.ts new file mode 100644 index 0000000..72588fe --- /dev/null +++ b/src/renderer/context.ts @@ -0,0 +1,14 @@ +import type { RendererState } from './state'; +import type { RendererDom } from './utils/dom'; +import type { PlatformInfo } from './utils/platform'; + +export type RendererContext = { + dom: RendererDom; + platform: PlatformInfo; + state: RendererState; +}; + +export type ModalStateReader = { + isAnySettingsModalOpen: () => boolean; + isAnyModalOpen: () => boolean; +}; diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts new file mode 100644 index 0000000..7867aa4 --- /dev/null +++ b/src/renderer/error-recovery.test.ts @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createRendererRecoveryController } from './error-recovery.js'; +import { resolvePlatformInfo } from './utils/platform.js'; + +test('handleError logs context and recovers overlay state', () => { + const payloads: unknown[] = []; + let dismissed = 0; + let restored = 0; + const shown: string[] = []; + + const controller = createRendererRecoveryController({ + dismissActiveUi: () => { + dismissed += 1; + }, + restoreOverlayInteraction: () => { + restored += 1; + }, + showToast: (message) => { + shown.push(message); + }, + getSnapshot: () => ({ + activeModal: 'jimaku', + subtitlePreview: '字幕テキスト', + secondarySubtitlePreview: 'secondary', + isOverlayInteractive: true, + isOverSubtitle: true, + invisiblePositionEditMode: false, + overlayLayer: 'visible', + }), + logError: (payload) => { + payloads.push(payload); + }, + }); + + controller.handleError(new Error('renderer boom'), { + source: 'callback', + action: 'onSubtitle', + }); + + assert.equal(dismissed, 1); + assert.equal(restored, 1); + assert.equal(shown.length, 1); + assert.match(shown[0]!, /recovered/i); + assert.equal(payloads.length, 1); + + const payload = payloads[0] as { + context: { action: string }; + error: { message: string; stack: string | null }; + snapshot: { activeModal: string | null; subtitlePreview: string }; + }; + assert.equal(payload.context.action, 'onSubtitle'); + assert.equal(payload.snapshot.activeModal, 'jimaku'); + assert.equal(payload.snapshot.subtitlePreview, '字幕テキスト'); + assert.equal(payload.error.message, 'renderer boom'); + assert.ok( + typeof payload.error.stack === 'string' && payload.error.stack.includes('renderer boom'), + ); +}); + +test('handleError normalizes non-Error values', () => { + const payloads: unknown[] = []; + + const controller = createRendererRecoveryController({ + dismissActiveUi: () => {}, + restoreOverlayInteraction: () => {}, + showToast: () => {}, + getSnapshot: () => ({ + activeModal: null, + subtitlePreview: '', + secondarySubtitlePreview: '', + isOverlayInteractive: false, + isOverSubtitle: false, + invisiblePositionEditMode: false, + overlayLayer: 'invisible', + }), + logError: (payload) => { + payloads.push(payload); + }, + }); + + controller.handleError({ code: 500, reason: 'timeout' }, { source: 'callback', action: 'modal' }); + + const payload = payloads[0] as { error: { message: string; stack: string | null } }; + assert.equal(payload.error.message, JSON.stringify({ code: 500, reason: 'timeout' })); + assert.equal(payload.error.stack, null); +}); + +test('nested recovery errors are ignored while current recovery is active', () => { + const payloads: unknown[] = []; + let restored = 0; + + let controllerRef: ReturnType | null = null; + + const controller = createRendererRecoveryController({ + dismissActiveUi: () => { + controllerRef?.handleError(new Error('nested'), { source: 'callback', action: 'nested' }); + }, + restoreOverlayInteraction: () => { + restored += 1; + }, + showToast: () => {}, + getSnapshot: () => ({ + activeModal: 'runtime-options', + subtitlePreview: '', + secondarySubtitlePreview: '', + isOverlayInteractive: true, + isOverSubtitle: false, + invisiblePositionEditMode: true, + overlayLayer: 'visible', + }), + logError: (payload) => { + payloads.push(payload); + }, + }); + controllerRef = controller; + + controller.handleError(new Error('outer'), { source: 'callback', action: 'outer' }); + + assert.equal(payloads.length, 1); + assert.equal(restored, 1); +}); + +test('resolvePlatformInfo prefers query layer over preload layer', () => { + const previousWindow = (globalThis as { window?: unknown }).window; + const previousNavigator = (globalThis as { navigator?: unknown }).navigator; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getOverlayLayer: () => 'invisible', + }, + location: { search: '?layer=visible' }, + }, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + platform: 'MacIntel', + userAgent: 'Mozilla/5.0 (Macintosh)', + }, + }); + + try { + const info = resolvePlatformInfo(); + assert.equal(info.overlayLayer, 'visible'); + assert.equal(info.isInvisibleLayer, false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: previousNavigator, + }); + } +}); + +test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => { + const previousWindow = (globalThis as { window?: unknown }).window; + const previousNavigator = (globalThis as { navigator?: unknown }).navigator; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getOverlayLayer: () => 'secondary', + }, + location: { search: '' }, + }, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + platform: 'MacIntel', + userAgent: 'Mozilla/5.0 (Macintosh)', + }, + }); + + try { + const info = resolvePlatformInfo(); + assert.equal(info.overlayLayer, 'secondary'); + assert.equal(info.isSecondaryLayer, true); + assert.equal(info.shouldToggleMouseIgnore, false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: previousNavigator, + }); + } +}); diff --git a/src/renderer/error-recovery.ts b/src/renderer/error-recovery.ts new file mode 100644 index 0000000..cffda23 --- /dev/null +++ b/src/renderer/error-recovery.ts @@ -0,0 +1,177 @@ +export type RendererErrorSource = + | 'callback' + | 'window.onerror' + | 'window.unhandledrejection' + | 'bootstrap'; + +export type RendererRecoveryContext = { + source: RendererErrorSource; + action: string; + details?: Record; +}; + +export type RendererRecoverySnapshot = { + activeModal: string | null; + subtitlePreview: string; + secondarySubtitlePreview: string; + isOverlayInteractive: boolean; + isOverSubtitle: boolean; + invisiblePositionEditMode: boolean; + overlayLayer: 'visible' | 'invisible' | 'secondary'; +}; + +type NormalizedRendererError = { + message: string; + stack: string | null; +}; + +export type RendererRecoveryLogPayload = { + kind: 'renderer-overlay-recovery'; + context: RendererRecoveryContext; + error: NormalizedRendererError; + snapshot: RendererRecoverySnapshot; + timestamp: string; +}; + +type RendererRecoveryDeps = { + dismissActiveUi: () => void; + restoreOverlayInteraction: () => void; + showToast: (message: string) => void; + getSnapshot: () => RendererRecoverySnapshot; + logError: (payload: RendererRecoveryLogPayload) => void; + toastMessage?: string; +}; + +type RendererRecoveryController = { + handleError: (error: unknown, context: RendererRecoveryContext) => void; +}; + +type RendererRecoveryWindow = Pick; + +const DEFAULT_TOAST_MESSAGE = 'Renderer error recovered. Overlay is still running.'; + +function normalizeRendererError(error: unknown): NormalizedRendererError { + if (error instanceof Error) { + return { + message: error.message || 'Unknown renderer error', + stack: typeof error.stack === 'string' ? error.stack : null, + }; + } + + if (typeof error === 'string') { + return { + message: error, + stack: null, + }; + } + + if (typeof error === 'object' && error !== null) { + try { + return { + message: JSON.stringify(error), + stack: null, + }; + } catch { + return { + message: '[unserializable error object]', + stack: null, + }; + } + } + + return { + message: String(error), + stack: null, + }; +} + +export function createRendererRecoveryController( + deps: RendererRecoveryDeps, +): RendererRecoveryController { + let inRecovery = false; + + const toastMessage = deps.toastMessage ?? DEFAULT_TOAST_MESSAGE; + + const invokeRecoveryStep = ( + step: 'dismissActiveUi' | 'restoreOverlayInteraction' | 'showToast', + fn: () => void, + ): void => { + try { + fn(); + } catch (error) { + try { + deps.logError({ + kind: 'renderer-overlay-recovery', + context: { + source: 'callback', + action: `recovery-step:${step}`, + }, + error: normalizeRendererError(error), + snapshot: deps.getSnapshot(), + timestamp: new Date().toISOString(), + }); + } catch { + // Avoid recursive failures from logging inside the recovery path. + } + } + }; + + const handleError = (error: unknown, context: RendererRecoveryContext): void => { + if (inRecovery) { + return; + } + + inRecovery = true; + try { + deps.logError({ + kind: 'renderer-overlay-recovery', + context, + error: normalizeRendererError(error), + snapshot: deps.getSnapshot(), + timestamp: new Date().toISOString(), + }); + + invokeRecoveryStep('dismissActiveUi', deps.dismissActiveUi); + invokeRecoveryStep('restoreOverlayInteraction', deps.restoreOverlayInteraction); + invokeRecoveryStep('showToast', () => deps.showToast(toastMessage)); + } finally { + inRecovery = false; + } + }; + + return { handleError }; +} + +export function registerRendererGlobalErrorHandlers( + recoveryWindow: RendererRecoveryWindow, + controller: RendererRecoveryController, +): () => void { + const onError = (event: Event): void => { + const errorEvent = event as ErrorEvent; + controller.handleError(errorEvent.error ?? errorEvent.message, { + source: 'window.onerror', + action: 'global-error', + details: { + filename: errorEvent.filename, + lineno: errorEvent.lineno, + colno: errorEvent.colno, + }, + }); + }; + + const onUnhandledRejection = (event: Event): void => { + const rejectionEvent = event as PromiseRejectionEvent; + controller.handleError(rejectionEvent.reason, { + source: 'window.unhandledrejection', + action: 'global-unhandledrejection', + }); + }; + + recoveryWindow.addEventListener('error', onError); + recoveryWindow.addEventListener('unhandledrejection', onUnhandledRejection); + + return () => { + recoveryWindow.removeEventListener('error', onError); + recoveryWindow.removeEventListener('unhandledrejection', onUnhandledRejection); + }; +} diff --git a/src/renderer/fonts/MPLUS1[wght].ttf b/src/renderer/fonts/MPLUS1[wght].ttf new file mode 100644 index 0000000..078795d Binary files /dev/null and b/src/renderer/fonts/MPLUS1[wght].ttf differ diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts new file mode 100644 index 0000000..c177222 --- /dev/null +++ b/src/renderer/handlers/keyboard.ts @@ -0,0 +1,303 @@ +import type { Keybinding } from '../../types'; +import type { RendererContext } from '../context'; + +export function createKeyboardHandlers( + ctx: RendererContext, + options: { + handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean; + handleSubsyncKeydown: (e: KeyboardEvent) => boolean; + handleKikuKeydown: (e: KeyboardEvent) => boolean; + handleJimakuKeydown: (e: KeyboardEvent) => boolean; + handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; + openSessionHelpModal: (opening: { + bindingKey: 'KeyH' | 'KeyK'; + fallbackUsed: boolean; + fallbackUnavailable: boolean; + }) => void; + saveInvisiblePositionEdit: () => void; + cancelInvisiblePositionEdit: () => void; + setInvisiblePositionEditMode: (enabled: boolean) => void; + applyInvisibleSubtitleOffsetPosition: () => void; + updateInvisiblePositionEditHud: () => void; + appendClipboardVideoToQueue: () => void; + }, +) { + // Timeout for the modal chord capture window (e.g. Y followed by H/K). + const CHORD_TIMEOUT_MS = 1000; + + const CHORD_MAP = new Map< + string, + { type: 'mpv' | 'electron'; command?: string[]; action?: () => void } + >([ + ['KeyS', { type: 'mpv', command: ['script-message', 'subminer-start'] }], + ['Shift+KeyS', { type: 'mpv', command: ['script-message', 'subminer-stop'] }], + ['KeyT', { type: 'mpv', command: ['script-message', 'subminer-toggle'] }], + ['KeyI', { type: 'mpv', command: ['script-message', 'subminer-toggle-invisible'] }], + ['Shift+KeyI', { type: 'mpv', command: ['script-message', 'subminer-show-invisible'] }], + ['KeyU', { type: 'mpv', command: ['script-message', 'subminer-hide-invisible'] }], + ['KeyO', { type: 'mpv', command: ['script-message', 'subminer-options'] }], + ['KeyR', { type: 'mpv', command: ['script-message', 'subminer-restart'] }], + ['KeyC', { type: 'mpv', command: ['script-message', 'subminer-status'] }], + ['KeyY', { type: 'mpv', command: ['script-message', 'subminer-menu'] }], + ['KeyD', { type: 'electron', action: () => window.electronAPI.toggleDevTools() }], + ]); + + function isInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + if (target.closest('.modal')) return true; + if (ctx.dom.subtitleContainer.contains(target)) return true; + if (target.tagName === 'IFRAME' && target.id?.startsWith('yomitan-popup')) { + return true; + } + if (target.closest && target.closest('iframe[id^="yomitan-popup"]')) return true; + return false; + } + + function keyEventToString(e: KeyboardEvent): string { + const parts: string[] = []; + if (e.ctrlKey) parts.push('Ctrl'); + if (e.altKey) parts.push('Alt'); + if (e.shiftKey) parts.push('Shift'); + if (e.metaKey) parts.push('Meta'); + parts.push(e.code); + return parts.join('+'); + } + + function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean { + return ( + e.code === ctx.platform.invisiblePositionEditToggleCode && + !e.altKey && + e.shiftKey && + (e.ctrlKey || e.metaKey) + ); + } + + function resolveSessionHelpChordBinding(): { + bindingKey: 'KeyH' | 'KeyK'; + fallbackUsed: boolean; + fallbackUnavailable: boolean; + } { + const firstChoice = 'KeyH'; + if (!ctx.state.keybindingsMap.has('KeyH')) { + return { + bindingKey: firstChoice, + fallbackUsed: false, + fallbackUnavailable: false, + }; + } + + if (ctx.state.keybindingsMap.has('KeyK')) { + return { + bindingKey: 'KeyK', + fallbackUsed: true, + fallbackUnavailable: true, + }; + } + + return { + bindingKey: 'KeyK', + fallbackUsed: true, + fallbackUnavailable: false, + }; + } + + function applySessionHelpChordBinding(): void { + CHORD_MAP.delete('KeyH'); + CHORD_MAP.delete('KeyK'); + const info = resolveSessionHelpChordBinding(); + CHORD_MAP.set(info.bindingKey, { + type: 'electron', + action: () => { + options.openSessionHelpModal(info); + }, + }); + } + + function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean { + if (!ctx.platform.isInvisibleLayer) return false; + + if (isInvisiblePositionToggleShortcut(e)) { + e.preventDefault(); + if (ctx.state.invisiblePositionEditMode) { + options.cancelInvisiblePositionEdit(); + } else { + options.setInvisiblePositionEditMode(true); + } + return true; + } + + if (!ctx.state.invisiblePositionEditMode) return false; + + const step = e.shiftKey + ? ctx.platform.invisiblePositionStepFastPx + : ctx.platform.invisiblePositionStepPx; + + if (e.key === 'Escape') { + e.preventDefault(); + options.cancelInvisiblePositionEdit(); + return true; + } + + if (e.key === 'Enter' || ((e.ctrlKey || e.metaKey) && e.code === 'KeyS')) { + e.preventDefault(); + options.saveInvisiblePositionEdit(); + return true; + } + + if ( + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'h' || + e.key === 'j' || + e.key === 'k' || + e.key === 'l' || + e.key === 'H' || + e.key === 'J' || + e.key === 'K' || + e.key === 'L' + ) { + e.preventDefault(); + if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') { + ctx.state.invisibleSubtitleOffsetYPx += step; + } else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') { + ctx.state.invisibleSubtitleOffsetYPx -= step; + } else if (e.key === 'ArrowLeft' || e.key === 'h' || e.key === 'H') { + ctx.state.invisibleSubtitleOffsetXPx -= step; + } else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') { + ctx.state.invisibleSubtitleOffsetXPx += step; + } + options.applyInvisibleSubtitleOffsetPosition(); + options.updateInvisiblePositionEditHud(); + return true; + } + + return true; + } + + function resetChord(): void { + ctx.state.chordPending = false; + if (ctx.state.chordTimeout !== null) { + clearTimeout(ctx.state.chordTimeout); + ctx.state.chordTimeout = null; + } + } + + async function setupMpvInputForwarding(): Promise { + updateKeybindings(await window.electronAPI.getKeybindings()); + + document.addEventListener('keydown', (e: KeyboardEvent) => { + const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); + if (yomitanPopup) return; + if (handleInvisiblePositionEditKeydown(e)) return; + + if (ctx.state.runtimeOptionsModalOpen) { + options.handleRuntimeOptionsKeydown(e); + return; + } + if (ctx.state.subsyncModalOpen) { + options.handleSubsyncKeydown(e); + return; + } + if (ctx.state.kikuModalOpen) { + options.handleKikuKeydown(e); + return; + } + if (ctx.state.jimakuModalOpen) { + options.handleJimakuKeydown(e); + return; + } + if (ctx.state.sessionHelpModalOpen) { + options.handleSessionHelpKeydown(e); + return; + } + + if (ctx.state.chordPending) { + const modifierKeys = [ + 'ShiftLeft', + 'ShiftRight', + 'ControlLeft', + 'ControlRight', + 'AltLeft', + 'AltRight', + 'MetaLeft', + 'MetaRight', + ]; + if (modifierKeys.includes(e.code)) { + return; + } + + e.preventDefault(); + const secondKey = keyEventToString(e); + const action = CHORD_MAP.get(secondKey); + resetChord(); + if (action) { + if (action.type === 'mpv' && action.command) { + window.electronAPI.sendMpvCommand(action.command); + } else if (action.type === 'electron' && action.action) { + action.action(); + } + } + return; + } + + if (e.code === 'KeyY' && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !e.repeat) { + e.preventDefault(); + applySessionHelpChordBinding(); + ctx.state.chordPending = true; + ctx.state.chordTimeout = setTimeout(() => { + resetChord(); + }, CHORD_TIMEOUT_MS); + return; + } + + if ( + (e.ctrlKey || e.metaKey) && + !e.altKey && + !e.shiftKey && + e.code === 'KeyA' && + !e.repeat + ) { + e.preventDefault(); + options.appendClipboardVideoToQueue(); + return; + } + + const keyString = keyEventToString(e); + const command = ctx.state.keybindingsMap.get(keyString); + + if (command) { + e.preventDefault(); + window.electronAPI.sendMpvCommand(command); + } + }); + + document.addEventListener('mousedown', (e: MouseEvent) => { + if (e.button === 2 && !isInteractiveTarget(e.target)) { + e.preventDefault(); + window.electronAPI.sendMpvCommand(['cycle', 'pause']); + } + }); + + document.addEventListener('contextmenu', (e: Event) => { + if (!isInteractiveTarget(e.target)) { + e.preventDefault(); + } + }); + } + + function updateKeybindings(keybindings: Keybinding[]): void { + ctx.state.keybindingsMap = new Map(); + for (const binding of keybindings) { + if (binding.command) { + ctx.state.keybindingsMap.set(binding.key, binding.command); + } + } + } + + return { + setupMpvInputForwarding, + updateKeybindings, + }; +} diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts new file mode 100644 index 0000000..b5095a1 --- /dev/null +++ b/src/renderer/handlers/mouse.ts @@ -0,0 +1,328 @@ +import type { ModalStateReader, RendererContext } from '../context'; + +export function createMouseHandlers( + ctx: RendererContext, + options: { + modalStateReader: ModalStateReader; + applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: any, source: string) => void; + applyYPercent: (yPercent: number) => void; + getCurrentYPercent: () => number; + persistSubtitlePositionPatch: (patch: { yPercent: number }) => void; + reportHoveredTokenIndex: (tokenIndex: number | null) => void; + }, +) { + const wordSegmenter = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? new Intl.Segmenter(undefined, { granularity: 'word' }) + : null; + + function handleMouseEnter(): void { + ctx.state.isOverSubtitle = true; + ctx.dom.overlay.classList.add('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(false); + } + } + + function handleMouseLeave(): void { + ctx.state.isOverSubtitle = false; + const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); + if ( + !yomitanPopup && + !options.modalStateReader.isAnyModalOpen() && + !ctx.state.invisiblePositionEditMode + ) { + ctx.dom.overlay.classList.remove('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } + } + } + + function setupDragging(): void { + ctx.dom.subtitleContainer.addEventListener('mousedown', (e: MouseEvent) => { + if (e.button === 2) { + e.preventDefault(); + ctx.state.isDragging = true; + ctx.state.dragStartY = e.clientY; + ctx.state.startYPercent = options.getCurrentYPercent(); + ctx.dom.subtitleContainer.style.cursor = 'grabbing'; + } + }); + + document.addEventListener('mousemove', (e: MouseEvent) => { + if (!ctx.state.isDragging) return; + + const deltaY = ctx.state.dragStartY - e.clientY; + const deltaPercent = (deltaY / window.innerHeight) * 100; + const newYPercent = ctx.state.startYPercent + deltaPercent; + + options.applyYPercent(newYPercent); + }); + + document.addEventListener('mouseup', (e: MouseEvent) => { + if (ctx.state.isDragging && e.button === 2) { + ctx.state.isDragging = false; + ctx.dom.subtitleContainer.style.cursor = ''; + + const yPercent = options.getCurrentYPercent(); + options.persistSubtitlePositionPatch({ yPercent }); + } + }); + + ctx.dom.subtitleContainer.addEventListener('contextmenu', (e: Event) => { + e.preventDefault(); + }); + } + + function getCaretTextPointRange(clientX: number, clientY: number): Range | null { + const documentWithCaretApi = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretPositionFromPoint?: ( + x: number, + y: number, + ) => { offsetNode: Node; offset: number } | null; + }; + + if (typeof documentWithCaretApi.caretRangeFromPoint === 'function') { + return documentWithCaretApi.caretRangeFromPoint(clientX, clientY); + } + + if (typeof documentWithCaretApi.caretPositionFromPoint === 'function') { + const caretPosition = documentWithCaretApi.caretPositionFromPoint(clientX, clientY); + if (!caretPosition) return null; + const range = document.createRange(); + range.setStart(caretPosition.offsetNode, caretPosition.offset); + range.collapse(true); + return range; + } + + return null; + } + + function getWordBoundsAtOffset( + text: string, + offset: number, + ): { start: number; end: number } | null { + if (!text || text.length === 0) return null; + + const clampedOffset = Math.max(0, Math.min(offset, text.length)); + const probeIndex = clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset; + + if (wordSegmenter) { + for (const part of wordSegmenter.segment(text)) { + const start = part.index; + const end = start + part.segment.length; + if (probeIndex >= start && probeIndex < end) { + if (part.isWordLike === false) return null; + return { start, end }; + } + } + } + + const isBoundary = (char: string): boolean => + /[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(char); + + const probeChar = text[probeIndex]; + if (!probeChar || isBoundary(probeChar)) return null; + + let start = probeIndex; + while (start > 0 && !isBoundary(text[start - 1] ?? '')) { + start -= 1; + } + + let end = probeIndex + 1; + while (end < text.length && !isBoundary(text[end] ?? '')) { + end += 1; + } + + if (end <= start) return null; + return { start, end }; + } + + function updateHoverWordSelection(event: MouseEvent): void { + if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return; + if (event.buttons !== 0) return; + if (!(event.target instanceof Node)) return; + if (!ctx.dom.subtitleRoot.contains(event.target)) return; + + const caretRange = getCaretTextPointRange(event.clientX, event.clientY); + if (!caretRange) return; + if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) return; + if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return; + + const textNode = caretRange.startContainer as Text; + const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset); + if (!wordBounds) return; + + const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice( + wordBounds.start, + wordBounds.end, + )}`; + if ( + selectionKey === ctx.state.lastHoverSelectionKey && + textNode === ctx.state.lastHoverSelectionNode + ) { + return; + } + + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.setStart(textNode, wordBounds.start); + range.setEnd(textNode, wordBounds.end); + + selection.removeAllRanges(); + selection.addRange(range); + ctx.state.lastHoverSelectionKey = selectionKey; + ctx.state.lastHoverSelectionNode = textNode; + } + + function setupInvisibleHoverSelection(): void { + if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return; + + ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => { + updateHoverWordSelection(event); + }); + + ctx.dom.subtitleRoot.addEventListener('mouseleave', () => { + ctx.state.lastHoverSelectionKey = ''; + ctx.state.lastHoverSelectionNode = null; + }); + } + + function setupInvisibleTokenHoverReporter(): void { + if (!ctx.platform.isInvisibleLayer) return; + + let pendingNullHoverTimer: ReturnType | null = null; + const clearPendingNullHoverTimer = (): void => { + if (pendingNullHoverTimer !== null) { + clearTimeout(pendingNullHoverTimer); + pendingNullHoverTimer = null; + } + }; + + const reportHoveredToken = (tokenIndex: number | null): void => { + if (ctx.state.lastHoveredTokenIndex === tokenIndex) return; + ctx.state.lastHoveredTokenIndex = tokenIndex; + options.reportHoveredTokenIndex(tokenIndex); + }; + + const queueNullHoveredToken = (): void => { + if (pendingNullHoverTimer !== null) return; + pendingNullHoverTimer = setTimeout(() => { + pendingNullHoverTimer = null; + reportHoveredToken(null); + }, 120); + }; + + ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => { + if (!(event.target instanceof Element)) { + queueNullHoveredToken(); + return; + } + const target = event.target.closest('.word[data-token-index]'); + if (!target || !ctx.dom.subtitleRoot.contains(target)) { + queueNullHoveredToken(); + return; + } + const rawTokenIndex = target.dataset.tokenIndex; + const tokenIndex = rawTokenIndex ? Number.parseInt(rawTokenIndex, 10) : Number.NaN; + if (!Number.isInteger(tokenIndex) || tokenIndex < 0) { + queueNullHoveredToken(); + return; + } + clearPendingNullHoverTimer(); + reportHoveredToken(tokenIndex); + }); + + ctx.dom.subtitleRoot.addEventListener('mouseleave', () => { + clearPendingNullHoverTimer(); + reportHoveredToken(null); + }); + } + + function setupResizeHandler(): void { + window.addEventListener('resize', () => { + if (ctx.platform.isInvisibleLayer) { + if (!ctx.state.mpvSubtitleRenderMetrics) return; + options.applyInvisibleSubtitleLayoutFromMpvMetrics( + ctx.state.mpvSubtitleRenderMetrics, + 'resize', + ); + return; + } + options.applyYPercent(options.getCurrentYPercent()); + }); + } + + function setupSelectionObserver(): void { + document.addEventListener('selectionchange', () => { + const selection = window.getSelection(); + const hasSelection = selection && selection.rangeCount > 0 && !selection.isCollapsed; + + if (hasSelection) { + ctx.dom.subtitleRoot.classList.add('has-selection'); + } else { + ctx.dom.subtitleRoot.classList.remove('has-selection'); + } + }); + } + + function setupYomitanObserver(): void { + const observer = new MutationObserver((mutations: MutationRecord[]) => { + for (const mutation of mutations) { + mutation.addedNodes.forEach((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + const element = node as Element; + if ( + element.tagName === 'IFRAME' && + element.id && + element.id.startsWith('yomitan-popup') + ) { + ctx.dom.overlay.classList.add('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(false); + } + } + }); + + mutation.removedNodes.forEach((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + const element = node as Element; + if ( + element.tagName === 'IFRAME' && + element.id && + element.id.startsWith('yomitan-popup') + ) { + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { + forward: true, + }); + } + } + } + }); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } + + return { + handleMouseEnter, + handleMouseLeave, + setupDragging, + setupInvisibleHoverSelection, + setupInvisibleTokenHoverReporter, + setupResizeHandler, + setupSelectionObserver, + setupYomitanObserver, + }; +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..df4d105 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,220 @@ + + + + + + + + SubMiner + + + + +
+ +
+
+
+
+
+
+ + + + + +
+ + + diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts new file mode 100644 index 0000000..f105eed --- /dev/null +++ b/src/renderer/modals/jimaku.ts @@ -0,0 +1,384 @@ +import type { + JimakuApiResponse, + JimakuDownloadResult, + JimakuEntry, + JimakuFileEntry, + JimakuMediaInfo, +} from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; + +export function createJimakuModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + function setJimakuStatus(message: string, isError = false): void { + ctx.dom.jimakuStatus.textContent = message; + ctx.dom.jimakuStatus.style.color = isError + ? 'rgba(255, 120, 120, 0.95)' + : 'rgba(255, 255, 255, 0.8)'; + } + + function resetJimakuLists(): void { + ctx.state.jimakuEntries = []; + ctx.state.jimakuFiles = []; + ctx.state.selectedEntryIndex = 0; + ctx.state.selectedFileIndex = 0; + ctx.state.currentEntryId = null; + + ctx.dom.jimakuEntriesList.innerHTML = ''; + ctx.dom.jimakuFilesList.innerHTML = ''; + ctx.dom.jimakuEntriesSection.classList.add('hidden'); + ctx.dom.jimakuFilesSection.classList.add('hidden'); + ctx.dom.jimakuBroadenButton.classList.add('hidden'); + } + + function formatEntryLabel(entry: JimakuEntry): string { + if (entry.english_name && entry.english_name !== entry.name) { + return `${entry.name} / ${entry.english_name}`; + } + return entry.name; + } + + function renderEntries(): void { + ctx.dom.jimakuEntriesList.innerHTML = ''; + if (ctx.state.jimakuEntries.length === 0) { + ctx.dom.jimakuEntriesSection.classList.add('hidden'); + return; + } + + ctx.dom.jimakuEntriesSection.classList.remove('hidden'); + ctx.state.jimakuEntries.forEach((entry, index) => { + const li = document.createElement('li'); + li.textContent = formatEntryLabel(entry); + + if (entry.japanese_name) { + const sub = document.createElement('div'); + sub.className = 'jimaku-subtext'; + sub.textContent = entry.japanese_name; + li.appendChild(sub); + } + + if (index === ctx.state.selectedEntryIndex) { + li.classList.add('active'); + } + + li.addEventListener('click', () => { + selectEntry(index); + }); + + ctx.dom.jimakuEntriesList.appendChild(li); + }); + } + + function formatBytes(size: number): string { + if (!Number.isFinite(size)) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let value = size; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx += 1; + } + return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`; + } + + function renderFiles(): void { + ctx.dom.jimakuFilesList.innerHTML = ''; + if (ctx.state.jimakuFiles.length === 0) { + ctx.dom.jimakuFilesSection.classList.add('hidden'); + return; + } + + ctx.dom.jimakuFilesSection.classList.remove('hidden'); + ctx.state.jimakuFiles.forEach((file, index) => { + const li = document.createElement('li'); + li.textContent = file.name; + + const sub = document.createElement('div'); + sub.className = 'jimaku-subtext'; + sub.textContent = `${formatBytes(file.size)} • ${file.last_modified}`; + li.appendChild(sub); + + if (index === ctx.state.selectedFileIndex) { + li.classList.add('active'); + } + + li.addEventListener('click', () => { + void selectFile(index); + }); + + ctx.dom.jimakuFilesList.appendChild(li); + }); + } + + function getSearchQuery(): { query: string; episode: number | null } { + const title = ctx.dom.jimakuTitleInput.value.trim(); + const episode = ctx.dom.jimakuEpisodeInput.value + ? Number.parseInt(ctx.dom.jimakuEpisodeInput.value, 10) + : null; + return { query: title, episode: Number.isFinite(episode) ? episode : null }; + } + + async function performJimakuSearch(): Promise { + const { query, episode } = getSearchQuery(); + if (!query) { + setJimakuStatus('Enter a title before searching.', true); + return; + } + + resetJimakuLists(); + setJimakuStatus('Searching Jimaku...'); + ctx.state.currentEpisodeFilter = episode; + + const response: JimakuApiResponse = await window.electronAPI.jimakuSearchEntries( + { query }, + ); + if (!response.ok) { + const retry = response.error.retryAfter + ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` + : ''; + setJimakuStatus(`${response.error.error}${retry}`, true); + return; + } + + ctx.state.jimakuEntries = response.data; + ctx.state.selectedEntryIndex = 0; + + if (ctx.state.jimakuEntries.length === 0) { + setJimakuStatus('No entries found.'); + return; + } + + setJimakuStatus('Select an entry.'); + renderEntries(); + if (ctx.state.jimakuEntries.length === 1) { + void selectEntry(0); + } + } + + async function loadFiles(entryId: number, episode: number | null): Promise { + setJimakuStatus('Loading files...'); + ctx.state.jimakuFiles = []; + ctx.state.selectedFileIndex = 0; + + ctx.dom.jimakuFilesList.innerHTML = ''; + ctx.dom.jimakuFilesSection.classList.add('hidden'); + + const response: JimakuApiResponse = await window.electronAPI.jimakuListFiles( + { + entryId, + episode, + }, + ); + if (!response.ok) { + const retry = response.error.retryAfter + ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` + : ''; + setJimakuStatus(`${response.error.error}${retry}`, true); + return; + } + + ctx.state.jimakuFiles = response.data; + if (ctx.state.jimakuFiles.length === 0) { + if (episode !== null) { + setJimakuStatus('No files found for this episode.'); + ctx.dom.jimakuBroadenButton.classList.remove('hidden'); + } else { + setJimakuStatus('No files found.'); + } + return; + } + + ctx.dom.jimakuBroadenButton.classList.add('hidden'); + setJimakuStatus('Select a subtitle file.'); + renderFiles(); + if (ctx.state.jimakuFiles.length === 1) { + await selectFile(0); + } + } + + function selectEntry(index: number): void { + if (index < 0 || index >= ctx.state.jimakuEntries.length) return; + + ctx.state.selectedEntryIndex = index; + ctx.state.currentEntryId = ctx.state.jimakuEntries[index]!.id; + renderEntries(); + + if (ctx.state.currentEntryId !== null) { + void loadFiles(ctx.state.currentEntryId, ctx.state.currentEpisodeFilter); + } + } + + async function selectFile(index: number): Promise { + if (index < 0 || index >= ctx.state.jimakuFiles.length) return; + + ctx.state.selectedFileIndex = index; + renderFiles(); + + if (ctx.state.currentEntryId === null) { + setJimakuStatus('Select an entry first.', true); + return; + } + + const file = ctx.state.jimakuFiles[index]!; + setJimakuStatus('Downloading subtitle...'); + + const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({ + entryId: ctx.state.currentEntryId, + url: file.url, + name: file.name, + }); + + if (result.ok) { + setJimakuStatus(`Downloaded and loaded: ${result.path}`); + return; + } + + const retry = result.error.retryAfter + ? ` Retry after ${result.error.retryAfter.toFixed(1)}s.` + : ''; + setJimakuStatus(`${result.error.error}${retry}`, true); + } + + function isTextInputFocused(): boolean { + const active = document.activeElement; + if (!active) return false; + const tag = active.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea'; + } + + function openJimakuModal(): void { + if (ctx.platform.isInvisibleLayer) return; + if (ctx.state.jimakuModalOpen) return; + + ctx.state.jimakuModalOpen = true; + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.jimakuModal.classList.remove('hidden'); + ctx.dom.jimakuModal.setAttribute('aria-hidden', 'false'); + + setJimakuStatus('Loading media info...'); + resetJimakuLists(); + + window.electronAPI + .getJimakuMediaInfo() + .then((info: JimakuMediaInfo) => { + ctx.dom.jimakuTitleInput.value = info.title || ''; + ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : ''; + ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : ''; + ctx.state.currentEpisodeFilter = info.episode ?? null; + + if (info.confidence === 'high' && info.title && info.episode) { + void performJimakuSearch(); + } else if (info.title) { + setJimakuStatus('Check title/season/episode and press Search.'); + } else { + setJimakuStatus('Enter title/season/episode and press Search.'); + } + }) + .catch(() => { + setJimakuStatus('Failed to load media info.', true); + }); + } + + function closeJimakuModal(): void { + if (!ctx.state.jimakuModalOpen) return; + + ctx.state.jimakuModalOpen = false; + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.jimakuModal.classList.add('hidden'); + ctx.dom.jimakuModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('jimaku'); + + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + + resetJimakuLists(); + } + + function handleJimakuKeydown(e: KeyboardEvent): boolean { + if (e.key === 'Escape') { + e.preventDefault(); + closeJimakuModal(); + return true; + } + + if (isTextInputFocused()) { + if (e.key === 'Enter') { + e.preventDefault(); + void performJimakuSearch(); + } + return true; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (ctx.state.jimakuFiles.length > 0) { + ctx.state.selectedFileIndex = Math.min( + ctx.state.jimakuFiles.length - 1, + ctx.state.selectedFileIndex + 1, + ); + renderFiles(); + } else if (ctx.state.jimakuEntries.length > 0) { + ctx.state.selectedEntryIndex = Math.min( + ctx.state.jimakuEntries.length - 1, + ctx.state.selectedEntryIndex + 1, + ); + renderEntries(); + } + return true; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (ctx.state.jimakuFiles.length > 0) { + ctx.state.selectedFileIndex = Math.max(0, ctx.state.selectedFileIndex - 1); + renderFiles(); + } else if (ctx.state.jimakuEntries.length > 0) { + ctx.state.selectedEntryIndex = Math.max(0, ctx.state.selectedEntryIndex - 1); + renderEntries(); + } + return true; + } + + if (e.key === 'Enter') { + e.preventDefault(); + if (ctx.state.jimakuFiles.length > 0) { + void selectFile(ctx.state.selectedFileIndex); + } else if (ctx.state.jimakuEntries.length > 0) { + selectEntry(ctx.state.selectedEntryIndex); + } else { + void performJimakuSearch(); + } + return true; + } + + return true; + } + + function wireDomEvents(): void { + ctx.dom.jimakuSearchButton.addEventListener('click', () => { + void performJimakuSearch(); + }); + ctx.dom.jimakuCloseButton.addEventListener('click', () => { + closeJimakuModal(); + }); + ctx.dom.jimakuBroadenButton.addEventListener('click', () => { + if (ctx.state.currentEntryId !== null) { + ctx.dom.jimakuBroadenButton.classList.add('hidden'); + void loadFiles(ctx.state.currentEntryId, null); + } + }); + } + + return { + closeJimakuModal, + handleJimakuKeydown, + openJimakuModal, + wireDomEvents, + }; +} diff --git a/src/renderer/modals/kiku.ts b/src/renderer/modals/kiku.ts new file mode 100644 index 0000000..11c84df --- /dev/null +++ b/src/renderer/modals/kiku.ts @@ -0,0 +1,299 @@ +import type { + KikuDuplicateCardInfo, + KikuFieldGroupingChoice, + KikuMergePreviewResponse, +} from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; + +export function createKikuModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + function formatMediaMeta(card: KikuDuplicateCardInfo): string { + const parts: string[] = []; + parts.push(card.hasAudio ? 'Audio: Yes' : 'Audio: No'); + parts.push(card.hasImage ? 'Image: Yes' : 'Image: No'); + return parts.join(' | '); + } + + function updateKikuCardSelection(): void { + ctx.dom.kikuCard1.classList.toggle('active', ctx.state.kikuSelectedCard === 1); + ctx.dom.kikuCard2.classList.toggle('active', ctx.state.kikuSelectedCard === 2); + } + + function setKikuModalStep(step: 'select' | 'preview'): void { + ctx.state.kikuModalStep = step; + const isSelect = step === 'select'; + ctx.dom.kikuSelectionStep.classList.toggle('hidden', !isSelect); + ctx.dom.kikuPreviewStep.classList.toggle('hidden', isSelect); + ctx.dom.kikuHint.textContent = isSelect + ? 'Press 1 or 2 to select · Enter to continue · Esc to cancel' + : 'Enter to confirm merge · Backspace to go back · Esc to cancel'; + } + + function updateKikuPreviewToggle(): void { + ctx.dom.kikuPreviewCompactButton.classList.toggle( + 'active', + ctx.state.kikuPreviewMode === 'compact', + ); + ctx.dom.kikuPreviewFullButton.classList.toggle('active', ctx.state.kikuPreviewMode === 'full'); + } + + function renderKikuPreview(): void { + const payload = + ctx.state.kikuPreviewMode === 'compact' + ? ctx.state.kikuPreviewCompactData + : ctx.state.kikuPreviewFullData; + ctx.dom.kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : '{}'; + updateKikuPreviewToggle(); + } + + function setKikuPreviewError(message: string | null): void { + if (!message) { + ctx.dom.kikuPreviewError.textContent = ''; + ctx.dom.kikuPreviewError.classList.add('hidden'); + return; + } + + ctx.dom.kikuPreviewError.textContent = message; + ctx.dom.kikuPreviewError.classList.remove('hidden'); + } + + function openKikuFieldGroupingModal(data: { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; + }): void { + if (ctx.platform.isInvisibleLayer) return; + if (ctx.state.kikuModalOpen) return; + + ctx.state.kikuModalOpen = true; + ctx.state.kikuOriginalData = data.original; + ctx.state.kikuDuplicateData = data.duplicate; + ctx.state.kikuSelectedCard = 1; + + ctx.dom.kikuCard1Expression.textContent = data.original.expression; + ctx.dom.kikuCard1Sentence.textContent = data.original.sentencePreview || '(no sentence)'; + ctx.dom.kikuCard1Meta.textContent = formatMediaMeta(data.original); + + ctx.dom.kikuCard2Expression.textContent = data.duplicate.expression; + ctx.dom.kikuCard2Sentence.textContent = data.duplicate.sentencePreview || '(current subtitle)'; + ctx.dom.kikuCard2Meta.textContent = formatMediaMeta(data.duplicate); + + ctx.dom.kikuDeleteDuplicateCheckbox.checked = true; + ctx.state.kikuPendingChoice = null; + ctx.state.kikuPreviewCompactData = null; + ctx.state.kikuPreviewFullData = null; + ctx.state.kikuPreviewMode = 'compact'; + + renderKikuPreview(); + setKikuPreviewError(null); + setKikuModalStep('select'); + updateKikuCardSelection(); + + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.kikuModal.classList.remove('hidden'); + ctx.dom.kikuModal.setAttribute('aria-hidden', 'false'); + } + + function closeKikuFieldGroupingModal(): void { + if (!ctx.state.kikuModalOpen) return; + + ctx.state.kikuModalOpen = false; + options.syncSettingsModalSubtitleSuppression(); + + ctx.dom.kikuModal.classList.add('hidden'); + ctx.dom.kikuModal.setAttribute('aria-hidden', 'true'); + + setKikuPreviewError(null); + ctx.dom.kikuPreviewJson.textContent = ''; + + ctx.state.kikuPendingChoice = null; + ctx.state.kikuPreviewCompactData = null; + ctx.state.kikuPreviewFullData = null; + ctx.state.kikuPreviewMode = 'compact'; + setKikuModalStep('select'); + ctx.state.kikuOriginalData = null; + ctx.state.kikuDuplicateData = null; + + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + } + + async function confirmKikuSelection(): Promise { + if (!ctx.state.kikuOriginalData || !ctx.state.kikuDuplicateData) return; + + const keepData = + ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuOriginalData : ctx.state.kikuDuplicateData; + const deleteData = + ctx.state.kikuSelectedCard === 1 ? ctx.state.kikuDuplicateData : ctx.state.kikuOriginalData; + + const choice: KikuFieldGroupingChoice = { + keepNoteId: keepData.noteId, + deleteNoteId: deleteData.noteId, + deleteDuplicate: ctx.dom.kikuDeleteDuplicateCheckbox.checked, + cancelled: false, + }; + + ctx.state.kikuPendingChoice = choice; + setKikuPreviewError(null); + ctx.dom.kikuConfirmButton.disabled = true; + + try { + const preview: KikuMergePreviewResponse = await window.electronAPI.kikuBuildMergePreview({ + keepNoteId: choice.keepNoteId, + deleteNoteId: choice.deleteNoteId, + deleteDuplicate: choice.deleteDuplicate, + }); + + if (!preview.ok) { + setKikuPreviewError(preview.error || 'Failed to build merge preview'); + return; + } + + ctx.state.kikuPreviewCompactData = preview.compact || {}; + ctx.state.kikuPreviewFullData = preview.full || {}; + ctx.state.kikuPreviewMode = 'compact'; + renderKikuPreview(); + setKikuModalStep('preview'); + } finally { + ctx.dom.kikuConfirmButton.disabled = false; + } + } + + function confirmKikuMerge(): void { + if (!ctx.state.kikuPendingChoice) return; + window.electronAPI.kikuFieldGroupingRespond(ctx.state.kikuPendingChoice); + closeKikuFieldGroupingModal(); + } + + function goBackFromKikuPreview(): void { + setKikuPreviewError(null); + setKikuModalStep('select'); + } + + function cancelKikuFieldGrouping(): void { + const choice: KikuFieldGroupingChoice = { + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }; + + window.electronAPI.kikuFieldGroupingRespond(choice); + closeKikuFieldGroupingModal(); + } + + function handleKikuKeydown(e: KeyboardEvent): boolean { + if (ctx.state.kikuModalStep === 'preview') { + if (e.key === 'Escape') { + e.preventDefault(); + cancelKikuFieldGrouping(); + return true; + } + if (e.key === 'Backspace') { + e.preventDefault(); + goBackFromKikuPreview(); + return true; + } + if (e.key === 'Enter') { + e.preventDefault(); + confirmKikuMerge(); + return true; + } + return true; + } + + if (e.key === 'Escape') { + e.preventDefault(); + cancelKikuFieldGrouping(); + return true; + } + + if (e.key === '1') { + e.preventDefault(); + ctx.state.kikuSelectedCard = 1; + updateKikuCardSelection(); + return true; + } + + if (e.key === '2') { + e.preventDefault(); + ctx.state.kikuSelectedCard = 2; + updateKikuCardSelection(); + return true; + } + + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + ctx.state.kikuSelectedCard = ctx.state.kikuSelectedCard === 1 ? 2 : 1; + updateKikuCardSelection(); + return true; + } + + if (e.key === 'Enter') { + e.preventDefault(); + void confirmKikuSelection(); + return true; + } + + return true; + } + + function wireDomEvents(): void { + ctx.dom.kikuCard1.addEventListener('click', () => { + ctx.state.kikuSelectedCard = 1; + updateKikuCardSelection(); + }); + ctx.dom.kikuCard1.addEventListener('dblclick', () => { + ctx.state.kikuSelectedCard = 1; + void confirmKikuSelection(); + }); + + ctx.dom.kikuCard2.addEventListener('click', () => { + ctx.state.kikuSelectedCard = 2; + updateKikuCardSelection(); + }); + ctx.dom.kikuCard2.addEventListener('dblclick', () => { + ctx.state.kikuSelectedCard = 2; + void confirmKikuSelection(); + }); + + ctx.dom.kikuConfirmButton.addEventListener('click', () => { + void confirmKikuSelection(); + }); + ctx.dom.kikuCancelButton.addEventListener('click', () => { + cancelKikuFieldGrouping(); + }); + ctx.dom.kikuBackButton.addEventListener('click', () => { + goBackFromKikuPreview(); + }); + ctx.dom.kikuFinalConfirmButton.addEventListener('click', () => { + confirmKikuMerge(); + }); + ctx.dom.kikuFinalCancelButton.addEventListener('click', () => { + cancelKikuFieldGrouping(); + }); + + ctx.dom.kikuPreviewCompactButton.addEventListener('click', () => { + ctx.state.kikuPreviewMode = 'compact'; + renderKikuPreview(); + }); + ctx.dom.kikuPreviewFullButton.addEventListener('click', () => { + ctx.state.kikuPreviewMode = 'full'; + renderKikuPreview(); + }); + } + + return { + cancelKikuFieldGrouping, + closeKikuFieldGroupingModal, + handleKikuKeydown, + openKikuFieldGroupingModal, + wireDomEvents, + }; +} diff --git a/src/renderer/modals/runtime-options.ts b/src/renderer/modals/runtime-options.ts new file mode 100644 index 0000000..b069674 --- /dev/null +++ b/src/renderer/modals/runtime-options.ts @@ -0,0 +1,258 @@ +import type { RuntimeOptionApplyResult, RuntimeOptionState, RuntimeOptionValue } from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; + +export function createRuntimeOptionsModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + function formatRuntimeOptionValue(value: RuntimeOptionValue): string { + if (typeof value === 'boolean') { + return value ? 'On' : 'Off'; + } + return value; + } + + function setRuntimeOptionsStatus(message: string, isError = false): void { + ctx.dom.runtimeOptionsStatus.textContent = message; + ctx.dom.runtimeOptionsStatus.classList.toggle('error', isError); + } + + function getRuntimeOptionDisplayValue(option: RuntimeOptionState): RuntimeOptionValue { + return ctx.state.runtimeOptionDraftValues.get(option.id) ?? option.value; + } + + function getSelectedRuntimeOption(): RuntimeOptionState | null { + if (ctx.state.runtimeOptions.length === 0) return null; + if (ctx.state.runtimeOptionSelectedIndex < 0) return null; + if (ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length) { + return null; + } + return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex] ?? null; + } + + function renderRuntimeOptionsList(): void { + ctx.dom.runtimeOptionsList.innerHTML = ''; + ctx.state.runtimeOptions.forEach((option, index) => { + const li = document.createElement('li'); + li.className = 'runtime-options-item'; + li.classList.toggle('active', index === ctx.state.runtimeOptionSelectedIndex); + + const label = document.createElement('div'); + label.className = 'runtime-options-label'; + label.textContent = option.label; + + const value = document.createElement('div'); + value.className = 'runtime-options-value'; + value.textContent = `Value: ${formatRuntimeOptionValue(getRuntimeOptionDisplayValue(option))}`; + value.title = 'Click to cycle value, right-click to cycle backward'; + + const allowed = document.createElement('div'); + allowed.className = 'runtime-options-allowed'; + allowed.textContent = `Allowed: ${option.allowedValues + .map((entry) => formatRuntimeOptionValue(entry)) + .join(' | ')}`; + + li.appendChild(label); + li.appendChild(value); + li.appendChild(allowed); + + li.addEventListener('click', () => { + ctx.state.runtimeOptionSelectedIndex = index; + renderRuntimeOptionsList(); + }); + li.addEventListener('dblclick', () => { + ctx.state.runtimeOptionSelectedIndex = index; + void applySelectedRuntimeOption(); + }); + + value.addEventListener('click', (event) => { + event.stopPropagation(); + ctx.state.runtimeOptionSelectedIndex = index; + cycleRuntimeDraftValue(1); + }); + value.addEventListener('contextmenu', (event) => { + event.preventDefault(); + event.stopPropagation(); + ctx.state.runtimeOptionSelectedIndex = index; + cycleRuntimeDraftValue(-1); + }); + + ctx.dom.runtimeOptionsList.appendChild(li); + }); + } + + function updateRuntimeOptions(optionsList: RuntimeOptionState[]): void { + const previousId = + ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex]?.id ?? + ctx.state.runtimeOptions[0]?.id; + + ctx.state.runtimeOptions = optionsList; + ctx.state.runtimeOptionDraftValues.clear(); + + for (const option of ctx.state.runtimeOptions) { + ctx.state.runtimeOptionDraftValues.set(option.id, option.value); + } + + const nextIndex = ctx.state.runtimeOptions.findIndex((option) => option.id === previousId); + ctx.state.runtimeOptionSelectedIndex = nextIndex >= 0 ? nextIndex : 0; + + renderRuntimeOptionsList(); + } + + function cycleRuntimeDraftValue(direction: 1 | -1): void { + const option = getSelectedRuntimeOption(); + if (!option || option.allowedValues.length === 0) return; + + const currentValue = getRuntimeOptionDisplayValue(option); + const currentIndex = option.allowedValues.findIndex((value) => value === currentValue); + const safeIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = + direction === 1 + ? (safeIndex + 1) % option.allowedValues.length + : (safeIndex - 1 + option.allowedValues.length) % option.allowedValues.length; + + const nextValue = option.allowedValues[nextIndex]; + if (nextValue === undefined) return; + ctx.state.runtimeOptionDraftValues.set(option.id, nextValue); + renderRuntimeOptionsList(); + setRuntimeOptionsStatus(`Selected ${option.label}: ${formatRuntimeOptionValue(nextValue)}`); + } + + async function applySelectedRuntimeOption(): Promise { + const option = getSelectedRuntimeOption(); + if (!option) return; + + const nextValue = getRuntimeOptionDisplayValue(option); + const result: RuntimeOptionApplyResult = await window.electronAPI.setRuntimeOptionValue( + option.id, + nextValue, + ); + if (!result.ok) { + setRuntimeOptionsStatus(result.error || 'Failed to apply option', true); + return; + } + + if (result.option) { + ctx.state.runtimeOptionDraftValues.set(result.option.id, result.option.value); + } + + const latest = await window.electronAPI.getRuntimeOptions(); + updateRuntimeOptions(latest); + setRuntimeOptionsStatus(result.osdMessage || 'Option applied.'); + } + + function closeRuntimeOptionsModal(): void { + if (!ctx.state.runtimeOptionsModalOpen) return; + + ctx.state.runtimeOptionsModalOpen = false; + options.syncSettingsModalSubtitleSuppression(); + + ctx.dom.runtimeOptionsModal.classList.add('hidden'); + ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('runtime-options'); + + setRuntimeOptionsStatus(''); + + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + } + + async function openRuntimeOptionsModal(): Promise { + if (ctx.platform.isInvisibleLayer) return; + + const optionsList = await window.electronAPI.getRuntimeOptions(); + updateRuntimeOptions(optionsList); + + ctx.state.runtimeOptionsModalOpen = true; + options.syncSettingsModalSubtitleSuppression(); + + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.runtimeOptionsModal.classList.remove('hidden'); + ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false'); + + setRuntimeOptionsStatus( + 'Use arrow keys. Click value to cycle. Enter or double-click to apply.', + ); + } + + function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean { + if (e.key === 'Escape') { + e.preventDefault(); + closeRuntimeOptionsModal(); + return true; + } + + if ( + e.key === 'ArrowDown' || + e.key === 'j' || + e.key === 'J' || + (e.ctrlKey && (e.key === 'n' || e.key === 'N')) + ) { + e.preventDefault(); + if (ctx.state.runtimeOptions.length > 0) { + ctx.state.runtimeOptionSelectedIndex = Math.min( + ctx.state.runtimeOptions.length - 1, + ctx.state.runtimeOptionSelectedIndex + 1, + ); + renderRuntimeOptionsList(); + } + return true; + } + + if ( + e.key === 'ArrowUp' || + e.key === 'k' || + e.key === 'K' || + (e.ctrlKey && (e.key === 'p' || e.key === 'P')) + ) { + e.preventDefault(); + if (ctx.state.runtimeOptions.length > 0) { + ctx.state.runtimeOptionSelectedIndex = Math.max( + 0, + ctx.state.runtimeOptionSelectedIndex - 1, + ); + renderRuntimeOptionsList(); + } + return true; + } + + if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') { + e.preventDefault(); + cycleRuntimeDraftValue(1); + return true; + } + + if (e.key === 'ArrowLeft' || e.key === 'h' || e.key === 'H') { + e.preventDefault(); + cycleRuntimeDraftValue(-1); + return true; + } + + if (e.key === 'Enter') { + e.preventDefault(); + void applySelectedRuntimeOption(); + return true; + } + + return true; + } + + function wireDomEvents(): void { + ctx.dom.runtimeOptionsClose.addEventListener('click', () => { + closeRuntimeOptionsModal(); + }); + } + + return { + closeRuntimeOptionsModal, + handleRuntimeOptionsKeydown, + openRuntimeOptionsModal, + setRuntimeOptionsStatus, + updateRuntimeOptions, + wireDomEvents, + }; +} diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts new file mode 100644 index 0000000..3df690a --- /dev/null +++ b/src/renderer/modals/session-help.ts @@ -0,0 +1,759 @@ +import type { Keybinding } from '../../types'; +import type { ShortcutsConfig } from '../../types'; +import { SPECIAL_COMMANDS } from '../../config/definitions'; +import type { ModalStateReader, RendererContext } from '../context'; + +type SessionHelpBindingInfo = { + bindingKey: 'KeyH' | 'KeyK'; + fallbackUsed: boolean; + fallbackUnavailable: boolean; +}; + +type SessionHelpItem = { + shortcut: string; + action: string; + color?: string; +}; + +type SessionHelpSection = { + title: string; + rows: SessionHelpItem[]; +}; +type RuntimeShortcutConfig = Omit, 'multiCopyTimeoutMs'>; + +const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +// Fallbacks mirror the session overlay's default subtitle/word color scheme. +const FALLBACK_COLORS = { + knownWordColor: '#a6da95', + nPlusOneColor: '#c6a0f6', + jlptN1Color: '#ed8796', + jlptN2Color: '#f5a97f', + jlptN3Color: '#f9e2af', + jlptN4Color: '#a6e3a1', + jlptN5Color: '#8aadf4', +}; + +const KEY_NAME_MAP: Record = { + Space: 'Space', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', + Escape: 'Esc', + Tab: 'Tab', + Enter: 'Enter', + CommandOrControl: 'Cmd/Ctrl', + Ctrl: 'Ctrl', + Control: 'Ctrl', + Command: 'Cmd', + Cmd: 'Cmd', + Shift: 'Shift', + Alt: 'Alt', + Super: 'Meta', + Meta: 'Meta', + Backspace: 'Backspace', +}; + +function normalizeColor(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const next = value.trim(); + return HEX_COLOR_RE.test(next) ? next : fallback; +} + +function normalizeKeyToken(token: string): string { + if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token]; + if (token.startsWith('Key')) return token.slice(3); + if (token.startsWith('Digit')) return token.slice(5); + if (token.startsWith('Numpad')) return token.slice(6); + return token; +} + +function formatKeybinding(rawBinding: string): string { + const parts = rawBinding.split('+'); + const key = parts.pop(); + if (!key) return rawBinding; + const normalized = [...parts, normalizeKeyToken(key)]; + return normalized.join(' + '); +} + +const OVERLAY_SHORTCUTS: Array<{ + key: keyof RuntimeShortcutConfig; + label: string; +}> = [ + { key: 'copySubtitle', label: 'Copy subtitle' }, + { key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' }, + { + key: 'updateLastCardFromClipboard', + label: 'Update last card from clipboard', + }, + { key: 'triggerFieldGrouping', label: 'Trigger field grouping' }, + { key: 'triggerSubsync', label: 'Open subtitle sync controls' }, + { key: 'mineSentence', label: 'Mine sentence' }, + { key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' }, + { key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' }, + { key: 'markAudioCard', label: 'Mark audio card' }, + { key: 'openRuntimeOptions', label: 'Open runtime options' }, + { key: 'openJimaku', label: 'Open jimaku' }, + { key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' }, + { key: 'toggleInvisibleOverlayGlobal', label: 'Show/hide invisible overlay' }, +]; + +function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] { + const rows: SessionHelpItem[] = []; + + for (const shortcut of OVERLAY_SHORTCUTS) { + const keybind = shortcuts[shortcut.key]; + if (typeof keybind !== 'string') continue; + if (keybind.trim().length === 0) continue; + + rows.push({ + shortcut: formatKeybinding(keybind), + action: shortcut.label, + }); + } + + if (rows.length === 0) return []; + return [{ title: 'Overlay shortcuts', rows }]; +} + +function describeCommand(command: (string | number)[]): string { + const first = command[0]; + if (typeof first !== 'string') return 'Unknown action'; + + if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback'; + if (first === 'seek' && typeof command[1] === 'number') { + return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`; + } + if (first === 'sub-seek' && typeof command[1] === 'number') { + return `Shift subtitle by ${command[1]} ms`; + } + if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; + if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; + if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; + if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; + if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { + const [, rawId, rawDirection] = first.split(':'); + return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`; + } + + return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`; +} + +function sectionForCommand(command: (string | number)[]): string { + const first = command[0]; + if (typeof first !== 'string') return 'Other shortcuts'; + + if ( + first === 'cycle' || + first === 'seek' || + first === 'sub-seek' || + first === SPECIAL_COMMANDS.REPLAY_SUBTITLE || + first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE + ) { + return 'Playback and navigation'; + } + + if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) { + return 'Visual feedback'; + } + + if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) { + return 'Subtitle sync'; + } + + if ( + first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || + first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) + ) { + return 'Runtime settings'; + } + + if (first === 'quit') return 'System actions'; + return 'Other shortcuts'; +} + +function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] { + const grouped = new Map(); + + for (const binding of keybindings) { + const section = sectionForCommand(binding.command ?? []); + const row: SessionHelpItem = { + shortcut: formatKeybinding(binding.key), + action: describeCommand(binding.command ?? []), + }; + grouped.set(section, [...(grouped.get(section) ?? []), row]); + } + + const sectionOrder = [ + 'Playback and navigation', + 'Visual feedback', + 'Subtitle sync', + 'Runtime settings', + 'System actions', + 'Other shortcuts', + ]; + const sectionEntries = Array.from(grouped.entries()).sort((a, b) => { + const aIdx = sectionOrder.indexOf(a[0]); + const bIdx = sectionOrder.indexOf(b[0]); + if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]); + if (aIdx === -1) return 1; + if (bIdx === -1) return -1; + return aIdx - bIdx; + }); + + return sectionEntries.map(([title, rows]) => ({ title, rows })); +} + +function buildColorSection(style: { + knownWordColor?: unknown; + nPlusOneColor?: unknown; + jlptColors?: { + N1?: unknown; + N2?: unknown; + N3?: unknown; + N4?: unknown; + N5?: unknown; + }; +}): SessionHelpSection { + return { + title: 'Color legend', + rows: [ + { + shortcut: 'Known words', + action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), + color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), + }, + { + shortcut: 'N+1 words', + action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), + color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), + }, + { + shortcut: 'JLPT N1', + action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), + color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), + }, + { + shortcut: 'JLPT N2', + action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color), + color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color), + }, + { + shortcut: 'JLPT N3', + action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color), + color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color), + }, + { + shortcut: 'JLPT N4', + action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color), + color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color), + }, + { + shortcut: 'JLPT N5', + action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color), + color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color), + }, + ], + }; +} + +function filterSections(sections: SessionHelpSection[], query: string): SessionHelpSection[] { + const normalize = (value: string): string => + value + .toLowerCase() + .replace(/commandorcontrol/gu, 'ctrl') + .replace(/cmd\/ctrl/gu, 'ctrl') + .replace(/[\s+\-_/]/gu, ''); + const normalized = normalize(query); + if (!normalized) return sections; + + return sections + .map((section) => { + if (normalize(section.title).includes(normalized)) { + return section; + } + + const rows = section.rows.filter( + (row) => + normalize(row.shortcut).includes(normalized) || + normalize(row.action).includes(normalized), + ); + if (rows.length === 0) return null; + return { ...section, rows }; + }) + .filter((section): section is SessionHelpSection => section !== null) + .filter((section) => section.rows.length > 0); +} + +function formatBindingHint(info: SessionHelpBindingInfo): string { + if (info.bindingKey === 'KeyK' && info.fallbackUsed) { + return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)'; + } + return 'Y-H'; +} + +function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'session-help-item'; + button.tabIndex = -1; + button.dataset.sessionHelpIndex = String(globalIndex); + + const left = document.createElement('div'); + left.className = 'session-help-item-left'; + const shortcut = document.createElement('span'); + shortcut.className = 'session-help-key'; + shortcut.textContent = row.shortcut; + left.appendChild(shortcut); + + const right = document.createElement('div'); + right.className = 'session-help-item-right'; + const action = document.createElement('span'); + action.className = 'session-help-action'; + action.textContent = row.action; + right.appendChild(action); + + if (row.color) { + const dot = document.createElement('span'); + dot.className = 'session-help-color-dot'; + dot.style.backgroundColor = row.color; + right.insertBefore(dot, action); + } + + button.appendChild(left); + button.appendChild(right); + return button; +} + +const SECTION_ICON: Record = { + 'MPV shortcuts': '⚙', + 'Playback and navigation': '▶', + 'Visual feedback': '◉', + 'Subtitle sync': '⟲', + 'Runtime settings': '⚙', + 'System actions': '◆', + 'Other shortcuts': '…', + 'Overlay shortcuts (configurable)': '✦', + 'Overlay shortcuts': '✦', + 'Color legend': '◈', +}; + +function createSectionNode( + section: SessionHelpSection, + sectionIndex: number, + globalIndexMap: number[], +): HTMLElement { + const sectionNode = document.createElement('section'); + sectionNode.className = 'session-help-section'; + + const title = document.createElement('h3'); + title.className = 'session-help-section-title'; + const icon = SECTION_ICON[section.title] ?? '•'; + title.textContent = `${icon} ${section.title}`; + sectionNode.appendChild(title); + + const list = document.createElement('div'); + list.className = 'session-help-item-list'; + + section.rows.forEach((row, rowIndex) => { + const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex; + const button = createShortcutRow(row, globalIndex); + list.appendChild(button); + }); + + sectionNode.appendChild(list); + return sectionNode; +} + +export function createSessionHelpModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + let priorFocus: Element | null = null; + let openBinding: SessionHelpBindingInfo = { + bindingKey: 'KeyH', + fallbackUsed: false, + fallbackUnavailable: false, + }; + let helpFilterValue = ''; + let helpSections: SessionHelpSection[] = []; + let focusGuard: ((event: FocusEvent) => void) | null = null; + let windowFocusGuard: (() => void) | null = null; + let modalPointerFocusGuard: ((event: Event) => void) | null = null; + let isRecoveringModalFocus = false; + let lastFocusRecoveryAt = 0; + + function getItems(): HTMLButtonElement[] { + return Array.from( + ctx.dom.sessionHelpContent.querySelectorAll('.session-help-item'), + ) as HTMLButtonElement[]; + } + + function setSelected(index: number): void { + const items = getItems(); + if (items.length === 0) return; + + const wrappedIndex = index % items.length; + const next = wrappedIndex < 0 ? wrappedIndex + items.length : wrappedIndex; + ctx.state.sessionHelpSelectedIndex = next; + + items.forEach((item, idx) => { + item.classList.toggle('active', idx === next); + item.tabIndex = idx === next ? 0 : -1; + }); + const activeItem = items[next]; + if (!activeItem) return; + activeItem.focus({ preventScroll: true }); + activeItem.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } + + function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean { + return target instanceof Element && ctx.dom.sessionHelpModal.contains(target); + } + + function focusFallbackTarget(): boolean { + void window.electronAPI.focusMainWindow(); + const items = getItems(); + const firstItem = items.find((item) => item.offsetParent !== null); + if (firstItem) { + firstItem.focus({ preventScroll: true }); + return document.activeElement === firstItem; + } + + if (ctx.dom.sessionHelpClose instanceof HTMLElement) { + ctx.dom.sessionHelpClose.focus({ preventScroll: true }); + return document.activeElement === ctx.dom.sessionHelpClose; + } + + window.focus(); + return false; + } + + function enforceModalFocus(): void { + if (!ctx.state.sessionHelpModalOpen) return; + if (!isSessionHelpModalFocusTarget(document.activeElement)) { + if (isRecoveringModalFocus) return; + + const now = Date.now(); + if (now - lastFocusRecoveryAt < 120) return; + + isRecoveringModalFocus = true; + lastFocusRecoveryAt = now; + focusFallbackTarget(); + + window.setTimeout(() => { + isRecoveringModalFocus = false; + }, 120); + } + } + + function isFilterInputFocused(): boolean { + return document.activeElement === ctx.dom.sessionHelpFilter; + } + + function focusFilterInput(): void { + ctx.dom.sessionHelpFilter.focus({ preventScroll: true }); + ctx.dom.sessionHelpFilter.select(); + } + + function applyFilterAndRender(): void { + const sections = filterSections(helpSections, helpFilterValue); + const indexOffsets: number[] = []; + let running = 0; + for (const section of sections) { + indexOffsets.push(running); + running += section.rows.length; + } + + ctx.dom.sessionHelpContent.innerHTML = ''; + sections.forEach((section, sectionIndex) => { + const sectionNode = createSectionNode(section, sectionIndex, indexOffsets); + ctx.dom.sessionHelpContent.appendChild(sectionNode); + }); + + if (getItems().length === 0) { + ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results'); + ctx.dom.sessionHelpContent.textContent = helpFilterValue + ? 'No matching shortcuts found.' + : 'No active session shortcuts found.'; + ctx.state.sessionHelpSelectedIndex = 0; + return; + } + + ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results'); + + if (isFilterInputFocused()) return; + + setSelected(0); + } + + function requestOverlayFocus(): void { + void window.electronAPI.focusMainWindow(); + } + + function addPointerFocusListener(): void { + if (modalPointerFocusGuard) return; + + modalPointerFocusGuard = () => { + requestOverlayFocus(); + enforceModalFocus(); + }; + ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard); + ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard); + } + + function removePointerFocusListener(): void { + if (!modalPointerFocusGuard) return; + ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard); + ctx.dom.sessionHelpModal.removeEventListener('click', modalPointerFocusGuard); + modalPointerFocusGuard = null; + } + + function startFocusRecoveryGuards(): void { + if (windowFocusGuard) return; + + windowFocusGuard = () => { + requestOverlayFocus(); + enforceModalFocus(); + }; + window.addEventListener('blur', windowFocusGuard); + window.addEventListener('focus', windowFocusGuard); + } + + function stopFocusRecoveryGuards(): void { + if (!windowFocusGuard) return; + window.removeEventListener('blur', windowFocusGuard); + window.removeEventListener('focus', windowFocusGuard); + windowFocusGuard = null; + } + + function showRenderError(message: string): void { + helpSections = []; + helpFilterValue = ''; + ctx.dom.sessionHelpFilter.value = ''; + ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results'); + ctx.dom.sessionHelpContent.textContent = message; + ctx.state.sessionHelpSelectedIndex = 0; + } + + async function render(): Promise { + try { + const [keybindings, styleConfig, shortcuts] = await Promise.all([ + window.electronAPI.getKeybindings(), + window.electronAPI.getSubtitleStyle(), + window.electronAPI.getConfiguredShortcuts(), + ]); + + const bindingSections = buildBindingSections(keybindings); + if (bindingSections.length > 0) { + const playback = bindingSections.find( + (section) => section.title === 'Playback and navigation', + ); + if (playback) { + playback.title = 'MPV shortcuts'; + } + } + + const shortcutSections = buildOverlayShortcutSections(shortcuts); + if (shortcutSections.length > 0) { + shortcutSections[0]!.title = 'Overlay shortcuts (configurable)'; + } + const colorSection = buildColorSection(styleConfig ?? {}); + helpSections = [...bindingSections, ...shortcutSections, colorSection]; + applyFilterAndRender(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to load session help data.'; + showRenderError(`Session help failed to load: ${message}`); + return false; + } + } + + async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise { + openBinding = opening; + priorFocus = document.activeElement; + + const dataLoaded = await render(); + + ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`; + if (openBinding.fallbackUnavailable) { + ctx.dom.sessionHelpWarning.textContent = + 'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.'; + } else if (openBinding.fallbackUsed) { + ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.'; + } else { + ctx.dom.sessionHelpWarning.textContent = ''; + } + if (dataLoaded) { + ctx.dom.sessionHelpStatus.textContent = + 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.'; + } else { + ctx.dom.sessionHelpStatus.textContent = + 'Session help data is unavailable right now. Press Esc to close.'; + ctx.dom.sessionHelpWarning.textContent = + 'Unable to load latest shortcut settings from the runtime.'; + } + + ctx.state.sessionHelpModalOpen = true; + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.sessionHelpModal.classList.remove('hidden'); + ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false'); + ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1'); + ctx.dom.sessionHelpFilter.value = ''; + helpFilterValue = ''; + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(false); + } + + if (focusGuard === null) { + focusGuard = (event: FocusEvent) => { + if (!ctx.state.sessionHelpModalOpen) return; + if (!isSessionHelpModalFocusTarget(event.target)) { + event.preventDefault(); + enforceModalFocus(); + } + }; + document.addEventListener('focusin', focusGuard); + } + + addPointerFocusListener(); + startFocusRecoveryGuards(); + requestOverlayFocus(); + window.focus(); + enforceModalFocus(); + } + + function closeSessionHelpModal(): void { + if (!ctx.state.sessionHelpModalOpen) return; + + ctx.state.sessionHelpModalOpen = false; + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.sessionHelpModal.classList.add('hidden'); + ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true'); + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + + if (focusGuard) { + document.removeEventListener('focusin', focusGuard); + focusGuard = null; + } + removePointerFocusListener(); + stopFocusRecoveryGuards(); + + if (priorFocus instanceof HTMLElement && priorFocus.isConnected) { + priorFocus.focus({ preventScroll: true }); + return; + } + + if (ctx.dom.overlay instanceof HTMLElement) { + // Overlay remains `tabindex="-1"` to allow programmatic focus for fallback. + ctx.dom.overlay.focus({ preventScroll: true }); + } + if (ctx.platform.shouldToggleMouseIgnore) { + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } else { + window.electronAPI.setIgnoreMouseEvents(false); + } + } + ctx.dom.sessionHelpFilter.value = ''; + helpFilterValue = ''; + window.focus(); + } + + function handleSessionHelpKeydown(e: KeyboardEvent): boolean { + if (!ctx.state.sessionHelpModalOpen) return false; + + if (isFilterInputFocused()) { + if (e.key === 'Escape') { + e.preventDefault(); + if (!helpFilterValue) { + closeSessionHelpModal(); + return true; + } + + helpFilterValue = ''; + ctx.dom.sessionHelpFilter.value = ''; + applyFilterAndRender(); + focusFallbackTarget(); + return true; + } + return false; + } + + if (e.key === 'Escape') { + e.preventDefault(); + closeSessionHelpModal(); + return true; + } + + const items = getItems(); + if (items.length === 0) return true; + + if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { + e.preventDefault(); + focusFilterInput(); + return true; + } + + const key = e.key.toLowerCase(); + + if (key === 'arrowdown' || key === 'j' || key === 'l') { + e.preventDefault(); + setSelected(ctx.state.sessionHelpSelectedIndex + 1); + return true; + } + + if (key === 'arrowup' || key === 'k' || key === 'h') { + e.preventDefault(); + setSelected(ctx.state.sessionHelpSelectedIndex - 1); + return true; + } + + return true; + } + + function wireDomEvents(): void { + ctx.dom.sessionHelpFilter.addEventListener('input', () => { + helpFilterValue = ctx.dom.sessionHelpFilter.value; + applyFilterAndRender(); + }); + + ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + focusFallbackTarget(); + } + }); + + ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + const row = target.closest('.session-help-item') as HTMLElement | null; + if (!row) return; + const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10); + if (!Number.isFinite(index)) return; + setSelected(index); + }); + + ctx.dom.sessionHelpClose.addEventListener('click', () => { + closeSessionHelpModal(); + }); + } + + return { + closeSessionHelpModal, + handleSessionHelpKeydown, + openSessionHelpModal, + wireDomEvents, + }; +} diff --git a/src/renderer/modals/subsync.ts b/src/renderer/modals/subsync.ts new file mode 100644 index 0000000..4aeb225 --- /dev/null +++ b/src/renderer/modals/subsync.ts @@ -0,0 +1,142 @@ +import type { SubsyncManualPayload } from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; + +export function createSubsyncModal( + ctx: RendererContext, + options: { + modalStateReader: Pick; + syncSettingsModalSubtitleSuppression: () => void; + }, +) { + function setSubsyncStatus(message: string, isError = false): void { + ctx.dom.subsyncStatus.textContent = message; + ctx.dom.subsyncStatus.classList.toggle('error', isError); + } + + function updateSubsyncSourceVisibility(): void { + const useAlass = ctx.dom.subsyncEngineAlass.checked; + ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass); + } + + function renderSubsyncSourceTracks(): void { + ctx.dom.subsyncSourceSelect.innerHTML = ''; + for (const track of ctx.state.subsyncSourceTracks) { + const option = document.createElement('option'); + option.value = String(track.id); + option.textContent = track.label; + ctx.dom.subsyncSourceSelect.appendChild(option); + } + ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0; + } + + function closeSubsyncModal(): void { + if (!ctx.state.subsyncModalOpen) return; + + ctx.state.subsyncModalOpen = false; + options.syncSettingsModalSubtitleSuppression(); + + ctx.dom.subsyncModal.classList.add('hidden'); + ctx.dom.subsyncModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('subsync'); + + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + } + } + + function openSubsyncModal(payload: SubsyncManualPayload): void { + if (ctx.platform.isInvisibleLayer) return; + + ctx.state.subsyncSubmitting = false; + ctx.dom.subsyncRunButton.disabled = false; + ctx.state.subsyncSourceTracks = payload.sourceTracks; + + const hasSources = ctx.state.subsyncSourceTracks.length > 0; + ctx.dom.subsyncEngineAlass.checked = hasSources; + ctx.dom.subsyncEngineFfsubsync.checked = !hasSources; + + renderSubsyncSourceTracks(); + updateSubsyncSourceVisibility(); + + setSubsyncStatus( + hasSources + ? 'Choose engine and source, then run.' + : 'No source subtitles available for alass. Use ffsubsync.', + false, + ); + + ctx.state.subsyncModalOpen = true; + options.syncSettingsModalSubtitleSuppression(); + + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.subsyncModal.classList.remove('hidden'); + ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false'); + } + + async function runSubsyncManualFromModal(): Promise { + if (ctx.state.subsyncSubmitting) return; + + const engine = ctx.dom.subsyncEngineAlass.checked ? 'alass' : 'ffsubsync'; + const sourceTrackId = + engine === 'alass' && ctx.dom.subsyncSourceSelect.value + ? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10) + : null; + + if (engine === 'alass' && !Number.isFinite(sourceTrackId)) { + setSubsyncStatus('Select a source subtitle track for alass.', true); + return; + } + + ctx.state.subsyncSubmitting = true; + ctx.dom.subsyncRunButton.disabled = true; + + closeSubsyncModal(); + try { + await window.electronAPI.runSubsyncManual({ + engine, + sourceTrackId, + }); + } finally { + ctx.state.subsyncSubmitting = false; + ctx.dom.subsyncRunButton.disabled = false; + } + } + + function handleSubsyncKeydown(e: KeyboardEvent): boolean { + if (e.key === 'Escape') { + e.preventDefault(); + closeSubsyncModal(); + return true; + } + + if (e.key === 'Enter') { + e.preventDefault(); + void runSubsyncManualFromModal(); + return true; + } + + return true; + } + + function wireDomEvents(): void { + ctx.dom.subsyncCloseButton.addEventListener('click', () => { + closeSubsyncModal(); + }); + ctx.dom.subsyncEngineAlass.addEventListener('change', () => { + updateSubsyncSourceVisibility(); + }); + ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => { + updateSubsyncSourceVisibility(); + }); + ctx.dom.subsyncRunButton.addEventListener('click', () => { + void runSubsyncManualFromModal(); + }); + } + + return { + closeSubsyncModal, + handleSubsyncKeydown, + openSubsyncModal, + wireDomEvents, + }; +} diff --git a/src/renderer/overlay-content-measurement.ts b/src/renderer/overlay-content-measurement.ts new file mode 100644 index 0000000..086b309 --- /dev/null +++ b/src/renderer/overlay-content-measurement.ts @@ -0,0 +1,117 @@ +import type { OverlayContentMeasurement, OverlayContentRect } from '../types'; +import type { RendererContext } from './context'; + +const MEASUREMENT_DEBOUNCE_MS = 80; + +function isMeasurableOverlayLayer(layer: string): layer is 'visible' | 'invisible' { + return layer === 'visible' || layer === 'invisible'; +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function toMeasuredRect(rect: DOMRect): OverlayContentRect | null { + if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) { + return null; + } + if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) { + return null; + } + + const width = Math.max(0, rect.width); + const height = Math.max(0, rect.height); + + return { + x: round2(rect.left), + y: round2(rect.top), + width: round2(width), + height: round2(height), + }; +} + +function unionRects(a: OverlayContentRect, b: OverlayContentRect): OverlayContentRect { + const left = Math.min(a.x, b.x); + const top = Math.min(a.y, b.y); + const right = Math.max(a.x + a.width, b.x + b.width); + const bottom = Math.max(a.y + a.height, b.y + b.height); + return { + x: round2(left), + y: round2(top), + width: round2(Math.max(0, right - left)), + height: round2(Math.max(0, bottom - top)), + }; +} + +function hasVisibleTextContent(element: HTMLElement): boolean { + return Boolean(element.textContent && element.textContent.trim().length > 0); +} + +function collectContentRect(ctx: RendererContext): OverlayContentRect | null { + let combinedRect: OverlayContentRect | null = null; + + const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot); + if (subtitleHasContent) { + const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect()); + if (subtitleRect) { + combinedRect = subtitleRect; + } + } + + const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot); + if (secondaryHasContent) { + const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect()); + if (secondaryRect) { + combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect; + } + } + + if (!combinedRect) { + return null; + } + + return { + x: combinedRect.x, + y: combinedRect.y, + width: round2(Math.max(0, combinedRect.width)), + height: round2(Math.max(0, combinedRect.height)), + }; +} + +export function createOverlayContentMeasurementReporter(ctx: RendererContext) { + let debounceTimer: number | null = null; + + function emitNow(): void { + if (!isMeasurableOverlayLayer(ctx.platform.overlayLayer)) { + return; + } + + const measurement: OverlayContentMeasurement = { + layer: ctx.platform.overlayLayer, + measuredAtMs: Date.now(), + viewport: { + width: window.innerWidth, + height: window.innerHeight, + }, + // Explicit null rect signals "no content yet", and main should use fallback bounds. + contentRect: collectContentRect(ctx), + }; + + window.electronAPI.reportOverlayContentBounds(measurement); + } + + function schedule(): void { + if (debounceTimer !== null) { + window.clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => { + debounceTimer = null; + emitNow(); + }, MEASUREMENT_DEBOUNCE_MS); + } + + return { + emitNow, + schedule, + }; +} diff --git a/src/renderer/positioning.ts b/src/renderer/positioning.ts new file mode 100644 index 0000000..5600c46 --- /dev/null +++ b/src/renderer/positioning.ts @@ -0,0 +1 @@ +export { createPositioningController } from './positioning/controller.js'; diff --git a/src/renderer/positioning/controller.ts b/src/renderer/positioning/controller.ts new file mode 100644 index 0000000..13dc015 --- /dev/null +++ b/src/renderer/positioning/controller.ts @@ -0,0 +1,36 @@ +import type { ModalStateReader, RendererContext } from '../context'; +import { + createInMemorySubtitlePositionController, + type SubtitlePositionController, +} from './position-state.js'; +import { + createInvisibleOffsetController, + type InvisibleOffsetController, +} from './invisible-offset.js'; +import { + createMpvSubtitleLayoutController, + type MpvSubtitleLayoutController, +} from './invisible-layout.js'; + +type PositioningControllerOptions = { + modalStateReader: Pick; + applySubtitleFontSize: (fontSize: number) => void; +}; + +export function createPositioningController( + ctx: RendererContext, + options: PositioningControllerOptions, +) { + const visible = createInMemorySubtitlePositionController(ctx); + const invisibleOffset = createInvisibleOffsetController(ctx, options.modalStateReader); + const invisibleLayout = createMpvSubtitleLayoutController(ctx, options.applySubtitleFontSize, { + applyInvisibleSubtitleOffsetPosition: invisibleOffset.applyInvisibleSubtitleOffsetPosition, + updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud, + }); + + return { + ...visible, + ...invisibleOffset, + ...invisibleLayout, + } as SubtitlePositionController & InvisibleOffsetController & MpvSubtitleLayoutController; +} diff --git a/src/renderer/positioning/invisible-layout-helpers.ts b/src/renderer/positioning/invisible-layout-helpers.ts new file mode 100644 index 0000000..8179fb9 --- /dev/null +++ b/src/renderer/positioning/invisible-layout-helpers.ts @@ -0,0 +1,187 @@ +import type { MpvSubtitleRenderMetrics } from '../../types'; +import type { RendererContext } from '../context'; + +const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5; +const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '0.92'; +const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.2'; +const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.3'; + +export function applyContainerBaseLayout( + ctx: RendererContext, + params: { + horizontalAvailable: number; + leftInset: number; + marginX: number; + hAlign: 0 | 1 | 2; + }, +): void { + const { horizontalAvailable, leftInset, marginX, hAlign } = params; + + ctx.dom.subtitleContainer.style.position = 'absolute'; + ctx.dom.subtitleContainer.style.maxWidth = `${horizontalAvailable}px`; + ctx.dom.subtitleContainer.style.width = `${horizontalAvailable}px`; + ctx.dom.subtitleContainer.style.padding = '0'; + ctx.dom.subtitleContainer.style.background = 'transparent'; + ctx.dom.subtitleContainer.style.marginBottom = '0'; + ctx.dom.subtitleContainer.style.pointerEvents = 'none'; + ctx.dom.subtitleContainer.style.left = `${leftInset + marginX}px`; + ctx.dom.subtitleContainer.style.right = ''; + ctx.dom.subtitleContainer.style.transform = ''; + ctx.dom.subtitleContainer.style.textAlign = ''; + + if (hAlign === 0) { + ctx.dom.subtitleContainer.style.textAlign = 'left'; + ctx.dom.subtitleRoot.style.textAlign = 'left'; + } else if (hAlign === 2) { + ctx.dom.subtitleContainer.style.textAlign = 'right'; + ctx.dom.subtitleRoot.style.textAlign = 'right'; + } else { + ctx.dom.subtitleContainer.style.textAlign = 'center'; + ctx.dom.subtitleRoot.style.textAlign = 'center'; + } + + ctx.dom.subtitleRoot.style.display = 'inline-block'; + ctx.dom.subtitleRoot.style.maxWidth = '100%'; + ctx.dom.subtitleRoot.style.pointerEvents = 'auto'; +} + +export function applyVerticalPosition( + ctx: RendererContext, + params: { + metrics: MpvSubtitleRenderMetrics; + renderAreaHeight: number; + topInset: number; + bottomInset: number; + marginY: number; + effectiveFontSize: number; + borderPx: number; + shadowPx: number; + vAlign: 0 | 1 | 2; + }, +): void { + const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset); + const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5); + + if (params.vAlign === 2) { + ctx.dom.subtitleContainer.style.top = `${Math.max( + 0, + params.topInset + params.marginY - baselineCompensationPx, + )}px`; + ctx.dom.subtitleContainer.style.bottom = ''; + return; + } + + if (params.vAlign === 1) { + ctx.dom.subtitleContainer.style.top = '50%'; + ctx.dom.subtitleContainer.style.bottom = ''; + ctx.dom.subtitleContainer.style.transform = 'translateY(-50%)'; + return; + } + + const anchorY = + params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx; + const bottomPx = Math.max(0, params.renderAreaHeight - anchorY); + + ctx.dom.subtitleContainer.style.top = ''; + ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`; +} + +function resolveFontFamily(rawFont: string): string { + const strippedFont = rawFont + .replace( + /\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i, + '', + ) + .trim(); + + return strippedFont !== rawFont + ? `"${rawFont}", "${strippedFont}", sans-serif` + : `"${rawFont}", sans-serif`; +} + +function resolveLineHeight(lineCount: number, isMacOSPlatform: boolean): string { + if (!isMacOSPlatform) return 'normal'; + if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE; + if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI; + return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE; +} + +function resolveLetterSpacing( + spacing: number, + pxPerScaledPixel: number, + isMacOSPlatform: boolean, +): string { + if (Math.abs(spacing) > 0.0001) { + return `${spacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`; + } + + return isMacOSPlatform ? '-0.02em' : '0px'; +} + +function applyComputedLineHeightCompensation( + ctx: RendererContext, + effectiveFontSize: number, +): void { + const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight); + if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) { + return; + } + + const halfLeading = (computedLineHeight - effectiveFontSize) / 2; + if (halfLeading <= 0.5) return; + + const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); + if (Number.isFinite(currentBottom)) { + ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`; + } + + const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top); + if (Number.isFinite(currentTop)) { + ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`; + } +} + +function applyMacOSAdjustments(ctx: RendererContext): void { + const isMacOSPlatform = ctx.platform.isMacOSPlatform; + if (!isMacOSPlatform) return; + + const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); + if (!Number.isFinite(currentBottom)) return; + + ctx.dom.subtitleContainer.style.bottom = `${Math.max( + 0, + currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX, + )}px`; +} + +export function applyTypography( + ctx: RendererContext, + params: { + metrics: MpvSubtitleRenderMetrics; + pxPerScaledPixel: number; + effectiveFontSize: number; + }, +): void { + const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount); + const isMacOSPlatform = ctx.platform.isMacOSPlatform; + + ctx.dom.subtitleRoot.style.setProperty( + 'line-height', + resolveLineHeight(lineCount, isMacOSPlatform), + isMacOSPlatform ? 'important' : '', + ); + ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont); + ctx.dom.subtitleRoot.style.setProperty( + 'letter-spacing', + resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel, isMacOSPlatform), + isMacOSPlatform ? 'important' : '', + ); + ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none'; + ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? '700' : '400'; + ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? 'italic' : 'normal'; + ctx.dom.subtitleRoot.style.transform = ''; + ctx.dom.subtitleRoot.style.transformOrigin = ''; + + applyComputedLineHeightCompensation(ctx, params.effectiveFontSize); + applyMacOSAdjustments(ctx); +} diff --git a/src/renderer/positioning/invisible-layout-metrics.ts b/src/renderer/positioning/invisible-layout-metrics.ts new file mode 100644 index 0000000..bb0d0e0 --- /dev/null +++ b/src/renderer/positioning/invisible-layout-metrics.ts @@ -0,0 +1,133 @@ +import type { MpvSubtitleRenderMetrics } from '../../types'; +import type { RendererContext } from '../context'; + +export type SubtitleAlignment = { hAlign: 0 | 1 | 2; vAlign: 0 | 1 | 2 }; + +export type SubtitleLayoutGeometry = { + renderAreaHeight: number; + renderAreaWidth: number; + leftInset: number; + rightInset: number; + topInset: number; + bottomInset: number; + horizontalAvailable: number; + marginY: number; + marginX: number; + pxPerScaledPixel: number; + effectiveFontSize: number; +}; + +export function calculateOsdScale( + metrics: MpvSubtitleRenderMetrics, + isMacOSPlatform: boolean, + viewportWidth: number, + viewportHeight: number, + devicePixelRatio: number, +): number { + const dims = metrics.osdDimensions; + + if (!isMacOSPlatform || !dims) { + return devicePixelRatio; + } + + const ratios = [dims.w / Math.max(1, viewportWidth), dims.h / Math.max(1, viewportHeight)].filter( + (value) => Number.isFinite(value) && value > 0, + ); + + const avgRatio = + ratios.length > 0 + ? ratios.reduce((sum, value) => sum + value, 0) / ratios.length + : devicePixelRatio; + + return avgRatio > 1.25 ? avgRatio : 1; +} + +export function calculateSubtitlePosition( + _metrics: MpvSubtitleRenderMetrics, + _scale: number, + alignment: number, +): SubtitleAlignment { + return { + hAlign: ((alignment - 1) % 3) as 0 | 1 | 2, + vAlign: Math.floor((alignment - 1) / 3) as 0 | 1 | 2, + }; +} + +function resolveLinePadding( + metrics: MpvSubtitleRenderMetrics, + pxPerScaledPixel: number, +): { marginY: number; marginX: number } { + return { + marginY: metrics.subMarginY * pxPerScaledPixel, + marginX: Math.max(0, metrics.subMarginX * pxPerScaledPixel), + }; +} + +export function applyPlatformFontCompensation( + fontSizePx: number, + isMacOSPlatform: boolean, +): number { + return isMacOSPlatform ? fontSizePx * 0.87 : fontSizePx; +} + +function calculateGeometry( + metrics: MpvSubtitleRenderMetrics, + osdToCssScale: number, +): Omit { + const dims = metrics.osdDimensions; + const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight; + const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth; + const videoLeftInset = dims ? dims.ml / osdToCssScale : 0; + const videoRightInset = dims ? dims.mr / osdToCssScale : 0; + const videoTopInset = dims ? dims.mt / osdToCssScale : 0; + const videoBottomInset = dims ? dims.mb / osdToCssScale : 0; + + const anchorToVideoArea = !metrics.subUseMargins; + const leftInset = anchorToVideoArea ? videoLeftInset : 0; + const rightInset = anchorToVideoArea ? videoRightInset : 0; + const topInset = anchorToVideoArea ? videoTopInset : 0; + const bottomInset = anchorToVideoArea ? videoBottomInset : 0; + const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset); + + return { + renderAreaHeight, + renderAreaWidth, + leftInset, + rightInset, + topInset, + bottomInset, + horizontalAvailable, + }; +} + +export function calculateSubtitleMetrics( + ctx: RendererContext, + metrics: MpvSubtitleRenderMetrics, +): SubtitleLayoutGeometry { + const osdToCssScale = calculateOsdScale( + metrics, + ctx.platform.isMacOSPlatform, + window.innerWidth, + window.innerHeight, + window.devicePixelRatio || 1, + ); + const geometry = calculateGeometry(metrics, osdToCssScale); + const videoHeight = geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset; + const scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight; + const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720); + const computedFontSize = + metrics.subFontSize * metrics.subScale * (ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel); + const effectiveFontSize = applyPlatformFontCompensation( + computedFontSize, + ctx.platform.isMacOSPlatform, + ); + const spacing = resolveLinePadding(metrics, pxPerScaledPixel); + + return { + ...geometry, + marginY: spacing.marginY, + marginX: spacing.marginX, + pxPerScaledPixel, + effectiveFontSize, + }; +} diff --git a/src/renderer/positioning/invisible-layout.ts b/src/renderer/positioning/invisible-layout.ts new file mode 100644 index 0000000..792c043 --- /dev/null +++ b/src/renderer/positioning/invisible-layout.ts @@ -0,0 +1,85 @@ +import type { MpvSubtitleRenderMetrics } from '../../types'; +import type { RendererContext } from '../context'; +import { + applyContainerBaseLayout, + applyTypography, + applyVerticalPosition, +} from './invisible-layout-helpers.js'; +import { calculateSubtitleMetrics, calculateSubtitlePosition } from './invisible-layout-metrics.js'; + +export type MpvSubtitleLayoutController = { + applyInvisibleSubtitleLayoutFromMpvMetrics: ( + metrics: MpvSubtitleRenderMetrics, + source: string, + ) => void; +}; + +export function createMpvSubtitleLayoutController( + ctx: RendererContext, + applySubtitleFontSize: (fontSize: number) => void, + options: { + applyInvisibleSubtitleOffsetPosition: () => void; + updateInvisiblePositionEditHud: () => void; + }, +): MpvSubtitleLayoutController { + function applyInvisibleSubtitleLayoutFromMpvMetrics( + metrics: MpvSubtitleRenderMetrics, + source: string, + ): void { + ctx.state.mpvSubtitleRenderMetrics = metrics; + + const geometry = calculateSubtitleMetrics(ctx, metrics); + const alignment = calculateSubtitlePosition(metrics, geometry.pxPerScaledPixel, 2); + + applySubtitleFontSize(geometry.effectiveFontSize); + const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel; + const effectiveShadowOffset = metrics.subShadowOffset * geometry.pxPerScaledPixel; + + document.documentElement.style.setProperty('--sub-border-size', `${effectiveBorderSize}px`); + + applyContainerBaseLayout(ctx, { + horizontalAvailable: Math.max( + 0, + geometry.horizontalAvailable - Math.round(geometry.marginX * 2), + ), + leftInset: geometry.leftInset, + marginX: geometry.marginX, + hAlign: alignment.hAlign, + }); + + applyVerticalPosition(ctx, { + metrics, + renderAreaHeight: geometry.renderAreaHeight, + topInset: geometry.topInset, + bottomInset: geometry.bottomInset, + marginY: geometry.marginY, + effectiveFontSize: geometry.effectiveFontSize, + borderPx: effectiveBorderSize, + shadowPx: effectiveShadowOffset, + vAlign: alignment.vAlign, + }); + + applyTypography(ctx, { + metrics, + pxPerScaledPixel: geometry.pxPerScaledPixel, + effectiveFontSize: geometry.effectiveFontSize, + }); + + ctx.state.invisibleLayoutBaseLeftPx = parseFloat(ctx.dom.subtitleContainer.style.left) || 0; + + const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); + ctx.state.invisibleLayoutBaseBottomPx = Number.isFinite(parsedBottom) ? parsedBottom : null; + + const parsedTop = parseFloat(ctx.dom.subtitleContainer.style.top); + ctx.state.invisibleLayoutBaseTopPx = Number.isFinite(parsedTop) ? parsedTop : null; + + options.applyInvisibleSubtitleOffsetPosition(); + options.updateInvisiblePositionEditHud(); + + console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source); + } + + return { + applyInvisibleSubtitleLayoutFromMpvMetrics, + }; +} diff --git a/src/renderer/positioning/invisible-offset.ts b/src/renderer/positioning/invisible-offset.ts new file mode 100644 index 0000000..cff3ac4 --- /dev/null +++ b/src/renderer/positioning/invisible-offset.ts @@ -0,0 +1,161 @@ +import type { SubtitlePosition } from '../../types'; +import type { ModalStateReader, RendererContext } from '../context'; + +export type InvisibleOffsetController = { + applyInvisibleStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void; + applyInvisibleSubtitleOffsetPosition: () => void; + updateInvisiblePositionEditHud: () => void; + setInvisiblePositionEditMode: (enabled: boolean) => void; + saveInvisiblePositionEdit: () => void; + cancelInvisiblePositionEdit: () => void; + setupInvisiblePositionEditHud: () => void; +}; + +function formatEditHudText(offsetX: number, offsetY: number): string { + return `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(offsetX)} y:${Math.round(offsetY)}`; +} + +function createEditPositionText(ctx: RendererContext): string { + return formatEditHudText( + ctx.state.invisibleSubtitleOffsetXPx, + ctx.state.invisibleSubtitleOffsetYPx, + ); +} + +function applyOffsetByBasePosition(ctx: RendererContext): void { + const nextLeft = ctx.state.invisibleLayoutBaseLeftPx + ctx.state.invisibleSubtitleOffsetXPx; + ctx.dom.subtitleContainer.style.left = `${nextLeft}px`; + + if (ctx.state.invisibleLayoutBaseBottomPx !== null) { + ctx.dom.subtitleContainer.style.bottom = `${Math.max( + 0, + ctx.state.invisibleLayoutBaseBottomPx + ctx.state.invisibleSubtitleOffsetYPx, + )}px`; + ctx.dom.subtitleContainer.style.top = ''; + return; + } + + if (ctx.state.invisibleLayoutBaseTopPx !== null) { + ctx.dom.subtitleContainer.style.top = `${Math.max( + 0, + ctx.state.invisibleLayoutBaseTopPx - ctx.state.invisibleSubtitleOffsetYPx, + )}px`; + ctx.dom.subtitleContainer.style.bottom = ''; + } +} + +export function createInvisibleOffsetController( + ctx: RendererContext, + modalStateReader: Pick, +): InvisibleOffsetController { + function setInvisiblePositionEditMode(enabled: boolean): void { + if (!ctx.platform.isInvisibleLayer) return; + if (ctx.state.invisiblePositionEditMode === enabled) return; + + ctx.state.invisiblePositionEditMode = enabled; + document.body.classList.toggle('invisible-position-edit', enabled); + + if (enabled) { + ctx.state.invisiblePositionEditStartX = ctx.state.invisibleSubtitleOffsetXPx; + ctx.state.invisiblePositionEditStartY = ctx.state.invisibleSubtitleOffsetYPx; + ctx.dom.overlay.classList.add('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(false); + } + } else { + if (!ctx.state.isOverSubtitle && !modalStateReader.isAnySettingsModalOpen()) { + ctx.dom.overlay.classList.remove('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } + } + } + + updateInvisiblePositionEditHud(); + } + + function updateInvisiblePositionEditHud(): void { + if (!ctx.state.invisiblePositionEditHud) return; + ctx.state.invisiblePositionEditHud.textContent = createEditPositionText(ctx); + } + + function applyInvisibleSubtitleOffsetPosition(): void { + applyOffsetByBasePosition(ctx); + } + + function applyInvisibleStoredSubtitlePosition( + position: SubtitlePosition | null, + source: string, + ): void { + if (position && typeof position.yPercent === 'number' && Number.isFinite(position.yPercent)) { + ctx.state.persistedSubtitlePosition = { + ...ctx.state.persistedSubtitlePosition, + yPercent: position.yPercent, + }; + } + + if (position) { + const nextX = + typeof position.invisibleOffsetXPx === 'number' && + Number.isFinite(position.invisibleOffsetXPx) + ? position.invisibleOffsetXPx + : 0; + const nextY = + typeof position.invisibleOffsetYPx === 'number' && + Number.isFinite(position.invisibleOffsetYPx) + ? position.invisibleOffsetYPx + : 0; + ctx.state.invisibleSubtitleOffsetXPx = nextX; + ctx.state.invisibleSubtitleOffsetYPx = nextY; + } else { + ctx.state.invisibleSubtitleOffsetXPx = 0; + ctx.state.invisibleSubtitleOffsetYPx = 0; + } + + applyOffsetByBasePosition(ctx); + console.log( + '[invisible-overlay] Applied subtitle offset from', + source, + `${ctx.state.invisibleSubtitleOffsetXPx}px`, + `${ctx.state.invisibleSubtitleOffsetYPx}px`, + ); + updateInvisiblePositionEditHud(); + } + + function saveInvisiblePositionEdit(): void { + const nextPosition = { + yPercent: ctx.state.persistedSubtitlePosition.yPercent, + invisibleOffsetXPx: ctx.state.invisibleSubtitleOffsetXPx, + invisibleOffsetYPx: ctx.state.invisibleSubtitleOffsetYPx, + }; + window.electronAPI.saveSubtitlePosition(nextPosition); + setInvisiblePositionEditMode(false); + } + + function cancelInvisiblePositionEdit(): void { + ctx.state.invisibleSubtitleOffsetXPx = ctx.state.invisiblePositionEditStartX; + ctx.state.invisibleSubtitleOffsetYPx = ctx.state.invisiblePositionEditStartY; + applyOffsetByBasePosition(ctx); + setInvisiblePositionEditMode(false); + } + + function setupInvisiblePositionEditHud(): void { + if (!ctx.platform.isInvisibleLayer) return; + const hud = document.createElement('div'); + hud.id = 'invisiblePositionEditHud'; + hud.className = 'invisible-position-edit-hud'; + ctx.dom.overlay.appendChild(hud); + ctx.state.invisiblePositionEditHud = hud; + updateInvisiblePositionEditHud(); + } + + return { + applyInvisibleStoredSubtitlePosition, + applyInvisibleSubtitleOffsetPosition, + updateInvisiblePositionEditHud, + setInvisiblePositionEditMode, + saveInvisiblePositionEdit, + cancelInvisiblePositionEdit, + setupInvisiblePositionEditHud, + }; +} diff --git a/src/renderer/positioning/position-state.ts b/src/renderer/positioning/position-state.ts new file mode 100644 index 0000000..a3e8d64 --- /dev/null +++ b/src/renderer/positioning/position-state.ts @@ -0,0 +1,120 @@ +import type { SubtitlePosition } from '../../types'; +import type { RendererContext } from '../context'; + +const PREFERRED_Y_PERCENT_MIN = 2; +const PREFERRED_Y_PERCENT_MAX = 80; + +export type SubtitlePositionController = { + applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void; + getCurrentYPercent: () => number; + applyYPercent: (yPercent: number) => void; + persistSubtitlePositionPatch: (patch: Partial) => void; +}; + +function clampYPercent(yPercent: number): number { + return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent)); +} + +function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number { + if (!position || typeof position.yPercent !== 'number' || !Number.isFinite(position.yPercent)) { + return ctx.state.persistedSubtitlePosition.yPercent; + } + + return position.yPercent; +} + +function getPersistedOffset( + position: SubtitlePosition | null, + key: 'invisibleOffsetXPx' | 'invisibleOffsetYPx', +): number { + if (position && typeof position[key] === 'number' && Number.isFinite(position[key])) { + return position[key]; + } + + return 0; +} + +function updatePersistedSubtitlePosition( + ctx: RendererContext, + position: SubtitlePosition | null, +): void { + ctx.state.persistedSubtitlePosition = { + yPercent: getPersistedYPercent(ctx, position), + invisibleOffsetXPx: getPersistedOffset(position, 'invisibleOffsetXPx'), + invisibleOffsetYPx: getPersistedOffset(position, 'invisibleOffsetYPx'), + }; +} + +function getNextPersistedPosition( + ctx: RendererContext, + patch: Partial, +): SubtitlePosition { + return { + yPercent: + typeof patch.yPercent === 'number' && Number.isFinite(patch.yPercent) + ? patch.yPercent + : ctx.state.persistedSubtitlePosition.yPercent, + invisibleOffsetXPx: + typeof patch.invisibleOffsetXPx === 'number' && Number.isFinite(patch.invisibleOffsetXPx) + ? patch.invisibleOffsetXPx + : (ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0), + invisibleOffsetYPx: + typeof patch.invisibleOffsetYPx === 'number' && Number.isFinite(patch.invisibleOffsetYPx) + ? patch.invisibleOffsetYPx + : (ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0), + }; +} + +export function createInMemorySubtitlePositionController( + ctx: RendererContext, +): SubtitlePositionController { + function getCurrentYPercent(): number { + if (ctx.state.currentYPercent !== null) { + return ctx.state.currentYPercent; + } + + const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60; + ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100); + return ctx.state.currentYPercent; + } + + function applyYPercent(yPercent: number): void { + const clampedPercent = clampYPercent(yPercent); + ctx.state.currentYPercent = clampedPercent; + const marginBottom = (clampedPercent / 100) * window.innerHeight; + + ctx.dom.subtitleContainer.style.position = ''; + ctx.dom.subtitleContainer.style.left = ''; + ctx.dom.subtitleContainer.style.top = ''; + ctx.dom.subtitleContainer.style.right = ''; + ctx.dom.subtitleContainer.style.transform = ''; + ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`; + } + + function persistSubtitlePositionPatch(patch: Partial): void { + const nextPosition = getNextPersistedPosition(ctx, patch); + ctx.state.persistedSubtitlePosition = nextPosition; + window.electronAPI.saveSubtitlePosition(nextPosition); + } + + function applyStoredSubtitlePosition(position: SubtitlePosition | null, source: string): void { + updatePersistedSubtitlePosition(ctx, position); + if (position && position.yPercent !== undefined) { + applyYPercent(position.yPercent); + console.log('Applied subtitle position from', source, ':', position.yPercent, '%'); + return; + } + + const defaultMarginBottom = 60; + const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100; + applyYPercent(defaultYPercent); + console.log('Applied default subtitle position from', source); + } + + return { + applyStoredSubtitlePosition, + getCurrentYPercent, + applyYPercent, + persistSubtitlePositionPatch, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts new file mode 100644 index 0000000..6e88923 --- /dev/null +++ b/src/renderer/renderer.ts @@ -0,0 +1,472 @@ +/* + * SubMiner - All-in-one sentence mining overlay + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import type { + KikuDuplicateCardInfo, + MpvSubtitleRenderMetrics, + RuntimeOptionState, + SecondarySubMode, + SubtitleData, + SubtitlePosition, + SubsyncManualPayload, + ConfigHotReloadPayload, +} from '../types'; +import { createKeyboardHandlers } from './handlers/keyboard.js'; +import { createMouseHandlers } from './handlers/mouse.js'; +import { createJimakuModal } from './modals/jimaku.js'; +import { createKikuModal } from './modals/kiku.js'; +import { createSessionHelpModal } from './modals/session-help.js'; +import { createRuntimeOptionsModal } from './modals/runtime-options.js'; +import { createSubsyncModal } from './modals/subsync.js'; +import { createPositioningController } from './positioning.js'; +import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js'; +import { createRendererState } from './state.js'; +import { createSubtitleRenderer } from './subtitle-render.js'; +import { + createRendererRecoveryController, + registerRendererGlobalErrorHandlers, +} from './error-recovery.js'; +import { resolveRendererDom } from './utils/dom.js'; +import { resolvePlatformInfo } from './utils/platform.js'; +import { + buildMpvLoadfileCommands, + collectDroppedVideoPaths, +} from '../core/services/overlay-drop.js'; + +const ctx = { + dom: resolveRendererDom(), + platform: resolvePlatformInfo(), + state: createRendererState(), +}; + +function isAnySettingsModalOpen(): boolean { + return ( + ctx.state.runtimeOptionsModalOpen || + ctx.state.subsyncModalOpen || + ctx.state.kikuModalOpen || + ctx.state.jimakuModalOpen || + ctx.state.sessionHelpModalOpen + ); +} + +function isAnyModalOpen(): boolean { + return ( + ctx.state.jimakuModalOpen || + ctx.state.kikuModalOpen || + ctx.state.runtimeOptionsModalOpen || + ctx.state.subsyncModalOpen || + ctx.state.sessionHelpModalOpen + ); +} + +function syncSettingsModalSubtitleSuppression(): void { + const suppressSubtitles = isAnySettingsModalOpen(); + document.body.classList.toggle('settings-modal-open', suppressSubtitles); + if (suppressSubtitles) { + ctx.state.isOverSubtitle = false; + } +} + +const subtitleRenderer = createSubtitleRenderer(ctx); +const measurementReporter = createOverlayContentMeasurementReporter(ctx); +const positioning = createPositioningController(ctx, { + modalStateReader: { isAnySettingsModalOpen }, + applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize, +}); +const runtimeOptionsModal = createRuntimeOptionsModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); +const subsyncModal = createSubsyncModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); +const sessionHelpModal = createSessionHelpModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); +const kikuModal = createKikuModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); +const jimakuModal = createJimakuModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); +const keyboardHandlers = createKeyboardHandlers(ctx, { + handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, + handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, + handleKikuKeydown: kikuModal.handleKikuKeydown, + handleJimakuKeydown: jimakuModal.handleJimakuKeydown, + handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, + openSessionHelpModal: sessionHelpModal.openSessionHelpModal, + saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit, + cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit, + setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode, + applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition, + updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud, + appendClipboardVideoToQueue: () => { + void window.electronAPI.appendClipboardVideoToQueue(); + }, +}); +const mouseHandlers = createMouseHandlers(ctx, { + modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, + applyInvisibleSubtitleLayoutFromMpvMetrics: + positioning.applyInvisibleSubtitleLayoutFromMpvMetrics, + applyYPercent: positioning.applyYPercent, + getCurrentYPercent: positioning.getCurrentYPercent, + persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, + reportHoveredTokenIndex: (tokenIndex: number | null) => { + window.electronAPI.reportHoveredSubtitleToken(tokenIndex); + }, +}); + +let lastSubtitlePreview = ''; +let lastSecondarySubtitlePreview = ''; +let overlayErrorToastTimeout: ReturnType | null = null; + +function truncateForErrorLog(text: string): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (normalized.length <= 180) { + return normalized; + } + return `${normalized.slice(0, 177)}...`; +} + +function getActiveModal(): string | null { + if (ctx.state.jimakuModalOpen) return 'jimaku'; + if (ctx.state.kikuModalOpen) return 'kiku'; + if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; + if (ctx.state.subsyncModalOpen) return 'subsync'; + if (ctx.state.sessionHelpModalOpen) return 'session-help'; + return null; +} + +function dismissActiveUiAfterError(): void { + if (ctx.state.jimakuModalOpen) { + jimakuModal.closeJimakuModal(); + } + if (ctx.state.runtimeOptionsModalOpen) { + runtimeOptionsModal.closeRuntimeOptionsModal(); + } + if (ctx.state.subsyncModalOpen) { + subsyncModal.closeSubsyncModal(); + } + if (ctx.state.kikuModalOpen) { + kikuModal.cancelKikuFieldGrouping(); + } + if (ctx.state.sessionHelpModalOpen) { + sessionHelpModal.closeSessionHelpModal(); + } + + syncSettingsModalSubtitleSuppression(); +} + +function restoreOverlayInteractionAfterError(): void { + ctx.state.isOverSubtitle = false; + if (ctx.state.invisiblePositionEditMode) { + positioning.setInvisiblePositionEditMode(false); + } + ctx.dom.overlay.classList.remove('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } +} + +function showOverlayErrorToast(message: string): void { + if (overlayErrorToastTimeout) { + clearTimeout(overlayErrorToastTimeout); + overlayErrorToastTimeout = null; + } + ctx.dom.overlayErrorToast.textContent = message; + ctx.dom.overlayErrorToast.classList.remove('hidden'); + overlayErrorToastTimeout = setTimeout(() => { + ctx.dom.overlayErrorToast.classList.add('hidden'); + ctx.dom.overlayErrorToast.textContent = ''; + overlayErrorToastTimeout = null; + }, 3200); +} + +const recovery = createRendererRecoveryController({ + dismissActiveUi: dismissActiveUiAfterError, + restoreOverlayInteraction: restoreOverlayInteractionAfterError, + showToast: showOverlayErrorToast, + getSnapshot: () => ({ + activeModal: getActiveModal(), + subtitlePreview: lastSubtitlePreview, + secondarySubtitlePreview: lastSecondarySubtitlePreview, + isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'), + isOverSubtitle: ctx.state.isOverSubtitle, + invisiblePositionEditMode: ctx.state.invisiblePositionEditMode, + overlayLayer: ctx.platform.overlayLayer, + }), + logError: (payload) => { + console.error('renderer overlay recovery', payload); + }, +}); + +registerRendererGlobalErrorHandlers(window, recovery); + +function runGuarded(action: string, fn: () => void): void { + try { + fn(); + } catch (error) { + recovery.handleError(error, { source: 'callback', action }); + } +} + +function runGuardedAsync(action: string, fn: () => Promise | void): void { + Promise.resolve() + .then(fn) + .catch((error) => { + recovery.handleError(error, { source: 'callback', action }); + }); +} + +async function init(): Promise { + document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); + + window.electronAPI.onSubtitle((data: SubtitleData) => { + runGuarded('subtitle:update', () => { + if (typeof data === 'string') { + lastSubtitlePreview = truncateForErrorLog(data); + } else if (data && typeof data.text === 'string') { + lastSubtitlePreview = truncateForErrorLog(data.text); + } + subtitleRenderer.renderSubtitle(data); + measurementReporter.schedule(); + }); + }); + + window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { + runGuarded('subtitle-position:update', () => { + if (ctx.platform.isInvisibleLayer) { + positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change'); + } else { + positioning.applyStoredSubtitlePosition(position, 'media-change'); + } + measurementReporter.schedule(); + }); + }); + + if (ctx.platform.isInvisibleLayer) { + window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => { + runGuarded('mpv-metrics:update', () => { + positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event'); + measurementReporter.schedule(); + }); + }); + window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => { + runGuarded('overlay-debug-visualization:update', () => { + document.body.classList.toggle('debug-invisible-visualization', enabled); + }); + }); + } + + const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw(); + lastSubtitlePreview = truncateForErrorLog(initialSubtitle); + subtitleRenderer.renderSubtitle(initialSubtitle); + measurementReporter.schedule(); + + window.electronAPI.onSecondarySub((text: string) => { + runGuarded('secondary-subtitle:update', () => { + lastSecondarySubtitlePreview = truncateForErrorLog(text); + subtitleRenderer.renderSecondarySub(text); + measurementReporter.schedule(); + }); + }); + window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => { + runGuarded('secondary-subtitle-mode:update', () => { + subtitleRenderer.updateSecondarySubMode(mode); + measurementReporter.schedule(); + }); + }); + + subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode()); + subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); + measurementReporter.schedule(); + + const hoverTarget = ctx.platform.isInvisibleLayer + ? ctx.dom.subtitleRoot + : ctx.dom.subtitleContainer; + hoverTarget.addEventListener('mouseenter', mouseHandlers.handleMouseEnter); + hoverTarget.addEventListener('mouseleave', mouseHandlers.handleMouseLeave); + ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter); + ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave); + + mouseHandlers.setupInvisibleHoverSelection(); + mouseHandlers.setupInvisibleTokenHoverReporter(); + positioning.setupInvisiblePositionEditHud(); + mouseHandlers.setupResizeHandler(); + mouseHandlers.setupSelectionObserver(); + mouseHandlers.setupYomitanObserver(); + setupDragDropToMpvQueue(); + window.addEventListener('resize', () => { + measurementReporter.schedule(); + }); + + jimakuModal.wireDomEvents(); + kikuModal.wireDomEvents(); + runtimeOptionsModal.wireDomEvents(); + subsyncModal.wireDomEvents(); + sessionHelpModal.wireDomEvents(); + + window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { + runGuarded('runtime-options:changed', () => { + runtimeOptionsModal.updateRuntimeOptions(options); + }); + }); + window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => { + runGuarded('config:hot-reload', () => { + keyboardHandlers.updateKeybindings(payload.keybindings); + subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); + subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); + measurementReporter.schedule(); + }); + }); + window.electronAPI.onOpenRuntimeOptions(() => { + runGuardedAsync('runtime-options:open', async () => { + try { + await runtimeOptionsModal.openRuntimeOptionsModal(); + } catch { + runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); + window.electronAPI.notifyOverlayModalClosed('runtime-options'); + syncSettingsModalSubtitleSuppression(); + } + }); + }); + window.electronAPI.onOpenJimaku(() => { + runGuarded('jimaku:open', () => { + jimakuModal.openJimakuModal(); + }); + }); + window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => { + runGuarded('subsync:manual-open', () => { + subsyncModal.openSubsyncModal(payload); + }); + }); + window.electronAPI.onKikuFieldGroupingRequest( + (data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => { + runGuarded('kiku:field-grouping-open', () => { + kikuModal.openKikuFieldGroupingModal(data); + }); + }, + ); + + if (!ctx.platform.isInvisibleLayer) { + mouseHandlers.setupDragging(); + } + + await keyboardHandlers.setupMpvInputForwarding(); + + subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); + + if (ctx.platform.isInvisibleLayer) { + positioning.applyInvisibleStoredSubtitlePosition( + await window.electronAPI.getSubtitlePosition(), + 'startup', + ); + positioning.applyInvisibleSubtitleLayoutFromMpvMetrics( + await window.electronAPI.getMpvSubtitleRenderMetrics(), + 'startup', + ); + } else { + positioning.applyStoredSubtitlePosition( + await window.electronAPI.getSubtitlePosition(), + 'startup', + ); + measurementReporter.schedule(); + } + + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } + + measurementReporter.emitNow(); +} + +function setupDragDropToMpvQueue(): void { + let dragDepth = 0; + + const setDropInteractive = (): void => { + ctx.dom.overlay.classList.add('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(false); + } + }; + + const clearDropInteractive = (): void => { + dragDepth = 0; + if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) { + return; + } + ctx.dom.overlay.classList.remove('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } + }; + + document.addEventListener('dragenter', (event: DragEvent) => { + if (!event.dataTransfer) return; + dragDepth += 1; + setDropInteractive(); + }); + + document.addEventListener('dragover', (event: DragEvent) => { + if (dragDepth <= 0 || !event.dataTransfer) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }); + + document.addEventListener('dragleave', () => { + if (dragDepth <= 0) return; + dragDepth -= 1; + if (dragDepth === 0) { + clearDropInteractive(); + } + }); + + document.addEventListener('drop', (event: DragEvent) => { + if (!event.dataTransfer) return; + event.preventDefault(); + + const droppedPaths = collectDroppedVideoPaths(event.dataTransfer); + const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey); + for (const command of loadCommands) { + window.electronAPI.sendMpvCommand(command); + } + if (loadCommands.length > 0) { + const action = event.shiftKey ? 'Queued' : 'Loaded'; + window.electronAPI.sendMpvCommand([ + 'show-text', + `${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`, + '1500', + ]); + } + + clearDropInteractive(); + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + runGuardedAsync('bootstrap:init', init); + }); +} else { + runGuardedAsync('bootstrap:init', init); +} diff --git a/src/renderer/state.ts b/src/renderer/state.ts new file mode 100644 index 0000000..add747c --- /dev/null +++ b/src/renderer/state.ts @@ -0,0 +1,176 @@ +import type { + JimakuEntry, + JimakuFileEntry, + KikuDuplicateCardInfo, + KikuFieldGroupingChoice, + MpvSubtitleRenderMetrics, + RuntimeOptionId, + RuntimeOptionState, + RuntimeOptionValue, + SubtitlePosition, + SubsyncSourceTrack, +} from '../types'; + +export type KikuModalStep = 'select' | 'preview'; +export type KikuPreviewMode = 'compact' | 'full'; + +export type ChordAction = + | { type: 'mpv'; command: string[] } + | { type: 'electron'; action: () => void } + | { type: 'noop' }; + +export type RendererState = { + isOverSubtitle: boolean; + isDragging: boolean; + dragStartY: number; + startYPercent: number; + currentYPercent: number | null; + persistedSubtitlePosition: SubtitlePosition; + + jimakuModalOpen: boolean; + jimakuEntries: JimakuEntry[]; + jimakuFiles: JimakuFileEntry[]; + selectedEntryIndex: number; + selectedFileIndex: number; + currentEpisodeFilter: number | null; + currentEntryId: number | null; + + kikuModalOpen: boolean; + kikuSelectedCard: 1 | 2; + kikuOriginalData: KikuDuplicateCardInfo | null; + kikuDuplicateData: KikuDuplicateCardInfo | null; + kikuModalStep: KikuModalStep; + kikuPreviewMode: KikuPreviewMode; + kikuPendingChoice: KikuFieldGroupingChoice | null; + kikuPreviewCompactData: Record | null; + kikuPreviewFullData: Record | null; + + runtimeOptionsModalOpen: boolean; + runtimeOptions: RuntimeOptionState[]; + runtimeOptionSelectedIndex: number; + runtimeOptionDraftValues: Map; + + subsyncModalOpen: boolean; + subsyncSourceTracks: SubsyncSourceTrack[]; + subsyncSubmitting: boolean; + + sessionHelpModalOpen: boolean; + sessionHelpSelectedIndex: number; + + mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics | null; + invisiblePositionEditMode: boolean; + invisiblePositionEditStartX: number; + invisiblePositionEditStartY: number; + invisibleSubtitleOffsetXPx: number; + invisibleSubtitleOffsetYPx: number; + invisibleLayoutBaseLeftPx: number; + invisibleLayoutBaseBottomPx: number | null; + invisibleLayoutBaseTopPx: number | null; + invisiblePositionEditHud: HTMLDivElement | null; + currentInvisibleSubtitleLineCount: number; + + lastHoverSelectionKey: string; + lastHoverSelectionNode: Text | null; + lastHoveredTokenIndex: number | null; + + knownWordColor: string; + nPlusOneColor: string; + jlptN1Color: string; + jlptN2Color: string; + jlptN3Color: string; + jlptN4Color: string; + jlptN5Color: string; + preserveSubtitleLineBreaks: boolean; + frequencyDictionaryEnabled: boolean; + frequencyDictionaryTopX: number; + frequencyDictionaryMode: 'single' | 'banded'; + frequencyDictionarySingleColor: string; + frequencyDictionaryBand1Color: string; + frequencyDictionaryBand2Color: string; + frequencyDictionaryBand3Color: string; + frequencyDictionaryBand4Color: string; + frequencyDictionaryBand5Color: string; + + keybindingsMap: Map; + chordPending: boolean; + chordTimeout: ReturnType | null; +}; + +export function createRendererState(): RendererState { + return { + isOverSubtitle: false, + isDragging: false, + dragStartY: 0, + startYPercent: 0, + currentYPercent: null, + persistedSubtitlePosition: { yPercent: 10 }, + + jimakuModalOpen: false, + jimakuEntries: [], + jimakuFiles: [], + selectedEntryIndex: 0, + selectedFileIndex: 0, + currentEpisodeFilter: null, + currentEntryId: null, + + kikuModalOpen: false, + kikuSelectedCard: 1, + kikuOriginalData: null, + kikuDuplicateData: null, + kikuModalStep: 'select', + kikuPreviewMode: 'compact', + kikuPendingChoice: null, + kikuPreviewCompactData: null, + kikuPreviewFullData: null, + + runtimeOptionsModalOpen: false, + runtimeOptions: [], + runtimeOptionSelectedIndex: 0, + runtimeOptionDraftValues: new Map(), + + subsyncModalOpen: false, + subsyncSourceTracks: [], + subsyncSubmitting: false, + + sessionHelpModalOpen: false, + sessionHelpSelectedIndex: 0, + + mpvSubtitleRenderMetrics: null, + invisiblePositionEditMode: false, + invisiblePositionEditStartX: 0, + invisiblePositionEditStartY: 0, + invisibleSubtitleOffsetXPx: 0, + invisibleSubtitleOffsetYPx: 0, + invisibleLayoutBaseLeftPx: 0, + invisibleLayoutBaseBottomPx: null, + invisibleLayoutBaseTopPx: null, + invisiblePositionEditHud: null, + currentInvisibleSubtitleLineCount: 1, + + lastHoverSelectionKey: '', + lastHoverSelectionNode: null, + lastHoveredTokenIndex: null, + + knownWordColor: '#a6da95', + nPlusOneColor: '#c6a0f6', + jlptN1Color: '#ed8796', + jlptN2Color: '#f5a97f', + jlptN3Color: '#f9e2af', + jlptN4Color: '#a6e3a1', + jlptN5Color: '#8aadf4', + preserveSubtitleLineBreaks: false, + frequencyDictionaryEnabled: false, + frequencyDictionaryTopX: 1000, + frequencyDictionaryMode: 'single', + frequencyDictionarySingleColor: '#f5a97f', + frequencyDictionaryBand1Color: '#ed8796', + frequencyDictionaryBand2Color: '#f5a97f', + frequencyDictionaryBand3Color: '#f9e2af', + frequencyDictionaryBand4Color: '#a6e3a1', + frequencyDictionaryBand5Color: '#8aadf4', + + keybindingsMap: new Map(), + chordPending: false, + chordTimeout: null, + }; +} diff --git a/src/renderer/style.css b/src/renderer/style.css new file mode 100644 index 0000000..76df987 --- /dev/null +++ b/src/renderer/style.css @@ -0,0 +1,1121 @@ +/* + * SubMiner - All-in-one sentence mining overlay + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +@font-face { + font-family: 'M PLUS 1'; + src: url('./fonts/MPLUS1[wght].ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + background: transparent; + font-family: + 'M PLUS 1', 'Noto Sans CJK JP Regular', 'Noto Sans CJK JP', 'Hiragino Sans', + 'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif; +} + +#overlay { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + pointer-events: none; +} + +#overlay.interactive { + pointer-events: auto; +} + +.overlay-error-toast { + position: absolute; + top: 16px; + right: 16px; + max-width: min(420px, calc(100vw - 32px)); + padding: 10px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 148, 148, 0.5); + background: rgba(48, 12, 12, 0.9); + color: rgba(255, 238, 238, 0.98); + font-size: 13px; + line-height: 1.35; + pointer-events: none; + opacity: 0; + transform: translateY(-6px); + transition: + opacity 160ms ease, + transform 160ms ease; + z-index: 1300; +} + +.overlay-error-toast:not(.hidden) { + opacity: 1; + transform: translateY(0); +} + +.modal { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + pointer-events: auto; + z-index: 1000; +} + +#jimakuModal { + z-index: 1100; +} + +.modal.hidden { + display: none; +} + +.hidden { + display: none !important; +} + +.modal-content { + width: min(720px, 92%); + max-height: 80%; + background: rgba(20, 20, 20, 0.95); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + padding: 16px; + color: #fff; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.modal-title { + font-size: 18px; + font-weight: 600; +} + +.modal-close { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 6px 10px; + cursor: pointer; +} + +.modal-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.modal-body { + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} + +.jimaku-form { + display: grid; + grid-template-columns: 1fr 120px 120px auto; + gap: 10px; + align-items: end; +} + +.jimaku-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: rgba(255, 255, 255, 0.7); +} + +.jimaku-field input { + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #fff; + padding: 6px 8px; +} + +.jimaku-button { + height: 36px; + padding: 0 14px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.15); + color: #fff; + cursor: pointer; +} + +.jimaku-button:hover { + background: rgba(255, 255, 255, 0.25); +} + +.jimaku-status { + min-height: 20px; + font-size: 13px; + color: rgba(255, 255, 255, 0.8); +} + +.jimaku-section { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 220px; + overflow: hidden; +} + +.jimaku-section.hidden { + display: none; +} + +.jimaku-section-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.6); +} + +.jimaku-list { + list-style: none; + padding: 0; + margin: 0; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + max-height: 180px; +} + +.jimaku-list li { + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} + +.jimaku-list li:last-child { + border-bottom: none; +} + +.jimaku-list li.active { + background: rgba(255, 255, 255, 0.15); +} + +.jimaku-list .jimaku-subtext { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.jimaku-link { + align-self: flex-start; + background: transparent; + color: rgba(255, 255, 255, 0.8); + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} + +.jimaku-link.hidden { + display: none; +} + +@media (max-width: 640px) { + .jimaku-form { + grid-template-columns: 1fr 1fr; + } + + .jimaku-button { + grid-column: span 2; + } +} + +#subtitleContainer { + max-width: 80%; + margin-bottom: 60px; + padding: 12px 20px; + background: rgb(30, 32, 48, 0.88); + border-radius: 8px; + pointer-events: auto; +} + +#subtitleRoot { + text-align: center; + font-size: 35px; + line-height: 1.5; + color: #cad3f5; + --subtitle-known-word-color: #a6da95; + --subtitle-n-plus-one-color: #c6a0f6; + --subtitle-jlpt-n1-color: #ed8796; + --subtitle-jlpt-n2-color: #f5a97f; + --subtitle-jlpt-n3-color: #f9e2af; + --subtitle-jlpt-n4-color: #a6e3a1; + --subtitle-jlpt-n5-color: #8aadf4; + --subtitle-frequency-single-color: #f5a97f; + --subtitle-frequency-band-1-color: #ed8796; + --subtitle-frequency-band-2-color: #f5a97f; + --subtitle-frequency-band-3-color: #f9e2af; + --subtitle-frequency-band-4-color: #a6e3a1; + --subtitle-frequency-band-5-color: #8aadf4; + text-shadow: + 2px 2px 4px rgba(0, 0, 0, 0.8), + -1px -1px 2px rgba(0, 0, 0, 0.5); + /* Enable text selection for Yomitan */ + user-select: text; + cursor: text; +} + +#subtitleRoot:empty { + display: none; +} + +#subtitleContainer:has(#subtitleRoot:empty) { + display: none; +} + +body.settings-modal-open #subtitleContainer { + display: none !important; + pointer-events: none !important; +} + +#subtitleRoot .c { + display: inline; + position: relative; +} + +#subtitleRoot .c:hover { + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; +} + +#subtitleRoot .word { + display: inline; + position: relative; +} + +#subtitleRoot .word.word-known { + color: var(--subtitle-known-word-color, #a6da95); + text-shadow: 0 0 6px rgba(166, 218, 149, 0.35); +} + +#subtitleRoot .word.word-n-plus-one { + color: var(--subtitle-n-plus-one-color, #c6a0f6); + text-shadow: 0 0 6px rgba(198, 160, 246, 0.35); +} + +#subtitleRoot .word.word-jlpt-n1 { + color: inherit; + text-decoration-line: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; + text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796); + text-decoration-style: solid; +} + +#subtitleRoot .word.word-jlpt-n2 { + color: inherit; + text-decoration-line: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; + text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f); + text-decoration-style: solid; +} + +#subtitleRoot .word.word-jlpt-n3 { + color: inherit; + text-decoration-line: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; + text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af); + text-decoration-style: solid; +} + +#subtitleRoot .word.word-jlpt-n4 { + color: inherit; + text-decoration-line: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; + text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1); + text-decoration-style: solid; +} + +#subtitleRoot .word.word-jlpt-n5 { + color: inherit; + text-decoration-line: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; + text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4); + text-decoration-style: solid; +} + +#subtitleRoot .word.word-frequency-single, +#subtitleRoot .word.word-frequency-band-1, +#subtitleRoot .word.word-frequency-band-2, +#subtitleRoot .word.word-frequency-band-3, +#subtitleRoot .word.word-frequency-band-4, +#subtitleRoot .word.word-frequency-band-5 { + text-shadow: 0 0 6px rgba(255, 255, 255, 0.3); +} + +#subtitleRoot .word.word-frequency-single { + color: var(--subtitle-frequency-single-color, #f5a97f); +} + +#subtitleRoot .word.word-frequency-band-1 { + color: var(--subtitle-frequency-band-1-color, #ed8796); +} + +#subtitleRoot .word.word-frequency-band-2 { + color: var(--subtitle-frequency-band-2-color, #f5a97f); +} + +#subtitleRoot .word.word-frequency-band-3 { + color: var(--subtitle-frequency-band-3-color, #f9e2af); +} + +#subtitleRoot .word.word-frequency-band-4 { + color: var(--subtitle-frequency-band-4-color, #a6e3a1); +} + +#subtitleRoot .word.word-frequency-band-5 { + color: var(--subtitle-frequency-band-5-color, #8aadf4); +} + +#subtitleRoot .word:hover { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +#subtitleRoot br { + display: block; + content: ''; + margin-bottom: 0.3em; +} + +#subtitleRoot.has-selection .word:hover, +#subtitleRoot.has-selection .c:hover { + background: transparent; +} + +body.layer-invisible #subtitleContainer { + background: transparent !important; + border: 0 !important; + padding: 0 !important; + border-radius: 0 !important; + position: relative; + z-index: 3; +} + +body.layer-invisible #subtitleRoot, +body.layer-invisible #subtitleRoot .word, +body.layer-invisible #subtitleRoot .c { + color: transparent !important; + text-shadow: none !important; + -webkit-text-stroke: 0 !important; + -webkit-text-fill-color: transparent !important; + background: transparent !important; + caret-color: transparent !important; + line-height: normal !important; + font-kerning: auto; + letter-spacing: normal; + font-variant-ligatures: normal; + font-feature-settings: normal; + text-rendering: auto; +} + +body.layer-invisible #subtitleRoot br { + margin-bottom: 0 !important; +} + +body.layer-invisible #subtitleRoot .word:hover, +body.layer-invisible #subtitleRoot .c:hover, +body.layer-invisible #subtitleRoot.has-selection .word:hover, +body.layer-invisible #subtitleRoot.has-selection .c:hover { + background: transparent !important; +} + +body.layer-invisible #subtitleRoot::selection, +body.layer-invisible #subtitleRoot .word::selection, +body.layer-invisible #subtitleRoot .c::selection { + background: transparent !important; + color: transparent !important; +} + +body.layer-invisible.debug-invisible-visualization #subtitleRoot, +body.layer-invisible.debug-invisible-visualization #subtitleRoot .word, +body.layer-invisible.debug-invisible-visualization #subtitleRoot .c { + color: #ed8796 !important; + -webkit-text-fill-color: #ed8796 !important; + -webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important; + paint-order: stroke fill !important; + text-shadow: none !important; +} + +.invisible-position-edit-hud { + position: absolute; + top: 14px; + left: 50%; + transform: translateX(-50%); + z-index: 30; + max-width: min(90vw, 1100px); + padding: 6px 10px; + border-radius: 8px; + font-size: 12px; + line-height: 1.35; + color: rgba(255, 255, 255, 0.95); + background: rgba(22, 24, 36, 0.88); + border: 1px solid rgba(130, 150, 255, 0.55); + pointer-events: none; + opacity: 0; + transition: opacity 120ms ease; +} + +body.layer-invisible.invisible-position-edit .invisible-position-edit-hud { + opacity: 1; +} + +body.layer-invisible.invisible-position-edit #subtitleRoot, +body.layer-invisible.invisible-position-edit #subtitleRoot .word, +body.layer-invisible.invisible-position-edit #subtitleRoot .c { + color: #ed8796 !important; + -webkit-text-fill-color: #ed8796 !important; + -webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important; + paint-order: stroke fill !important; + text-shadow: none !important; +} + +#secondarySubContainer { + position: absolute; + top: 40px; + left: 50%; + transform: translateX(-50%); + max-width: 80%; + padding: 10px 18px; + background: transparent; + border-radius: 8px; + pointer-events: auto; +} + +body.layer-visible #secondarySubContainer, +body.layer-invisible #secondarySubContainer { + display: none !important; + pointer-events: none !important; +} + +body.layer-secondary #subtitleContainer, +body.layer-secondary .modal, +body.layer-secondary .overlay-error-toast { + display: none !important; + pointer-events: none !important; +} + +body.layer-secondary #overlay { + justify-content: flex-start; + align-items: stretch; +} + +body.layer-secondary #secondarySubContainer { + position: absolute; + inset: 0; + top: 0; + left: 0; + right: 0; + transform: none; + max-width: 100%; + border-radius: 0; + background: transparent; + padding: 8px 12px; + display: flex; + align-items: center; + justify-content: center; +} + +#secondarySubRoot { + text-align: center; + font-size: 24px; + line-height: 1.5; + color: #ffffff; + -webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7); + paint-order: stroke fill; + text-shadow: + 0 2px 4px rgba(0, 0, 0, 0.95), + 0 0 8px rgba(0, 0, 0, 0.8), + 0 0 16px rgba(0, 0, 0, 0.55); + user-select: text; + cursor: text; +} + +body.layer-secondary #secondarySubRoot { + max-width: 100%; +} + +#secondarySubRoot:empty { + display: none; +} + +#secondarySubContainer:has(#secondarySubRoot:empty) { + display: none; +} + +body.settings-modal-open #secondarySubContainer { + display: none !important; + pointer-events: none !important; +} + +.secondary-sub-hidden { + display: none !important; +} + +#secondarySubContainer.secondary-sub-hover { + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: auto; + top: 0; + left: 0; + right: 0; + transform: none; + max-width: 100%; + background: transparent; + padding: 40px 0 0 0; + border-radius: 0; + display: flex; + justify-content: center; +} + +#secondarySubContainer.secondary-sub-hover #secondarySubRoot { + background: transparent; + border-radius: 8px; + padding: 10px 18px; +} + +#secondarySubContainer.secondary-sub-hover:hover { + opacity: 1; +} + +body.layer-secondary #secondarySubContainer.secondary-sub-hover { + padding: 8px 12px; + align-items: center; +} + +iframe[id^='yomitan-popup'] { + pointer-events: auto !important; + z-index: 2147483647 !important; +} + +.kiku-info-text { + font-size: 14px; + color: rgba(255, 255, 255, 0.8); + line-height: 1.5; +} + +.kiku-cards-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.kiku-card { + background: rgba(40, 40, 40, 0.8); + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + cursor: pointer; + transition: border-color 0.15s; + outline: none; +} + +.kiku-card:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.kiku-card.active { + border-color: rgba(100, 180, 255, 0.8); + background: rgba(40, 60, 90, 0.5); +} + +.kiku-card-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.5); + font-weight: 600; +} + +.kiku-card.active .kiku-card-label { + color: rgba(100, 180, 255, 0.9); +} + +.kiku-card-expression { + font-size: 22px; + font-weight: 600; + color: #fff; +} + +.kiku-card-sentence { + font-size: 14px; + color: rgba(255, 255, 255, 0.75); + max-height: 52px; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.kiku-card-meta { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + display: flex; + gap: 10px; +} + +.kiku-footer { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 10px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.kiku-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.kiku-preview-title { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.88); +} + +.kiku-preview-toggle { + display: inline-flex; + gap: 6px; +} + +.kiku-preview-toggle button { + padding: 5px 10px; + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 12px; +} + +.kiku-preview-toggle button.active { + border-color: rgba(100, 180, 255, 0.45); + background: rgba(100, 180, 255, 0.16); + color: rgba(100, 180, 255, 0.95); +} + +.kiku-preview-json { + margin: 0; + min-height: 220px; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(0, 0, 0, 0.34); + padding: 10px; + font-size: 11px; + line-height: 1.45; + color: rgba(255, 255, 255, 0.88); +} + +.kiku-preview-error { + margin: 0 0 10px; + font-size: 12px; + color: #ff8f8f; +} + +.kiku-delete-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + user-select: none; +} + +.kiku-delete-toggle input { + accent-color: rgba(100, 180, 255, 0.9); +} + +.kiku-confirm-button { + padding: 8px 20px; + border-radius: 6px; + border: 1px solid rgba(100, 180, 255, 0.4); + background: rgba(100, 180, 255, 0.15); + color: rgba(100, 180, 255, 0.95); + font-weight: 600; + cursor: pointer; +} + +.kiku-confirm-button:hover { + background: rgba(100, 180, 255, 0.25); +} + +.subsync-modal-content { + width: min(560px, 92%); +} + +.subsync-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.subsync-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + color: rgba(255, 255, 255, 0.85); +} + +.subsync-radio { + display: inline-flex; + align-items: center; + gap: 8px; + margin-right: 14px; + font-size: 13px; + color: rgba(255, 255, 255, 0.8); +} + +.subsync-field select { + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #fff; + padding: 8px 10px; +} + +.subsync-footer { + display: flex; + justify-content: flex-end; +} + +.kiku-cancel-button { + padding: 8px 16px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: transparent; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; +} + +.kiku-cancel-button:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.kiku-hint { + text-align: center; + font-size: 11px; + color: rgba(255, 255, 255, 0.35); +} + +.runtime-modal-content { + width: min(560px, 92%); +} + +.runtime-options-hint { + font-size: 12px; + color: rgba(255, 255, 255, 0.65); +} + +.runtime-options-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + max-height: 320px; + overflow-y: auto; +} + +.runtime-options-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + cursor: pointer; +} + +.runtime-options-item:last-child { + border-bottom: none; +} + +.runtime-options-item.active { + background: rgba(100, 180, 255, 0.15); +} + +.runtime-options-label { + font-size: 14px; + color: #fff; +} + +.runtime-options-value { + font-size: 13px; + color: rgba(100, 180, 255, 0.9); +} + +.runtime-options-allowed { + font-size: 11px; + color: rgba(255, 255, 255, 0.55); +} + +.runtime-options-status { + min-height: 18px; + font-size: 12px; + color: rgba(255, 255, 255, 0.75); +} + +.runtime-options-status.error { + color: #ff8f8f; +} + +.session-help-content { + width: min(760px, 92%); + max-height: 84%; + color: rgba(255, 255, 255, 0.95); +} + +.session-help-shortcut, +.session-help-warning, +.session-help-status { + min-height: 18px; + font-size: 13px; + color: rgba(255, 255, 255, 0.8); + line-height: 1.45; +} + +.session-help-shortcut { + font-weight: 600; + color: rgba(255, 255, 255, 0.97); +} + +.session-help-warning { + color: #f8a100; +} + +.session-help-content-list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: calc(84vh - 220px); + overflow-y: auto; + padding-right: 4px; +} + +.session-help-filter { + width: 100%; + min-height: 32px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.45); + color: #fff; + font-size: 13px; + line-height: 1.2; +} + +.session-help-filter::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.session-help-filter:focus { + outline: none; + border-color: rgba(137, 180, 255, 0.6); + box-shadow: 0 0 0 2px rgba(137, 180, 255, 0.2); +} + +.session-help-content-no-results { + color: rgba(255, 255, 255, 0.75); + padding: 12px; + font-size: 13px; +} + +.session-help-section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(1px); +} + +.session-help-section-title { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; + color: rgba(255, 255, 255, 0.55); + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 0 4px; +} + +.session-help-item-list { + display: flex; + flex-direction: column; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + overflow: hidden; +} + +.session-help-item { + width: 100%; + min-height: 42px; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + text-align: left; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: transparent; + color: #fff; + cursor: pointer; +} + +.session-help-item:last-child { + border-bottom: none; +} + +.session-help-item:hover, +.session-help-item:focus-visible, +.session-help-item.active { + background: rgba(137, 180, 255, 0.2); + outline: none; +} + +.session-help-item.active { + box-shadow: inset 3px 0 0 0 rgba(137, 180, 255, 0.9); +} + +.session-help-item-left { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; +} + +.session-help-item-right { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.session-help-key { + font-size: 12px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; + padding: 4px 9px; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + border-radius: 999px; + background: rgba(137, 180, 255, 0.16); + border: 1px solid rgba(137, 180, 255, 0.35); + letter-spacing: 0.01em; +} + +.session-help-action { + font-size: 13px; + color: rgba(255, 255, 255, 0.84); + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.35; +} + +.session-help-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + flex: 0 0 auto; +} + +@media (max-width: 640px) { + .kiku-cards-container { + grid-template-columns: 1fr; + } + + .session-help-content-list { + max-height: calc(84vh - 190px); + } + + .session-help-item { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } + + .session-help-item-right { + justify-content: flex-start; + width: 100%; + } + + .session-help-key { + width: 100%; + justify-content: center; + text-align: center; + } +} diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts new file mode 100644 index 0000000..f257368 --- /dev/null +++ b/src/renderer/subtitle-render.test.ts @@ -0,0 +1,293 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { MergedToken } from '../types'; +import { PartOfSpeech } from '../types.js'; +import { alignTokensToSourceText, computeWordClass, normalizeSubtitle } from './subtitle-render.js'; + +function createToken(overrides: Partial): MergedToken { + return { + surface: '', + reading: '', + headword: '', + startPos: 0, + endPos: 0, + partOfSpeech: PartOfSpeech.other, + isMerged: true, + isKnown: false, + isNPlusOneTarget: false, + ...overrides, + }; +} + +function extractClassBlock(cssText: string, selector: string): string { + const ruleRegex = /([^{}]+)\{([^}]*)\}/g; + let match: RegExpExecArray | null = null; + let fallbackBlock = ''; + + while ((match = ruleRegex.exec(cssText)) !== null) { + const selectorsBlock = match[1]?.trim() ?? ''; + const selectorBlock = match[2] ?? ''; + + const selectors = selectorsBlock + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (selectors.includes(selector)) { + if (selectors.length === 1) { + return selectorBlock; + } + + if (!fallbackBlock) { + fallbackBlock = selectorBlock; + } + } + } + + if (fallbackBlock) { + return fallbackBlock; + } + + return ''; +} + +test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => { + const knownJlpt = createToken({ + isKnown: true, + jlptLevel: 'N1', + surface: '猫', + }); + const nPlusOneJlpt = createToken({ + isNPlusOneTarget: true, + jlptLevel: 'N2', + surface: '犬', + }); + + assert.equal(computeWordClass(knownJlpt), 'word word-known word-jlpt-n1'); + assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2'); +}); + +test('computeWordClass does not add frequency class to known or N+1 terms', () => { + const known = createToken({ + isKnown: true, + frequencyRank: 10, + surface: '既知', + }); + const nPlusOne = createToken({ + isNPlusOneTarget: true, + frequencyRank: 10, + surface: '目標', + }); + const frequency = createToken({ + frequencyRank: 10, + surface: '頻度', + }); + + assert.equal( + computeWordClass(known, { + enabled: true, + topX: 100, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }), + 'word word-known', + ); + assert.equal( + computeWordClass(nPlusOne, { + enabled: true, + topX: 100, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }), + 'word word-n-plus-one', + ); + assert.equal( + computeWordClass(frequency, { + enabled: true, + topX: 100, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }), + 'word word-frequency-single', + ); +}); + +test('computeWordClass adds frequency class for single mode when rank is within topX', () => { + const token = createToken({ + surface: '猫', + frequencyRank: 50, + }); + + const actual = computeWordClass(token, { + enabled: true, + topX: 100, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }); + + assert.equal(actual, 'word word-frequency-single'); +}); + +test('computeWordClass adds frequency class when rank equals topX', () => { + const token = createToken({ + surface: '水', + frequencyRank: 100, + }); + + const actual = computeWordClass(token, { + enabled: true, + topX: 100, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }); + + assert.equal(actual, 'word word-frequency-single'); +}); + +test('computeWordClass adds frequency class for banded mode', () => { + const token = createToken({ + surface: '犬', + frequencyRank: 250, + }); + + const actual = computeWordClass(token, { + enabled: true, + topX: 1000, + mode: 'banded', + singleColor: '#000000', + bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'] as const, + }); + + assert.equal(actual, 'word word-frequency-band-2'); +}); + +test('computeWordClass uses configured band count for banded mode', () => { + const token = createToken({ + surface: '犬', + frequencyRank: 2, + }); + + const actual = computeWordClass(token, { + enabled: true, + topX: 4, + mode: 'banded', + singleColor: '#000000', + bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'], + } as any); + + assert.equal(actual, 'word word-frequency-band-3'); +}); + +test('computeWordClass skips frequency class when rank is out of topX', () => { + const token = createToken({ + surface: '犬', + frequencyRank: 1200, + }); + + const actual = computeWordClass(token, { + enabled: true, + topX: 1000, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }); + + assert.equal(actual, 'word'); +}); + +test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => { + const tokens = [ + createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }), + createToken({ surface: 'かかってこい', reading: 'かかってこい', headword: 'かかってこい' }), + ]; + + const segments = alignTokensToSourceText(tokens, 'キリキリと\nかかってこい'); + assert.deepEqual( + segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')), + ['token', 'text:\n', 'token'], + ); +}); + +test('alignTokensToSourceText treats whitespace-only token surfaces as plain text separators', () => { + const tokens = [ + createToken({ surface: '常人が使えば' }), + createToken({ surface: ' ' }), + createToken({ surface: 'その圧倒的な力に' }), + createToken({ surface: '\n' }), + createToken({ surface: '体が耐えきれず死に至るが…' }), + ]; + + const segments = alignTokensToSourceText(tokens, '常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…'); + assert.deepEqual( + segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')), + ['token', 'text: ', 'token', 'text:\n', 'token'], + ); +}); + +test('alignTokensToSourceText avoids duplicate tail when later token surface does not match source', () => { + const tokens = [ + createToken({ surface: '君たちが潰した拠点に' }), + createToken({ surface: '教団の主力は1人もいない' }), + ]; + + const segments = alignTokensToSourceText( + tokens, + '君たちが潰した拠点に\n教団の主力は1人もいない', + ); + assert.deepEqual( + segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')), + ['token', 'text:\n教団の主力は1人もいない'], + ); +}); + +test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => { + assert.equal( + normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true), + '常人が使えば その圧倒的な力に 体が耐えきれず死に至るが…', + ); +}); + +test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { + const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css'); + const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css'); + + const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath; + if (!fs.existsSync(cssPath)) { + assert.fail( + 'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.', + ); + } + + const cssText = fs.readFileSync(cssPath, 'utf-8'); + + for (let level = 1; level <= 5; level += 1) { + const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`); + assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`); + assert.match(block, /text-decoration-line:\s*underline;/); + assert.match(block, /text-decoration-thickness:\s*2px;/); + assert.match(block, /text-underline-offset:\s*4px;/); + assert.match(block, /color:\s*inherit;/); + } + + for (let band = 1; band <= 5; band += 1) { + const block = extractClassBlock( + cssText, + band === 1 + ? '#subtitleRoot .word.word-frequency-single' + : `#subtitleRoot .word.word-frequency-band-${band}`, + ); + assert.ok( + block.length > 0, + `frequency class word-frequency-${band === 1 ? 'single' : `band-${band}`} should exist`, + ); + assert.match(block, /color:\s*var\(/); + } +}); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts new file mode 100644 index 0000000..9498f2b --- /dev/null +++ b/src/renderer/subtitle-render.ts @@ -0,0 +1,540 @@ +import type { MergedToken, SecondarySubMode, SubtitleData, SubtitleStyleConfig } from '../types'; +import type { RendererContext } from './context'; + +type FrequencyRenderSettings = { + enabled: boolean; + topX: number; + mode: 'single' | 'banded'; + singleColor: string; + bandedColors: [string, string, string, string, string]; +}; + +function isWhitespaceOnly(value: string): boolean { + return value.trim().length === 0; +} + +export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string { + if (!text) return ''; + + let normalized = text.replace(/\\N/g, '\n').replace(/\\n/g, '\n'); + normalized = normalized.replace(/\{[^}]*\}/g, ''); + if (collapseLineBreaks) { + normalized = normalized.replace(/\n/g, ' '); + normalized = normalized.replace(/\s+/g, ' '); + } + + return trim ? normalized.trim() : normalized; +} + +const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +function sanitizeHexColor(value: unknown, fallback: string): string { + return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim()) + ? value.trim() + : fallback; +} + +const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = { + enabled: false, + topX: 1000, + mode: 'single', + singleColor: '#f5a97f', + bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'], +}; + +function sanitizeFrequencyTopX(value: unknown, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return Math.max(1, Math.floor(value)); +} + +function sanitizeFrequencyBandedColors( + value: unknown, + fallback: FrequencyRenderSettings['bandedColors'], +): FrequencyRenderSettings['bandedColors'] { + if (!Array.isArray(value) || value.length !== 5) { + return fallback; + } + + return [ + sanitizeHexColor(value[0], fallback[0]), + sanitizeHexColor(value[1], fallback[1]), + sanitizeHexColor(value[2], fallback[2]), + sanitizeHexColor(value[3], fallback[3]), + sanitizeHexColor(value[4], fallback[4]), + ]; +} + +function getFrequencyDictionaryClass( + token: MergedToken, + settings: FrequencyRenderSettings, +): string { + if (!settings.enabled) { + return ''; + } + + if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) { + return ''; + } + + const rank = Math.max(1, Math.floor(token.frequencyRank)); + const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX); + if (rank > topX) { + return ''; + } + + if (settings.mode === 'banded') { + const bandCount = settings.bandedColors.length; + const normalizedBand = Math.ceil((rank / topX) * bandCount); + const band = Math.min(bandCount, Math.max(1, normalizedBand)); + return `word-frequency-band-${band}`; + } + + return 'word-frequency-single'; +} + +function renderWithTokens( + root: HTMLElement, + tokens: MergedToken[], + frequencyRenderSettings?: Partial, + sourceText?: string, + preserveLineBreaks = false, +): void { + const resolvedFrequencyRenderSettings = { + ...DEFAULT_FREQUENCY_RENDER_SETTINGS, + ...frequencyRenderSettings, + bandedColors: sanitizeFrequencyBandedColors( + frequencyRenderSettings?.bandedColors, + DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors, + ), + topX: sanitizeFrequencyTopX( + frequencyRenderSettings?.topX, + DEFAULT_FREQUENCY_RENDER_SETTINGS.topX, + ), + singleColor: sanitizeHexColor( + frequencyRenderSettings?.singleColor, + DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor, + ), + }; + + const fragment = document.createDocumentFragment(); + + if (preserveLineBreaks && sourceText) { + const normalizedSource = normalizeSubtitle(sourceText, true, false); + const segments = alignTokensToSourceText(tokens, normalizedSource); + + for (const segment of segments) { + if (segment.kind === 'text') { + renderPlainTextPreserveLineBreaks(fragment, segment.text); + continue; + } + + const token = segment.token; + const span = document.createElement('span'); + span.className = computeWordClass(token, resolvedFrequencyRenderSettings); + span.textContent = token.surface; + span.dataset.tokenIndex = String(segment.tokenIndex); + if (token.reading) span.dataset.reading = token.reading; + if (token.headword) span.dataset.headword = token.headword; + fragment.appendChild(span); + } + + root.appendChild(fragment); + return; + } + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + const surface = token.surface.replace(/\n/g, ' '); + if (!surface) { + continue; + } + + if (isWhitespaceOnly(surface)) { + fragment.appendChild(document.createTextNode(surface)); + continue; + } + + const span = document.createElement('span'); + span.className = computeWordClass(token, resolvedFrequencyRenderSettings); + span.textContent = surface; + span.dataset.tokenIndex = String(index); + if (token.reading) span.dataset.reading = token.reading; + if (token.headword) span.dataset.headword = token.headword; + fragment.appendChild(span); + } + + root.appendChild(fragment); +} + +type SubtitleRenderSegment = + | { kind: 'text'; text: string } + | { kind: 'token'; token: MergedToken; tokenIndex: number }; + +export function alignTokensToSourceText( + tokens: MergedToken[], + sourceText: string, +): SubtitleRenderSegment[] { + if (tokens.length === 0) { + return sourceText ? [{ kind: 'text', text: sourceText }] : []; + } + + const segments: SubtitleRenderSegment[] = []; + let cursor = 0; + + for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) { + const token = tokens[tokenIndex]; + if (!token) { + continue; + } + const surface = token.surface; + if (!surface || isWhitespaceOnly(surface)) { + continue; + } + + const foundIndex = sourceText.indexOf(surface, cursor); + if (foundIndex < 0) { + // Token text can diverge from source normalization (e.g., half/full-width forms). + // Skip unmatched token to avoid duplicating visible tail text in preserve-line-break mode. + continue; + } + + if (foundIndex > cursor) { + segments.push({ kind: 'text', text: sourceText.slice(cursor, foundIndex) }); + } + + segments.push({ kind: 'token', token, tokenIndex }); + cursor = foundIndex + surface.length; + } + + if (cursor < sourceText.length) { + segments.push({ kind: 'text', text: sourceText.slice(cursor) }); + } + + return segments; +} + +export function computeWordClass( + token: MergedToken, + frequencySettings?: Partial, +): string { + const resolvedFrequencySettings = { + ...DEFAULT_FREQUENCY_RENDER_SETTINGS, + ...frequencySettings, + bandedColors: sanitizeFrequencyBandedColors( + frequencySettings?.bandedColors, + DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors, + ), + topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX), + singleColor: sanitizeHexColor( + frequencySettings?.singleColor, + DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor, + ), + }; + + const classes = ['word']; + + if (token.isNPlusOneTarget) { + classes.push('word-n-plus-one'); + } else if (token.isKnown) { + classes.push('word-known'); + } + + if (token.jlptLevel) { + classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`); + } + + if (!token.isKnown && !token.isNPlusOneTarget) { + const frequencyClass = getFrequencyDictionaryClass(token, resolvedFrequencySettings); + if (frequencyClass) { + classes.push(frequencyClass); + } + } + + return classes.join(' '); +} + +function renderCharacterLevel(root: HTMLElement, text: string): void { + const fragment = document.createDocumentFragment(); + + for (const char of text) { + if (char === '\n') { + fragment.appendChild(document.createElement('br')); + continue; + } + const span = document.createElement('span'); + span.className = 'c'; + span.textContent = char; + fragment.appendChild(span); + } + + root.appendChild(fragment); +} + +function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void { + const lines = text.split('\n'); + const fragment = document.createDocumentFragment(); + + for (let i = 0; i < lines.length; i += 1) { + fragment.appendChild(document.createTextNode(lines[i] ?? '')); + if (i < lines.length - 1) { + fragment.appendChild(document.createElement('br')); + } + } + + root.appendChild(fragment); +} + +export function createSubtitleRenderer(ctx: RendererContext) { + function renderSubtitle(data: SubtitleData | string): void { + ctx.dom.subtitleRoot.innerHTML = ''; + ctx.state.lastHoverSelectionKey = ''; + ctx.state.lastHoverSelectionNode = null; + ctx.state.lastHoveredTokenIndex = null; + + let text: string; + let tokens: MergedToken[] | null; + + if (typeof data === 'string') { + text = data; + tokens = null; + } else if (data && typeof data === 'object') { + text = data.text; + tokens = data.tokens; + } else { + return; + } + + if (!text) return; + + if (ctx.platform.isInvisibleLayer) { + const normalizedInvisible = normalizeSubtitle(text, false); + ctx.state.currentInvisibleSubtitleLineCount = Math.max( + 1, + normalizedInvisible.split('\n').length, + ); + if (tokens && tokens.length > 0) { + renderWithTokens( + ctx.dom.subtitleRoot, + tokens, + getFrequencyRenderSettings(), + text, + true, + ); + } else { + renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible); + } + return; + } + + const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks); + if (tokens && tokens.length > 0) { + renderWithTokens( + ctx.dom.subtitleRoot, + tokens, + getFrequencyRenderSettings(), + text, + ctx.state.preserveSubtitleLineBreaks, + ); + return; + } + renderCharacterLevel(ctx.dom.subtitleRoot, normalized); + } + + function getFrequencyRenderSettings(): Partial { + return { + enabled: ctx.state.frequencyDictionaryEnabled, + topX: ctx.state.frequencyDictionaryTopX, + mode: ctx.state.frequencyDictionaryMode, + singleColor: ctx.state.frequencyDictionarySingleColor, + bandedColors: [ + ctx.state.frequencyDictionaryBand1Color, + ctx.state.frequencyDictionaryBand2Color, + ctx.state.frequencyDictionaryBand3Color, + ctx.state.frequencyDictionaryBand4Color, + ctx.state.frequencyDictionaryBand5Color, + ] as [string, string, string, string, string], + }; + } + + function renderSecondarySub(text: string): void { + ctx.dom.secondarySubRoot.innerHTML = ''; + if (!text) return; + + const normalized = text + .replace(/\\N/g, '\n') + .replace(/\\n/g, '\n') + .replace(/\{[^}]*\}/g, '') + .trim(); + + if (!normalized) return; + + const lines = normalized.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (line) { + ctx.dom.secondarySubRoot.appendChild(document.createTextNode(line)); + } + if (i < lines.length - 1) { + ctx.dom.secondarySubRoot.appendChild(document.createElement('br')); + } + } + } + + function updateSecondarySubMode(mode: SecondarySubMode): void { + ctx.dom.secondarySubContainer.classList.remove( + 'secondary-sub-hidden', + 'secondary-sub-visible', + 'secondary-sub-hover', + ); + ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`); + } + + function applySubtitleFontSize(fontSize: number): void { + const clampedSize = Math.max(10, fontSize); + ctx.dom.subtitleRoot.style.fontSize = `${clampedSize}px`; + document.documentElement.style.setProperty('--subtitle-font-size', `${clampedSize}px`); + } + + function applySubtitleStyle(style: SubtitleStyleConfig | null): void { + if (!style) return; + + if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily; + if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`; + if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor; + if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight; + if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle; + if (style.backgroundColor) { + ctx.dom.subtitleContainer.style.background = style.backgroundColor; + } + + const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95'; + const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6'; + const jlptColors = { + N1: ctx.state.jlptN1Color ?? '#ed8796', + N2: ctx.state.jlptN2Color ?? '#f5a97f', + N3: ctx.state.jlptN3Color ?? '#f9e2af', + N4: ctx.state.jlptN4Color ?? '#a6e3a1', + N5: ctx.state.jlptN5Color ?? '#8aadf4', + ...(style.jlptColors + ? { + N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color), + N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color), + N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color), + N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color), + N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color), + } + : {}), + }; + + ctx.state.knownWordColor = knownWordColor; + ctx.state.nPlusOneColor = nPlusOneColor; + ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor); + ctx.state.jlptN1Color = jlptColors.N1; + ctx.state.jlptN2Color = jlptColors.N2; + ctx.state.jlptN3Color = jlptColors.N3; + ctx.state.jlptN4Color = jlptColors.N4; + ctx.state.jlptN5Color = jlptColors.N5; + ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false; + ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n4-color', jlptColors.N4); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n5-color', jlptColors.N5); + const frequencyDictionarySettings = style.frequencyDictionary ?? {}; + const frequencyEnabled = + frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled; + const frequencyTopX = sanitizeFrequencyTopX( + frequencyDictionarySettings.topX, + ctx.state.frequencyDictionaryTopX, + ); + const frequencyMode = frequencyDictionarySettings.mode + ? frequencyDictionarySettings.mode + : ctx.state.frequencyDictionaryMode; + const frequencySingleColor = sanitizeHexColor( + frequencyDictionarySettings.singleColor, + ctx.state.frequencyDictionarySingleColor, + ); + const frequencyBandedColors = sanitizeFrequencyBandedColors( + frequencyDictionarySettings.bandedColors, + [ + ctx.state.frequencyDictionaryBand1Color, + ctx.state.frequencyDictionaryBand2Color, + ctx.state.frequencyDictionaryBand3Color, + ctx.state.frequencyDictionaryBand4Color, + ctx.state.frequencyDictionaryBand5Color, + ] as [string, string, string, string, string], + ); + + ctx.state.frequencyDictionaryEnabled = frequencyEnabled; + ctx.state.frequencyDictionaryTopX = frequencyTopX; + ctx.state.frequencyDictionaryMode = frequencyMode; + ctx.state.frequencyDictionarySingleColor = frequencySingleColor; + [ + ctx.state.frequencyDictionaryBand1Color, + ctx.state.frequencyDictionaryBand2Color, + ctx.state.frequencyDictionaryBand3Color, + ctx.state.frequencyDictionaryBand4Color, + ctx.state.frequencyDictionaryBand5Color, + ] = frequencyBandedColors; + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-frequency-single-color', + frequencySingleColor, + ); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-frequency-band-1-color', + frequencyBandedColors[0], + ); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-frequency-band-2-color', + frequencyBandedColors[1], + ); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-frequency-band-3-color', + frequencyBandedColors[2], + ); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-frequency-band-4-color', + frequencyBandedColors[3], + ); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-frequency-band-5-color', + frequencyBandedColors[4], + ); + + const secondaryStyle = style.secondary; + if (!secondaryStyle) return; + + if (secondaryStyle.fontFamily) { + ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily; + } + if (secondaryStyle.fontSize) { + ctx.dom.secondarySubRoot.style.fontSize = `${secondaryStyle.fontSize}px`; + } + if (secondaryStyle.fontColor) { + ctx.dom.secondarySubRoot.style.color = secondaryStyle.fontColor; + } + if (secondaryStyle.fontWeight) { + ctx.dom.secondarySubRoot.style.fontWeight = secondaryStyle.fontWeight; + } + if (secondaryStyle.fontStyle) { + ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle; + } + if (secondaryStyle.backgroundColor) { + ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor; + } + } + + return { + applySubtitleFontSize, + applySubtitleStyle, + renderSecondarySub, + renderSubtitle, + updateSecondarySubMode, + }; +} diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts new file mode 100644 index 0000000..d415350 --- /dev/null +++ b/src/renderer/utils/dom.ts @@ -0,0 +1,143 @@ +export type RendererDom = { + subtitleRoot: HTMLElement; + subtitleContainer: HTMLElement; + overlay: HTMLElement; + overlayErrorToast: HTMLDivElement; + secondarySubContainer: HTMLElement; + secondarySubRoot: HTMLElement; + + jimakuModal: HTMLDivElement; + jimakuTitleInput: HTMLInputElement; + jimakuSeasonInput: HTMLInputElement; + jimakuEpisodeInput: HTMLInputElement; + jimakuSearchButton: HTMLButtonElement; + jimakuCloseButton: HTMLButtonElement; + jimakuStatus: HTMLDivElement; + jimakuEntriesSection: HTMLDivElement; + jimakuEntriesList: HTMLUListElement; + jimakuFilesSection: HTMLDivElement; + jimakuFilesList: HTMLUListElement; + jimakuBroadenButton: HTMLButtonElement; + + kikuModal: HTMLDivElement; + kikuCard1: HTMLDivElement; + kikuCard2: HTMLDivElement; + kikuCard1Expression: HTMLDivElement; + kikuCard2Expression: HTMLDivElement; + kikuCard1Sentence: HTMLDivElement; + kikuCard2Sentence: HTMLDivElement; + kikuCard1Meta: HTMLDivElement; + kikuCard2Meta: HTMLDivElement; + kikuConfirmButton: HTMLButtonElement; + kikuCancelButton: HTMLButtonElement; + kikuDeleteDuplicateCheckbox: HTMLInputElement; + kikuSelectionStep: HTMLDivElement; + kikuPreviewStep: HTMLDivElement; + kikuPreviewJson: HTMLPreElement; + kikuPreviewCompactButton: HTMLButtonElement; + kikuPreviewFullButton: HTMLButtonElement; + kikuPreviewError: HTMLDivElement; + kikuBackButton: HTMLButtonElement; + kikuFinalConfirmButton: HTMLButtonElement; + kikuFinalCancelButton: HTMLButtonElement; + kikuHint: HTMLDivElement; + + runtimeOptionsModal: HTMLDivElement; + runtimeOptionsClose: HTMLButtonElement; + runtimeOptionsList: HTMLUListElement; + runtimeOptionsStatus: HTMLDivElement; + + subsyncModal: HTMLDivElement; + subsyncCloseButton: HTMLButtonElement; + subsyncEngineAlass: HTMLInputElement; + subsyncEngineFfsubsync: HTMLInputElement; + subsyncSourceLabel: HTMLLabelElement; + subsyncSourceSelect: HTMLSelectElement; + subsyncRunButton: HTMLButtonElement; + subsyncStatus: HTMLDivElement; + + sessionHelpModal: HTMLDivElement; + sessionHelpClose: HTMLButtonElement; + sessionHelpShortcut: HTMLDivElement; + sessionHelpWarning: HTMLDivElement; + sessionHelpStatus: HTMLDivElement; + sessionHelpFilter: HTMLInputElement; + sessionHelpContent: HTMLDivElement; +}; + +function getRequiredElement(id: string): T { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Missing required DOM element #${id}`); + } + return element as T; +} + +export function resolveRendererDom(): RendererDom { + return { + subtitleRoot: getRequiredElement('subtitleRoot'), + subtitleContainer: getRequiredElement('subtitleContainer'), + overlay: getRequiredElement('overlay'), + overlayErrorToast: getRequiredElement('overlayErrorToast'), + secondarySubContainer: getRequiredElement('secondarySubContainer'), + secondarySubRoot: getRequiredElement('secondarySubRoot'), + + jimakuModal: getRequiredElement('jimakuModal'), + jimakuTitleInput: getRequiredElement('jimakuTitle'), + jimakuSeasonInput: getRequiredElement('jimakuSeason'), + jimakuEpisodeInput: getRequiredElement('jimakuEpisode'), + jimakuSearchButton: getRequiredElement('jimakuSearch'), + jimakuCloseButton: getRequiredElement('jimakuClose'), + jimakuStatus: getRequiredElement('jimakuStatus'), + jimakuEntriesSection: getRequiredElement('jimakuEntriesSection'), + jimakuEntriesList: getRequiredElement('jimakuEntries'), + jimakuFilesSection: getRequiredElement('jimakuFilesSection'), + jimakuFilesList: getRequiredElement('jimakuFiles'), + jimakuBroadenButton: getRequiredElement('jimakuBroaden'), + + kikuModal: getRequiredElement('kikuFieldGroupingModal'), + kikuCard1: getRequiredElement('kikuCard1'), + kikuCard2: getRequiredElement('kikuCard2'), + kikuCard1Expression: getRequiredElement('kikuCard1Expression'), + kikuCard2Expression: getRequiredElement('kikuCard2Expression'), + kikuCard1Sentence: getRequiredElement('kikuCard1Sentence'), + kikuCard2Sentence: getRequiredElement('kikuCard2Sentence'), + kikuCard1Meta: getRequiredElement('kikuCard1Meta'), + kikuCard2Meta: getRequiredElement('kikuCard2Meta'), + kikuConfirmButton: getRequiredElement('kikuConfirmButton'), + kikuCancelButton: getRequiredElement('kikuCancelButton'), + kikuDeleteDuplicateCheckbox: getRequiredElement('kikuDeleteDuplicate'), + kikuSelectionStep: getRequiredElement('kikuSelectionStep'), + kikuPreviewStep: getRequiredElement('kikuPreviewStep'), + kikuPreviewJson: getRequiredElement('kikuPreviewJson'), + kikuPreviewCompactButton: getRequiredElement('kikuPreviewCompact'), + kikuPreviewFullButton: getRequiredElement('kikuPreviewFull'), + kikuPreviewError: getRequiredElement('kikuPreviewError'), + kikuBackButton: getRequiredElement('kikuBackButton'), + kikuFinalConfirmButton: getRequiredElement('kikuFinalConfirmButton'), + kikuFinalCancelButton: getRequiredElement('kikuFinalCancelButton'), + kikuHint: getRequiredElement('kikuHint'), + + runtimeOptionsModal: getRequiredElement('runtimeOptionsModal'), + runtimeOptionsClose: getRequiredElement('runtimeOptionsClose'), + runtimeOptionsList: getRequiredElement('runtimeOptionsList'), + runtimeOptionsStatus: getRequiredElement('runtimeOptionsStatus'), + + subsyncModal: getRequiredElement('subsyncModal'), + subsyncCloseButton: getRequiredElement('subsyncClose'), + subsyncEngineAlass: getRequiredElement('subsyncEngineAlass'), + subsyncEngineFfsubsync: getRequiredElement('subsyncEngineFfsubsync'), + subsyncSourceLabel: getRequiredElement('subsyncSourceLabel'), + subsyncSourceSelect: getRequiredElement('subsyncSourceSelect'), + subsyncRunButton: getRequiredElement('subsyncRun'), + subsyncStatus: getRequiredElement('subsyncStatus'), + + sessionHelpModal: getRequiredElement('sessionHelpModal'), + sessionHelpClose: getRequiredElement('sessionHelpClose'), + sessionHelpShortcut: getRequiredElement('sessionHelpShortcut'), + sessionHelpWarning: getRequiredElement('sessionHelpWarning'), + sessionHelpStatus: getRequiredElement('sessionHelpStatus'), + sessionHelpFilter: getRequiredElement('sessionHelpFilter'), + sessionHelpContent: getRequiredElement('sessionHelpContent'), + }; +} diff --git a/src/renderer/utils/platform.ts b/src/renderer/utils/platform.ts new file mode 100644 index 0000000..72dfb44 --- /dev/null +++ b/src/renderer/utils/platform.ts @@ -0,0 +1,48 @@ +export type OverlayLayer = 'visible' | 'invisible' | 'secondary'; + +export type PlatformInfo = { + overlayLayer: OverlayLayer; + isInvisibleLayer: boolean; + isSecondaryLayer: boolean; + isLinuxPlatform: boolean; + isMacOSPlatform: boolean; + shouldToggleMouseIgnore: boolean; + invisiblePositionEditToggleCode: string; + invisiblePositionStepPx: number; + invisiblePositionStepFastPx: number; +}; + +export function resolvePlatformInfo(): PlatformInfo { + const overlayLayerFromPreload = window.electronAPI.getOverlayLayer(); + const queryLayer = new URLSearchParams(window.location.search).get('layer'); + const overlayLayerFromQuery: OverlayLayer | null = + queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary' + ? queryLayer + : null; + + const overlayLayer: OverlayLayer = + overlayLayerFromQuery ?? + (overlayLayerFromPreload === 'visible' || + overlayLayerFromPreload === 'invisible' || + overlayLayerFromPreload === 'secondary' + ? overlayLayerFromPreload + : 'visible'); + + const isInvisibleLayer = overlayLayer === 'invisible'; + const isSecondaryLayer = overlayLayer === 'secondary'; + const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux'); + const isMacOSPlatform = + navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent); + + return { + overlayLayer, + isInvisibleLayer, + isSecondaryLayer, + isLinuxPlatform, + isMacOSPlatform, + shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer, + invisiblePositionEditToggleCode: 'KeyP', + invisiblePositionStepPx: 1, + invisiblePositionStepFastPx: 4, + }; +} diff --git a/src/runtime-options.ts b/src/runtime-options.ts new file mode 100644 index 0000000..0b1ce70 --- /dev/null +++ b/src/runtime-options.ts @@ -0,0 +1,208 @@ +/* + * SubMiner - All-in-one sentence mining overlay + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { + AnkiConnectConfig, + RuntimeOptionApplyResult, + RuntimeOptionId, + RuntimeOptionState, + RuntimeOptionValue, +} from './types'; +import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from './config'; + +type RuntimeOverrides = Record; + +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function getPathValue(source: Record, path: string): unknown { + const parts = path.split('.'); + let current: unknown = source; + for (const part of parts) { + if (!current || typeof current !== 'object' || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +function setPathValue(target: Record, path: string, value: unknown): void { + const parts = path.split('.'); + let current = target; + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]!; + const isLeaf = i === parts.length - 1; + if (isLeaf) { + current[part] = value; + return; + } + + const next = current[part]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + current[part] = {}; + } + current = current[part] as Record; + } +} + +function allowedValues(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue[] { + return [...definition.allowedValues]; +} + +function isAllowedValue( + definition: RuntimeOptionRegistryEntry, + value: RuntimeOptionValue, +): boolean { + if (definition.valueType === 'boolean') { + return typeof value === 'boolean'; + } + return typeof value === 'string' && definition.allowedValues.includes(value); +} + +export class RuntimeOptionsManager { + private readonly getAnkiConfig: () => AnkiConnectConfig; + private readonly applyAnkiPatch: (patch: Partial) => void; + private readonly onOptionsChanged: (options: RuntimeOptionState[]) => void; + private runtimeOverrides: RuntimeOverrides = {}; + private readonly definitions = new Map(); + + constructor( + getAnkiConfig: () => AnkiConnectConfig, + callbacks: { + applyAnkiPatch: (patch: Partial) => void; + onOptionsChanged: (options: RuntimeOptionState[]) => void; + }, + ) { + this.getAnkiConfig = getAnkiConfig; + this.applyAnkiPatch = callbacks.applyAnkiPatch; + this.onOptionsChanged = callbacks.onOptionsChanged; + for (const definition of RUNTIME_OPTION_REGISTRY) { + this.definitions.set(definition.id, definition); + } + } + + private getEffectiveValue(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue { + const override = getPathValue(this.runtimeOverrides, definition.path); + if (override !== undefined) return override as RuntimeOptionValue; + + const source = { + ankiConnect: this.getAnkiConfig(), + } as Record; + + const raw = getPathValue(source, definition.path); + if (raw === undefined || raw === null) { + return definition.defaultValue; + } + return raw as RuntimeOptionValue; + } + + listOptions(): RuntimeOptionState[] { + const options: RuntimeOptionState[] = []; + for (const definition of RUNTIME_OPTION_REGISTRY) { + options.push({ + id: definition.id, + label: definition.label, + scope: definition.scope, + valueType: definition.valueType, + value: this.getEffectiveValue(definition), + allowedValues: allowedValues(definition), + requiresRestart: definition.requiresRestart, + }); + } + return options; + } + + getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined { + const definition = this.definitions.get(id); + if (!definition) return undefined; + return this.getEffectiveValue(definition); + } + + setOptionValue(id: RuntimeOptionId, value: RuntimeOptionValue): RuntimeOptionApplyResult { + const definition = this.definitions.get(id); + if (!definition) { + return { ok: false, error: `Unknown runtime option: ${id}` }; + } + + if (!isAllowedValue(definition, value)) { + return { + ok: false, + error: `Invalid value for ${id}: ${String(value)}`, + }; + } + + const next = deepClone(this.runtimeOverrides); + setPathValue(next, definition.path, value); + this.runtimeOverrides = next; + + const ankiPatch = definition.toAnkiPatch(value); + this.applyAnkiPatch(ankiPatch); + + const option = this.listOptions().find((item) => item.id === id); + if (!option) { + return { ok: false, error: `Failed to apply option: ${id}` }; + } + + const osdMessage = `Runtime option: ${definition.label} -> ${definition.formatValueForOsd(option.value)}`; + this.onOptionsChanged(this.listOptions()); + return { + ok: true, + option, + osdMessage, + requiresRestart: definition.requiresRestart, + }; + } + + cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult { + const definition = this.definitions.get(id); + if (!definition) { + return { ok: false, error: `Unknown runtime option: ${id}` }; + } + + const values = allowedValues(definition); + if (values.length === 0) { + return { ok: false, error: `Option ${id} has no allowed values` }; + } + + const currentValue = this.getEffectiveValue(definition); + const currentIndex = values.findIndex((value) => value === currentValue); + const safeIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = + direction === 1 + ? (safeIndex + 1) % values.length + : (safeIndex - 1 + values.length) % values.length; + return this.setOptionValue(id, values[nextIndex]!); + } + + getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig { + const source = baseConfig ?? this.getAnkiConfig(); + const effective: AnkiConnectConfig = deepClone(source); + + for (const definition of RUNTIME_OPTION_REGISTRY) { + const override = getPathValue(this.runtimeOverrides, definition.path); + if (override === undefined) continue; + + const subPath = definition.path.replace(/^ankiConnect\./, ''); + setPathValue(effective as unknown as Record, subPath, override); + } + + return effective; + } +} diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts new file mode 100644 index 0000000..21531f3 --- /dev/null +++ b/src/shared/ipc/contracts.ts @@ -0,0 +1,86 @@ +import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types'; + +export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku'] as const; +export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; + +export const IPC_CHANNELS = { + command: { + setIgnoreMouseEvents: 'set-ignore-mouse-events', + overlayModalClosed: 'overlay:modal-closed', + openYomitanSettings: 'open-yomitan-settings', + quitApp: 'quit-app', + toggleDevTools: 'toggle-dev-tools', + toggleOverlay: 'toggle-overlay', + saveSubtitlePosition: 'save-subtitle-position', + setMecabEnabled: 'set-mecab-enabled', + mpvCommand: 'mpv-command', + setAnkiConnectEnabled: 'set-anki-connect-enabled', + clearAnkiConnectHistory: 'clear-anki-connect-history', + refreshKnownWords: 'anki:refresh-known-words', + kikuFieldGroupingRespond: 'kiku:field-grouping-respond', + reportOverlayContentBounds: 'overlay-content-bounds:report', + }, + request: { + getOverlayVisibility: 'get-overlay-visibility', + getVisibleOverlayVisibility: 'get-visible-overlay-visibility', + getInvisibleOverlayVisibility: 'get-invisible-overlay-visibility', + getCurrentSubtitle: 'get-current-subtitle', + getCurrentSubtitleRaw: 'get-current-subtitle-raw', + getCurrentSubtitleAss: 'get-current-subtitle-ass', + getMpvSubtitleRenderMetrics: 'get-mpv-subtitle-render-metrics', + getSubtitlePosition: 'get-subtitle-position', + getSubtitleStyle: 'get-subtitle-style', + getMecabStatus: 'get-mecab-status', + getKeybindings: 'get-keybindings', + getConfigShortcuts: 'get-config-shortcuts', + getSecondarySubMode: 'get-secondary-sub-mode', + getCurrentSecondarySub: 'get-current-secondary-sub', + focusMainWindow: 'focus-main-window', + runSubsyncManual: 'subsync:run-manual', + getAnkiConnectStatus: 'get-anki-connect-status', + getRuntimeOptions: 'runtime-options:get', + setRuntimeOption: 'runtime-options:set', + cycleRuntimeOption: 'runtime-options:cycle', + getAnilistStatus: 'anilist:get-status', + clearAnilistToken: 'anilist:clear-token', + openAnilistSetup: 'anilist:open-setup', + getAnilistQueueStatus: 'anilist:get-queue-status', + retryAnilistNow: 'anilist:retry-now', + appendClipboardVideoToQueue: 'clipboard:append-video-to-queue', + jimakuGetMediaInfo: 'jimaku:get-media-info', + jimakuSearchEntries: 'jimaku:search-entries', + jimakuListFiles: 'jimaku:list-files', + jimakuDownloadFile: 'jimaku:download-file', + kikuBuildMergePreview: 'kiku:build-merge-preview', + }, + event: { + subtitleSet: 'subtitle:set', + subtitleVisibility: 'mpv:subVisibility', + subtitlePositionSet: 'subtitle-position:set', + mpvSubtitleRenderMetricsSet: 'mpv-subtitle-render-metrics:set', + subtitleAssSet: 'subtitle-ass:set', + overlayDebugVisualizationSet: 'overlay-debug-visualization:set', + secondarySubtitleSet: 'secondary-subtitle:set', + secondarySubtitleMode: 'secondary-subtitle:mode', + subsyncOpenManual: 'subsync:open-manual', + kikuFieldGroupingRequest: 'kiku:field-grouping-request', + runtimeOptionsChanged: 'runtime-options:changed', + runtimeOptionsOpen: 'runtime-options:open', + jimakuOpen: 'jimaku:open', + configHotReload: 'config:hot-reload', + }, +} as const; + +export type RuntimeOptionsSetRequest = { + id: RuntimeOptionId; + value: RuntimeOptionValue; +}; + +export type RuntimeOptionsCycleRequest = { + id: RuntimeOptionId; + direction: 1 | -1; +}; + +export type OverlayContentBoundsReportRequest = { + measurement: OverlayContentMeasurement; +}; diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts new file mode 100644 index 0000000..1ee1984 --- /dev/null +++ b/src/shared/ipc/validators.ts @@ -0,0 +1,157 @@ +import type { + JimakuDownloadQuery, + JimakuFilesQuery, + JimakuSearchQuery, + KikuFieldGroupingChoice, + KikuMergePreviewRequest, + RuntimeOptionId, + RuntimeOptionValue, + SubtitlePosition, + SubsyncManualRunRequest, +} from '../../types'; +import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts'; + +const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ + 'anki.autoUpdateNewCards', + 'anki.kikuFieldGrouping', + 'anki.nPlusOneMatchMode', +]; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value); +} + +export function parseOverlayHostedModal(value: unknown): OverlayHostedModal | null { + if (typeof value !== 'string') return null; + return OVERLAY_HOSTED_MODALS.includes(value as OverlayHostedModal) + ? (value as OverlayHostedModal) + : null; +} + +export function parseSubtitlePosition(value: unknown): SubtitlePosition | null { + if (!isObject(value) || !isFiniteNumber(value.yPercent)) { + return null; + } + const hasX = value.invisibleOffsetXPx !== undefined; + if (hasX && !isFiniteNumber(value.invisibleOffsetXPx)) { + return null; + } + const hasY = value.invisibleOffsetYPx !== undefined; + if (hasY && !isFiniteNumber(value.invisibleOffsetYPx)) { + return null; + } + return { + yPercent: value.yPercent, + invisibleOffsetXPx: hasX ? (value.invisibleOffsetXPx as number) : undefined, + invisibleOffsetYPx: hasY ? (value.invisibleOffsetYPx as number) : undefined, + }; +} + +export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null { + if (!isObject(value)) return null; + const { engine, sourceTrackId } = value; + if (engine !== 'alass' && engine !== 'ffsubsync') return null; + if (sourceTrackId !== undefined && sourceTrackId !== null && !isInteger(sourceTrackId)) { + return null; + } + return { + engine, + sourceTrackId: sourceTrackId === undefined ? undefined : (sourceTrackId as number | null), + }; +} + +export function parseRuntimeOptionId(value: unknown): RuntimeOptionId | null { + if (typeof value !== 'string') return null; + return RUNTIME_OPTION_IDS.includes(value as RuntimeOptionId) ? (value as RuntimeOptionId) : null; +} + +export function parseRuntimeOptionDirection(value: unknown): 1 | -1 | null { + return value === 1 || value === -1 ? value : null; +} + +export function parseRuntimeOptionValue(value: unknown): RuntimeOptionValue | null { + return typeof value === 'boolean' || typeof value === 'string' + ? (value as RuntimeOptionValue) + : null; +} + +export function parseMpvCommand(value: unknown): Array | null { + if (!Array.isArray(value)) return null; + return value.every((entry) => typeof entry === 'string' || typeof entry === 'number') + ? (value as Array) + : null; +} + +export function parseOptionalForwardingOptions(value: unknown): { + forward?: boolean; +} { + if (!isObject(value)) return {}; + const { forward } = value; + if (forward === undefined) return {}; + return typeof forward === 'boolean' ? { forward } : {}; +} + +export function parseKikuFieldGroupingChoice(value: unknown): KikuFieldGroupingChoice | null { + if (!isObject(value)) return null; + const { keepNoteId, deleteNoteId, deleteDuplicate, cancelled } = value; + if (!isInteger(keepNoteId) || !isInteger(deleteNoteId)) return null; + if (typeof deleteDuplicate !== 'boolean' || typeof cancelled !== 'boolean') return null; + return { + keepNoteId, + deleteNoteId, + deleteDuplicate, + cancelled, + }; +} + +export function parseKikuMergePreviewRequest(value: unknown): KikuMergePreviewRequest | null { + if (!isObject(value)) return null; + const { keepNoteId, deleteNoteId, deleteDuplicate } = value; + if (!isInteger(keepNoteId) || !isInteger(deleteNoteId)) return null; + if (typeof deleteDuplicate !== 'boolean') return null; + return { + keepNoteId, + deleteNoteId, + deleteDuplicate, + }; +} + +export function parseJimakuSearchQuery(value: unknown): JimakuSearchQuery | null { + if (!isObject(value) || typeof value.query !== 'string') return null; + return { query: value.query }; +} + +export function parseJimakuFilesQuery(value: unknown): JimakuFilesQuery | null { + if (!isObject(value) || !isInteger(value.entryId)) return null; + if (value.episode !== undefined && value.episode !== null && !isInteger(value.episode)) { + return null; + } + return { + entryId: value.entryId, + episode: (value.episode as number | null | undefined) ?? undefined, + }; +} + +export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery | null { + if (!isObject(value)) return null; + if ( + !isInteger(value.entryId) || + typeof value.url !== 'string' || + typeof value.name !== 'string' + ) { + return null; + } + return { + entryId: value.entryId, + url: value.url, + name: value.name, + }; +} diff --git a/src/subsync/engines.ts b/src/subsync/engines.ts new file mode 100644 index 0000000..c440e74 --- /dev/null +++ b/src/subsync/engines.ts @@ -0,0 +1,79 @@ +export type SubsyncEngine = 'alass' | 'ffsubsync'; + +export interface SubsyncCommandResult { + ok: boolean; + code: number | null; + stderr: string; + stdout: string; + error?: string; +} + +export interface SubsyncEngineExecutionContext { + referenceFilePath: string; + videoPath: string; + inputSubtitlePath: string; + outputPath: string; + audioStreamIndex: number | null; + resolveExecutablePath: (configuredPath: string, commandName: string) => string; + resolvedPaths: { + alassPath: string; + ffsubsyncPath: string; + }; + runCommand: (command: string, args: string[]) => Promise; +} + +export interface SubsyncEngineProvider { + engine: SubsyncEngine; + execute: (context: SubsyncEngineExecutionContext) => Promise; +} + +type SubsyncEngineProviderFactory = () => SubsyncEngineProvider; + +const subsyncEngineProviderFactories = new Map(); + +export function registerSubsyncEngineProvider( + engine: SubsyncEngine, + factory: SubsyncEngineProviderFactory, +): void { + if (subsyncEngineProviderFactories.has(engine)) { + return; + } + subsyncEngineProviderFactories.set(engine, factory); +} + +export function createSubsyncEngineProvider(engine: SubsyncEngine): SubsyncEngineProvider | null { + const factory = subsyncEngineProviderFactories.get(engine); + if (!factory) return null; + return factory(); +} + +function registerDefaultSubsyncEngineProviders(): void { + registerSubsyncEngineProvider('alass', () => ({ + engine: 'alass', + execute: async (context: SubsyncEngineExecutionContext) => { + const alassPath = context.resolveExecutablePath(context.resolvedPaths.alassPath, 'alass'); + return context.runCommand(alassPath, [ + context.referenceFilePath, + context.inputSubtitlePath, + context.outputPath, + ]); + }, + })); + + registerSubsyncEngineProvider('ffsubsync', () => ({ + engine: 'ffsubsync', + execute: async (context: SubsyncEngineExecutionContext) => { + const ffsubsyncPath = context.resolveExecutablePath( + context.resolvedPaths.ffsubsyncPath, + 'ffsubsync', + ); + const args = [context.videoPath, '-i', context.inputSubtitlePath, '-o', context.outputPath]; + if (context.audioStreamIndex !== null) { + args.push('--reference-stream', `0:${context.audioStreamIndex}`); + } + return context.runCommand(ffsubsyncPath, args); + }, + })); +} + +registerDefaultSubsyncEngineProviders(); diff --git a/src/subsync/utils.test.ts b/src/subsync/utils.test.ts new file mode 100644 index 0000000..9e04412 --- /dev/null +++ b/src/subsync/utils.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { codecToExtension } from './utils'; + +test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => { + assert.equal(codecToExtension('subrip'), 'srt'); + assert.equal(codecToExtension('webvtt'), 'vtt'); + assert.equal(codecToExtension('vtt'), 'vtt'); + assert.equal(codecToExtension('ttml'), 'ttml'); +}); + +test('codecToExtension returns null for unsupported codecs', () => { + assert.equal(codecToExtension('unsupported-codec'), null); +}); diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts new file mode 100644 index 0000000..d94ae22 --- /dev/null +++ b/src/subsync/utils.ts @@ -0,0 +1,144 @@ +import * as fs from 'fs'; +import * as childProcess from 'child_process'; +import { DEFAULT_CONFIG } from '../config'; +import { SubsyncConfig, SubsyncMode } from '../types'; + +export interface MpvTrack { + id?: number; + type?: string; + selected?: boolean; + external?: boolean; + lang?: string; + title?: string; + codec?: string; + 'ff-index'?: number; + 'external-filename'?: string; +} + +export interface SubsyncResolvedConfig { + defaultMode: SubsyncMode; + alassPath: string; + ffsubsyncPath: string; + ffmpegPath: string; +} + +const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = { + alass: '/usr/bin/alass', + ffsubsync: '/usr/bin/ffsubsync', + ffmpeg: '/usr/bin/ffmpeg', +} as const; + +export interface SubsyncContext { + videoPath: string; + primaryTrack: MpvTrack; + secondaryTrack: MpvTrack | null; + sourceTracks: MpvTrack[]; + audioStreamIndex: number | null; +} + +export interface CommandResult { + ok: boolean; + code: number | null; + stderr: string; + stdout: string; + error?: string; +} + +export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig { + const resolvePath = (value: string | undefined, fallback: string): string => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : fallback; + }; + + return { + defaultMode: config?.defaultMode ?? DEFAULT_CONFIG.subsync.defaultMode, + alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass), + ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync), + ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg), + }; +} + +export function hasPathSeparators(value: string): boolean { + return value.includes('/') || value.includes('\\'); +} + +export function fileExists(pathOrEmpty: string): boolean { + if (!pathOrEmpty) return false; + try { + return fs.existsSync(pathOrEmpty); + } catch { + return false; + } +} + +export function formatTrackLabel(track: MpvTrack): string { + const trackId = typeof track.id === 'number' ? track.id : -1; + const source = track.external ? 'External' : 'Internal'; + const lang = track.lang || track.title || 'unknown'; + const active = track.selected ? ' (active)' : ''; + return `${source} #${trackId} - ${lang}${active}`; +} + +export function getTrackById(tracks: MpvTrack[], trackId: number | null): MpvTrack | null { + if (trackId === null) return null; + return tracks.find((track) => track.id === trackId) ?? null; +} + +export function codecToExtension(codec: string | undefined): string | null { + if (!codec) return null; + const normalized = codec.toLowerCase(); + if ( + normalized === 'subrip' || + normalized === 'srt' || + normalized === 'text' || + normalized === 'mov_text' + ) + return 'srt'; + if (normalized === 'ass' || normalized === 'ssa') return 'ass'; + if (normalized === 'webvtt' || normalized === 'vtt') return 'vtt'; + if (normalized === 'ttml') return 'ttml'; + return null; +} + +export function runCommand( + executable: string, + args: string[], + timeoutMs = 120000, +): Promise { + return new Promise((resolve) => { + const child = childProcess.spawn(executable, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + }, timeoutMs); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', (error: Error) => { + clearTimeout(timeout); + resolve({ + ok: false, + code: null, + stderr, + stdout, + error: error.message, + }); + }); + child.on('close', (code: number | null) => { + clearTimeout(timeout); + resolve({ + ok: code === 0, + code, + stderr, + stdout, + }); + }); + }); +} diff --git a/src/subtitle-timing-tracker.ts b/src/subtitle-timing-tracker.ts new file mode 100644 index 0000000..22d475a --- /dev/null +++ b/src/subtitle-timing-tracker.ts @@ -0,0 +1,211 @@ +/* + * SubMiner - Subtitle mining overlay for mpv + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +interface TimingEntry { + startTime: number; + endTime: number; + timestamp: number; +} + +interface HistoryEntry { + displayText: string; + timingKey: string; + startTime: number; + endTime: number; + timestamp: number; +} + +export class SubtitleTimingTracker { + private timings = new Map(); + private history: HistoryEntry[] = []; + private readonly maxHistory = 200; + private readonly ttlMs = 5 * 60 * 1000; + private cleanupInterval: ReturnType | null = null; + + constructor() { + this.startCleanup(); + } + + recordSubtitle(text: string, startTime: number, endTime: number): void { + const normalizedText = this.normalizeText(text); + if (!normalizedText) return; + + const displayText = this.prepareDisplayText(text); + const timingKey = normalizedText; + + this.timings.set(timingKey, { + startTime, + endTime, + timestamp: Date.now(), + }); + + // Check for duplicate of most recent entry (deduplicate adjacent repeats) + const lastEntry = this.history[this.history.length - 1]; + if (lastEntry && lastEntry.timingKey === timingKey) { + // Update timing to most recent occurrence + lastEntry.startTime = startTime; + lastEntry.endTime = endTime; + lastEntry.timestamp = Date.now(); + return; + } + + this.history.push({ + displayText, + timingKey, + startTime, + endTime, + timestamp: Date.now(), + }); + + // Prune history if too large + if (this.history.length > this.maxHistory) { + this.history = this.history.slice(-this.maxHistory); + } + } + + findTiming(text: string): { startTime: number; endTime: number } | null { + const normalizedText = this.normalizeText(text); + if (!normalizedText) return null; + + const entry = this.timings.get(normalizedText); + if (!entry) { + return this.findFuzzyMatch(normalizedText); + } + + return { + startTime: entry.startTime, + endTime: entry.endTime, + }; + } + + /** + * Get recent subtitle blocks in chronological order. + * Returns the last `count` subtitle events (oldest → newest). + * Blocks preserve internal line breaks and are joined with blank lines. + */ + getRecentBlocks(count: number): string[] { + if (count <= 0) return []; + if (count > this.history.length) { + count = this.history.length; + } + return this.history.slice(-count).map((entry) => entry.displayText); + } + + /** + * Get display text for the most recent subtitle. + */ + getCurrentSubtitle(): string | null { + const lastEntry = this.history[this.history.length - 1]; + return lastEntry ? lastEntry.displayText : null; + } + + private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null { + let bestMatch: TimingEntry | null = null; + let bestScore = 0; + + for (const [key, entry] of this.timings.entries()) { + const score = this.calculateSimilarity(text, key); + if (score > bestScore && score > 0.7) { + bestScore = score; + bestMatch = entry; + } + } + + if (bestMatch) { + return { + startTime: bestMatch.startTime, + endTime: bestMatch.endTime, + }; + } + + return null; + } + + private calculateSimilarity(a: string, b: string): number { + const longer = a.length > b.length ? a : b; + const shorter = a.length > b.length ? b : a; + + if (longer.length === 0) return 1; + + const editDistance = this.getEditDistance(longer, shorter); + return (longer.length - editDistance) / longer.length; + } + + private getEditDistance(longer: string, shorter: string): number { + const costs: number[] = []; + for (let i = 0; i <= shorter.length; i++) { + let lastValue = i; + for (let j = 1; j <= longer.length; j++) { + let newValue = costs[j - 1] || 0; + if (longer.charAt(j - 1) !== shorter.charAt(i - 1)) { + newValue = Math.min(Math.min(newValue, lastValue), costs[j] || 0) + 1; + } + costs[j - 1] = lastValue; + lastValue = newValue; + } + costs[shorter.length] = lastValue; + } + return costs[shorter.length] || 0; + } + + private normalizeText(text: string): string { + return text + .replace(/\\N/g, ' ') + .replace(/\\n/g, ' ') + .replace(/\n/g, ' ') + .replace(/{[^}]*}/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + + private prepareDisplayText(text: string): string { + // Convert ASS/SSA newlines to real newlines, strip tags + return text + .replace(/\\N/g, '\n') + .replace(/\\n/g, '\n') + .replace(/{[^}]*}/g, '') + .trim(); + } + + private startCleanup(): void { + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 60000); + } + + cleanup(): void { + const now = Date.now(); + // Clean up old timing entries + for (const [key, entry] of this.timings.entries()) { + if (now - entry.timestamp > this.ttlMs) { + this.timings.delete(key); + } + } + // Clean up old history entries + this.history = this.history.filter((entry) => now - entry.timestamp <= this.ttlMs); + } + + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.timings.clear(); + this.history = []; + } +} diff --git a/src/subtitle/pipeline.ts b/src/subtitle/pipeline.ts new file mode 100644 index 0000000..7809059 --- /dev/null +++ b/src/subtitle/pipeline.ts @@ -0,0 +1,43 @@ +import { TokenMergerProvider } from '../token-mergers'; +import { TokenizerProvider } from '../tokenizers'; +import { SubtitleData } from '../types'; +import { normalizeDisplayText, normalizeTokenizerInput } from './stages/normalize'; +import { tokenizeStage } from './stages/tokenize'; +import { mergeStage } from './stages/merge'; + +export interface SubtitlePipelineDeps { + getTokenizer: () => TokenizerProvider | null; + getTokenMerger: () => TokenMergerProvider | null; +} + +export class SubtitlePipeline { + private readonly deps: SubtitlePipelineDeps; + + constructor(deps: SubtitlePipelineDeps) { + this.deps = deps; + } + + async process(text: string): Promise { + if (!text) { + return { text, tokens: null }; + } + + const displayText = normalizeDisplayText(text); + if (!displayText) { + return { text, tokens: null }; + } + + const tokenizeText = normalizeTokenizerInput(displayText); + + try { + const tokens = await tokenizeStage(this.deps.getTokenizer(), tokenizeText); + const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens); + if (!mergedTokens || mergedTokens.length === 0) { + return { text: displayText, tokens: null }; + } + return { text: displayText, tokens: mergedTokens }; + } catch { + return { text: displayText, tokens: null }; + } + } +} diff --git a/src/subtitle/stages/merge.ts b/src/subtitle/stages/merge.ts new file mode 100644 index 0000000..f94491b --- /dev/null +++ b/src/subtitle/stages/merge.ts @@ -0,0 +1,12 @@ +import { TokenMergerProvider } from '../../token-mergers'; +import { MergedToken, Token } from '../../types'; + +export function mergeStage( + mergerProvider: TokenMergerProvider | null, + tokens: Token[] | null, +): MergedToken[] | null { + if (!mergerProvider || !tokens || tokens.length === 0) { + return null; + } + return mergerProvider.merge(tokens); +} diff --git a/src/subtitle/stages/normalize.test.ts b/src/subtitle/stages/normalize.test.ts new file mode 100644 index 0000000..9584b06 --- /dev/null +++ b/src/subtitle/stages/normalize.test.ts @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeTokenizerInput } from './normalize'; + +test('normalizeTokenizerInput collapses zero-width separators between Japanese segments', () => { + const input = 'キリキリと\u200bかかってこい\nこのヘナチョコ冒険者どもめが!'; + const normalized = normalizeTokenizerInput(input); + + assert.equal(normalized, 'キリキリと かかってこい このヘナチョコ冒険者どもめが!'); +}); diff --git a/src/subtitle/stages/normalize.ts b/src/subtitle/stages/normalize.ts new file mode 100644 index 0000000..6cbe0f8 --- /dev/null +++ b/src/subtitle/stages/normalize.ts @@ -0,0 +1,13 @@ +export function normalizeDisplayText(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim(); +} + +const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g; + +export function normalizeTokenizerInput(displayText: string): string { + return displayText + .replace(/\n/g, ' ') + .replace(INVISIBLE_SEPARATOR_PATTERN, ' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/subtitle/stages/tokenize.ts b/src/subtitle/stages/tokenize.ts new file mode 100644 index 0000000..0ad8ea7 --- /dev/null +++ b/src/subtitle/stages/tokenize.ts @@ -0,0 +1,12 @@ +import { TokenizerProvider } from '../../tokenizers'; +import { Token } from '../../types'; + +export async function tokenizeStage( + tokenizerProvider: TokenizerProvider | null, + input: string, +): Promise { + if (!tokenizerProvider || !input) { + return null; + } + return tokenizerProvider.tokenize(input); +} diff --git a/src/token-merger.ts b/src/token-merger.ts new file mode 100644 index 0000000..8d7d40f --- /dev/null +++ b/src/token-merger.ts @@ -0,0 +1,342 @@ +/* + * SubMiner - All-in-one sentence mining overlay + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { PartOfSpeech, Token, MergedToken } from './types'; + +export function isNoun(tok: Token): boolean { + return tok.partOfSpeech === PartOfSpeech.noun; +} + +export function isProperNoun(tok: Token): boolean { + return tok.partOfSpeech === PartOfSpeech.noun && tok.pos2 === '固有名詞'; +} + +export function ignoreReading(tok: Token): boolean { + return tok.partOfSpeech === PartOfSpeech.symbol && tok.pos2 === '文字'; +} + +export function isCopula(tok: Token): boolean { + const raw = tok.inflectionType; + if (!raw) { + return false; + } + return ['特殊・ダ', '特殊・デス', '特殊|だ', '特殊|デス'].includes(raw); +} + +export function isAuxVerb(tok: Token): boolean { + return tok.partOfSpeech === PartOfSpeech.bound_auxiliary && !isCopula(tok); +} + +export function isContinuativeForm(tok: Token): boolean { + if (!tok.inflectionForm) { + return false; + } + const inflectionForm = tok.inflectionForm; + const isContinuative = + inflectionForm === '連用デ接続' || + inflectionForm === '連用タ接続' || + inflectionForm.startsWith('連用形'); + + if (!isContinuative) { + return false; + } + return tok.headword !== 'ない'; +} + +export function isVerbSuffix(tok: Token): boolean { + return tok.partOfSpeech === PartOfSpeech.verb && (tok.pos2 === '非自立' || tok.pos2 === '接尾'); +} + +export function isTatteParticle(tok: Token): boolean { + return ( + tok.partOfSpeech === PartOfSpeech.particle && + tok.pos2 === '接続助詞' && + tok.headword === 'たって' + ); +} + +export function isBaParticle(tok: Token): boolean { + return tok.partOfSpeech === PartOfSpeech.particle && tok.pos2 === '接続助詞' && tok.word === 'ば'; +} + +export function isTeDeParticle(tok: Token): boolean { + return ( + tok.partOfSpeech === PartOfSpeech.particle && + tok.pos2 === '接続助詞' && + ['て', 'で', 'ちゃ'].includes(tok.word) + ); +} + +export function isTaDaParticle(tok: Token): boolean { + return isAuxVerb(tok) && ['た', 'だ'].includes(tok.word); +} + +export function isVerb(tok: Token): boolean { + return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary].includes(tok.partOfSpeech); +} + +export function isVerbNonIndependent(): boolean { + return true; +} + +export function canReceiveAuxiliary(tok: Token): boolean { + return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary, PartOfSpeech.i_adjective].includes( + tok.partOfSpeech, + ); +} + +export function isNounSuffix(tok: Token): boolean { + return tok.partOfSpeech === PartOfSpeech.verb && tok.pos2 === '接尾'; +} + +export function isCounter(tok: Token): boolean { + return ( + tok.partOfSpeech === PartOfSpeech.noun && + tok.pos3 !== undefined && + tok.pos3.startsWith('助数詞') + ); +} + +export function isNumeral(tok: Token): boolean { + return ( + tok.partOfSpeech === PartOfSpeech.noun && tok.pos2 !== undefined && tok.pos2.startsWith('数') + ); +} + +export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean { + if (isVerb(lastStandaloneToken)) { + if (isAuxVerb(token)) { + return true; + } + if (isContinuativeForm(lastStandaloneToken) && isVerbSuffix(token)) { + return true; + } + if (isVerbSuffix(token) && isVerbNonIndependent()) { + return true; + } + } + + if (isNoun(lastStandaloneToken) && !isProperNoun(lastStandaloneToken) && isNounSuffix(token)) { + return true; + } + + if (isCounter(token) && isNumeral(lastStandaloneToken)) { + return true; + } + + if (isBaParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) { + return true; + } + + if (isTatteParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) { + return true; + } + + if (isTeDeParticle(token) && isContinuativeForm(lastStandaloneToken)) { + return true; + } + + if (isTaDaParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) { + return true; + } + + if (isTeDeParticle(lastStandaloneToken) && isVerbSuffix(token)) { + return true; + } + + return false; +} + +export function mergeTokens( + tokens: Token[], + isKnownWord: (text: string) => boolean = () => false, + knownWordMatchMode: 'headword' | 'surface' = 'headword', +): MergedToken[] { + if (!tokens || tokens.length === 0) { + return []; + } + + const result: MergedToken[] = []; + let charOffset = 0; + let lastStandaloneToken: Token | null = null; + + for (const token of tokens) { + const start = charOffset; + const end = charOffset + token.word.length; + charOffset = end; + + let shouldMergeToken = false; + + if (result.length > 0 && lastStandaloneToken !== null) { + shouldMergeToken = shouldMerge(lastStandaloneToken, token); + } + + const tokenReading = ignoreReading(token) ? '' : token.katakanaReading || token.word; + + if (shouldMergeToken && result.length > 0) { + const prev = result.pop()!; + const mergedHeadword = prev.headword; + const headwordForKnownMatch = (() => { + if (knownWordMatchMode === 'surface') { + return prev.surface; + } + return mergedHeadword; + })(); + result.push({ + surface: prev.surface + token.word, + reading: prev.reading + tokenReading, + headword: prev.headword, + startPos: prev.startPos, + endPos: end, + partOfSpeech: prev.partOfSpeech, + pos1: prev.pos1 ?? token.pos1, + pos2: prev.pos2 ?? token.pos2, + pos3: prev.pos3 ?? token.pos3, + isMerged: true, + isKnown: headwordForKnownMatch ? isKnownWord(headwordForKnownMatch) : false, + isNPlusOneTarget: false, + }); + } else { + const headwordForKnownMatch = (() => { + if (knownWordMatchMode === 'surface') { + return token.word; + } + return token.headword; + })(); + result.push({ + surface: token.word, + reading: tokenReading, + headword: token.headword, + startPos: start, + endPos: end, + partOfSpeech: token.partOfSpeech, + pos1: token.pos1, + pos2: token.pos2, + pos3: token.pos3, + isMerged: false, + isKnown: headwordForKnownMatch ? isKnownWord(headwordForKnownMatch) : false, + isNPlusOneTarget: false, + }); + } + + lastStandaloneToken = token; + } + + return result; +} + +const SENTENCE_BOUNDARY_SURFACES = new Set(['。', '?', '!', '?', '!', '…', '\u2026']); +const N_PLUS_ONE_IGNORED_POS1 = new Set(['助詞', '助動詞', '記号', '補助記号']); + +export function isNPlusOneCandidateToken(token: MergedToken): boolean { + if (token.isKnown) { + return false; + } + + if (token.partOfSpeech === PartOfSpeech.particle) { + return false; + } + + if (token.partOfSpeech === PartOfSpeech.bound_auxiliary) { + return false; + } + + if (token.partOfSpeech === PartOfSpeech.symbol) { + return false; + } + + if (token.partOfSpeech === PartOfSpeech.noun && token.pos2 === '固有名詞') { + return false; + } + + if (token.pos3 && token.pos3.startsWith('助数詞')) { + return false; + } + + if (token.pos1 && N_PLUS_ONE_IGNORED_POS1.has(token.pos1)) { + return false; + } + + if (token.surface.trim().length === 0) { + return false; + } + + return true; +} + +function isSentenceBoundaryToken(token: MergedToken): boolean { + if (token.partOfSpeech !== PartOfSpeech.symbol) { + return false; + } + + return SENTENCE_BOUNDARY_SURFACES.has(token.surface); +} + +export function markNPlusOneTargets(tokens: MergedToken[], minSentenceWords = 3): MergedToken[] { + if (tokens.length === 0) { + return []; + } + + const markedTokens = tokens.map((token) => ({ + ...token, + isNPlusOneTarget: false, + })); + + let sentenceStart = 0; + const minimumSentenceWords = Number.isInteger(minSentenceWords) + ? Math.max(1, minSentenceWords) + : 3; + + const markSentence = (start: number, endExclusive: number): void => { + const sentenceCandidates: number[] = []; + let sentenceWordCount = 0; + for (let i = start; i < endExclusive; i++) { + const token = markedTokens[i]; + if (!token) continue; + if (!isSentenceBoundaryToken(token) && token.surface.trim().length > 0) { + sentenceWordCount += 1; + } + + if (isNPlusOneCandidateToken(token)) { + sentenceCandidates.push(i); + } + } + + if (sentenceWordCount >= minimumSentenceWords && sentenceCandidates.length === 1) { + markedTokens[sentenceCandidates[0]!] = { + ...markedTokens[sentenceCandidates[0]!]!, + isNPlusOneTarget: true, + }; + } + }; + + for (let i = 0; i < markedTokens.length; i++) { + const token = markedTokens[i]; + if (!token) continue; + if (isSentenceBoundaryToken(token)) { + markSentence(sentenceStart, i); + sentenceStart = i + 1; + } + } + + if (sentenceStart < markedTokens.length) { + markSentence(sentenceStart, markedTokens.length); + } + + return markedTokens; +} diff --git a/src/token-mergers/index.ts b/src/token-mergers/index.ts new file mode 100644 index 0000000..0fbc28c --- /dev/null +++ b/src/token-mergers/index.ts @@ -0,0 +1,27 @@ +import { mergeTokens as defaultMergeTokens } from '../token-merger'; +import { MergedToken, Token } from '../types'; + +export interface TokenMergerProvider { + id: string; + merge: (tokens: Token[]) => MergedToken[]; +} + +type TokenMergerProviderFactory = () => TokenMergerProvider; + +const tokenMergerProviderFactories = new Map(); + +export function registerTokenMergerProvider(id: string, factory: TokenMergerProviderFactory): void { + if (tokenMergerProviderFactories.has(id)) { + return; + } + tokenMergerProviderFactories.set(id, factory); +} + +function registerDefaultTokenMergerProviders(): void { + registerTokenMergerProvider('default', () => ({ + id: 'default', + merge: (tokens: Token[]) => defaultMergeTokens(tokens), + })); +} + +registerDefaultTokenMergerProviders(); diff --git a/src/tokenizers/index.ts b/src/tokenizers/index.ts new file mode 100644 index 0000000..2804dbf --- /dev/null +++ b/src/tokenizers/index.ts @@ -0,0 +1,36 @@ +import { MecabTokenizer } from '../mecab-tokenizer'; +import { MecabStatus, Token } from '../types'; + +export interface TokenizerProvider { + id: string; + checkAvailability: () => Promise; + tokenize: (text: string) => Promise; + getStatus: () => MecabStatus; + setEnabled: (enabled: boolean) => void; +} + +type TokenizerProviderFactory = () => TokenizerProvider; + +const tokenizerProviderFactories = new Map(); + +export function registerTokenizerProvider(id: string, factory: TokenizerProviderFactory): void { + if (tokenizerProviderFactories.has(id)) { + return; + } + tokenizerProviderFactories.set(id, factory); +} + +function registerDefaultTokenizerProviders(): void { + registerTokenizerProvider('mecab', () => { + const mecab = new MecabTokenizer(); + return { + id: 'mecab', + checkAvailability: () => mecab.checkAvailability(), + tokenize: (text: string) => mecab.tokenize(text), + getStatus: () => mecab.getStatus(), + setEnabled: (enabled: boolean) => mecab.setEnabled(enabled), + }; + }); +} + +registerDefaultTokenizerProviders(); diff --git a/src/translators/index.ts b/src/translators/index.ts new file mode 100644 index 0000000..092269e --- /dev/null +++ b/src/translators/index.ts @@ -0,0 +1,101 @@ +import axios from 'axios'; + +export interface TranslationRequest { + sentence: string; + apiKey: string; + baseUrl: string; + model: string; + targetLanguage: string; + systemPrompt: string; + timeoutMs?: number; +} + +export interface TranslationProvider { + id: string; + translate: (request: TranslationRequest) => Promise; +} + +type TranslationProviderFactory = () => TranslationProvider; + +const translationProviderFactories = new Map(); + +export function registerTranslationProvider(id: string, factory: TranslationProviderFactory): void { + if (translationProviderFactories.has(id)) { + return; + } + translationProviderFactories.set(id, factory); +} + +export function createTranslationProvider(id = 'openai-compatible'): TranslationProvider | null { + const factory = translationProviderFactories.get(id); + if (!factory) return null; + return factory(); +} + +function extractAiText(content: unknown): string { + if (typeof content === 'string') { + return content.trim(); + } + if (!Array.isArray(content)) { + return ''; + } + const parts: string[] = []; + for (const item of content) { + if ( + item && + typeof item === 'object' && + 'type' in item && + (item as { type?: unknown }).type === 'text' && + 'text' in item && + typeof (item as { text?: unknown }).text === 'string' + ) { + parts.push((item as { text: string }).text); + } + } + return parts.join('').trim(); +} + +function normalizeOpenAiBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ''); + if (/\/v1$/i.test(trimmed)) { + return trimmed; + } + return `${trimmed}/v1`; +} + +function registerDefaultTranslationProviders(): void { + registerTranslationProvider('openai-compatible', () => ({ + id: 'openai-compatible', + translate: async (request: TranslationRequest): Promise => { + const response = await axios.post( + `${normalizeOpenAiBaseUrl(request.baseUrl)}/chat/completions`, + { + model: request.model, + temperature: 0, + messages: [ + { role: 'system', content: request.systemPrompt }, + { + role: 'user', + content: `Translate this text to ${request.targetLanguage}:\n\n${request.sentence}`, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${request.apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: request.timeoutMs ?? 15000, + }, + ); + + const content = (response.data as { choices?: unknown[] })?.choices?.[0] as + | { message?: { content?: unknown } } + | undefined; + const translated = extractAiText(content?.message?.content); + return translated || null; + }, + })); +} + +registerDefaultTranslationProviders(); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7cbdc56 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,793 @@ +/* + * SubMiner - All-in-one sentence mining overlay + * Copyright (C) 2024 sudacode + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export enum PartOfSpeech { + noun = 'noun', + verb = 'verb', + i_adjective = 'i_adjective', + na_adjective = 'na_adjective', + particle = 'particle', + bound_auxiliary = 'bound_auxiliary', + symbol = 'symbol', + other = 'other', +} + +export interface Token { + word: string; + partOfSpeech: PartOfSpeech; + pos1: string; + pos2: string; + pos3: string; + pos4: string; + inflectionType: string; + inflectionForm: string; + headword: string; + katakanaReading: string; + pronunciation: string; +} + +export interface MergedToken { + surface: string; + reading: string; + headword: string; + startPos: number; + endPos: number; + partOfSpeech: PartOfSpeech; + pos1?: string; + pos2?: string; + pos3?: string; + isMerged: boolean; + isKnown: boolean; + isNPlusOneTarget: boolean; + jlptLevel?: JlptLevel; + frequencyRank?: number; +} + +export type FrequencyDictionaryLookup = (term: string) => number | null; + +export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5'; + +export interface WindowGeometry { + x: number; + y: number; + width: number; + height: number; +} + +export interface SubtitlePosition { + yPercent: number; + invisibleOffsetXPx?: number; + invisibleOffsetYPx?: number; +} + +export interface SubtitleStyle { + fontSize: number; +} + +export interface Keybinding { + key: string; + command: (string | number)[] | null; +} + +export type SecondarySubMode = 'hidden' | 'visible' | 'hover'; + +export interface SecondarySubConfig { + secondarySubLanguages?: string[]; + autoLoadSecondarySub?: boolean; + defaultMode?: SecondarySubMode; +} + +export type SubsyncMode = 'auto' | 'manual'; + +export interface SubsyncConfig { + defaultMode?: SubsyncMode; + alass_path?: string; + ffsubsync_path?: string; + ffmpeg_path?: string; +} + +export interface WebSocketConfig { + enabled?: boolean | 'auto'; + port?: number; +} + +export interface TexthookerConfig { + openBrowser?: boolean; +} + +export interface NotificationOptions { + body?: string; + icon?: string; +} + +export interface MpvClient { + currentSubText: string; + currentVideoPath: string; + currentTimePos: number; + currentSubStart: number; + currentSubEnd: number; + currentAudioStreamIndex: number | null; + send(command: { command: unknown[]; request_id?: number }): boolean; +} + +export interface KikuDuplicateCardInfo { + noteId: number; + expression: string; + sentencePreview: string; + hasAudio: boolean; + hasImage: boolean; + isOriginal: boolean; +} + +export interface KikuFieldGroupingRequestData { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; +} + +export interface KikuFieldGroupingChoice { + keepNoteId: number; + deleteNoteId: number; + deleteDuplicate: boolean; + cancelled: boolean; +} + +export interface KikuMergePreviewRequest { + keepNoteId: number; + deleteNoteId: number; + deleteDuplicate: boolean; +} + +export interface KikuMergePreviewResponse { + ok: boolean; + compact?: Record; + full?: Record; + error?: string; +} + +export type RuntimeOptionId = + | 'anki.autoUpdateNewCards' + | 'anki.kikuFieldGrouping' + | 'anki.nPlusOneMatchMode'; + +export type RuntimeOptionScope = 'ankiConnect'; + +export type RuntimeOptionValueType = 'boolean' | 'enum'; + +export type RuntimeOptionValue = boolean | string; + +export type NPlusOneMatchMode = 'headword' | 'surface'; + +export interface RuntimeOptionState { + id: RuntimeOptionId; + label: string; + scope: RuntimeOptionScope; + valueType: RuntimeOptionValueType; + value: RuntimeOptionValue; + allowedValues: RuntimeOptionValue[]; + requiresRestart: boolean; +} + +export interface RuntimeOptionApplyResult { + ok: boolean; + option?: RuntimeOptionState; + osdMessage?: string; + requiresRestart?: boolean; + error?: string; +} + +export interface AnkiConnectConfig { + enabled?: boolean; + url?: string; + pollingRate?: number; + tags?: string[]; + fields?: { + audio?: string; + image?: string; + sentence?: string; + miscInfo?: string; + translation?: string; + }; + ai?: { + enabled?: boolean; + alwaysUseAiTranslation?: boolean; + apiKey?: string; + model?: string; + baseUrl?: string; + targetLanguage?: string; + systemPrompt?: string; + }; + openRouter?: { + enabled?: boolean; + alwaysUseAiTranslation?: boolean; + apiKey?: string; + model?: string; + baseUrl?: string; + targetLanguage?: string; + systemPrompt?: string; + }; + media?: { + generateAudio?: boolean; + generateImage?: boolean; + imageType?: 'static' | 'avif'; + imageFormat?: 'jpg' | 'png' | 'webp'; + imageQuality?: number; + imageMaxWidth?: number; + imageMaxHeight?: number; + animatedFps?: number; + animatedMaxWidth?: number; + animatedMaxHeight?: number; + animatedCrf?: number; + audioPadding?: number; + fallbackDuration?: number; + maxMediaDuration?: number; + }; + nPlusOne?: { + highlightEnabled?: boolean; + refreshMinutes?: number; + matchMode?: NPlusOneMatchMode; + decks?: string[]; + nPlusOne?: string; + knownWord?: string; + minSentenceWords?: number; + }; + behavior?: { + overwriteAudio?: boolean; + overwriteImage?: boolean; + mediaInsertMode?: 'append' | 'prepend'; + highlightWord?: boolean; + notificationType?: 'osd' | 'system' | 'both' | 'none'; + autoUpdateNewCards?: boolean; + }; + metadata?: { + pattern?: string; + }; + deck?: string; + isLapis?: { + enabled?: boolean; + sentenceCardModel?: string; + }; + isKiku?: { + enabled?: boolean; + fieldGrouping?: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto?: boolean; + }; +} + +export interface SubtitleStyleConfig { + enableJlpt?: boolean; + preserveLineBreaks?: boolean; + hoverTokenColor?: string; + fontFamily?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string; + fontStyle?: string; + backgroundColor?: string; + nPlusOneColor?: string; + knownWordColor?: string; + jlptColors?: { + N1: string; + N2: string; + N3: string; + N4: string; + N5: string; + }; + frequencyDictionary?: { + enabled?: boolean; + sourcePath?: string; + topX?: number; + mode?: FrequencyDictionaryMode; + singleColor?: string; + bandedColors?: [string, string, string, string, string]; + }; + secondary?: { + fontFamily?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string; + fontStyle?: string; + backgroundColor?: string; + }; +} + +export type FrequencyDictionaryMode = 'single' | 'banded'; + +export interface ShortcutsConfig { + toggleVisibleOverlayGlobal?: string | null; + toggleInvisibleOverlayGlobal?: string | null; + copySubtitle?: string | null; + copySubtitleMultiple?: string | null; + updateLastCardFromClipboard?: string | null; + triggerFieldGrouping?: string | null; + triggerSubsync?: string | null; + mineSentence?: string | null; + mineSentenceMultiple?: string | null; + multiCopyTimeoutMs?: number; + toggleSecondarySub?: string | null; + markAudioCard?: string | null; + openRuntimeOptions?: string | null; + openJimaku?: string | null; +} + +export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; + +export interface JimakuConfig { + apiKey?: string; + apiKeyCommand?: string; + apiBaseUrl?: string; + languagePreference?: JimakuLanguagePreference; + maxEntryResults?: number; +} + +export interface AnilistConfig { + enabled?: boolean; + accessToken?: string; +} + +export interface JellyfinConfig { + enabled?: boolean; + serverUrl?: string; + username?: string; + deviceId?: string; + clientName?: string; + clientVersion?: string; + defaultLibraryId?: string; + remoteControlEnabled?: boolean; + remoteControlAutoConnect?: boolean; + autoAnnounce?: boolean; + remoteControlDeviceName?: string; + pullPictures?: boolean; + iconCacheDir?: string; + directPlayPreferred?: boolean; + directPlayContainers?: string[]; + transcodeVideoCodec?: string; +} + +export interface DiscordPresenceConfig { + enabled?: boolean; + updateIntervalMs?: number; + debounceMs?: number; +} + +export interface InvisibleOverlayConfig { + startupVisibility?: 'platform-default' | 'visible' | 'hidden'; +} + +export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off'; + +export interface YoutubeSubgenConfig { + mode?: YoutubeSubgenMode; + whisperBin?: string; + whisperModel?: string; + primarySubLanguages?: string[]; +} + +export interface ImmersionTrackingConfig { + enabled?: boolean; + dbPath?: string; + batchSize?: number; + flushIntervalMs?: number; + queueCap?: number; + payloadCapBytes?: number; + maintenanceIntervalMs?: number; + retention?: { + eventsDays?: number; + telemetryDays?: number; + dailyRollupsDays?: number; + monthlyRollupsDays?: number; + vacuumIntervalDays?: number; + }; +} + +export interface Config { + subtitlePosition?: SubtitlePosition; + keybindings?: Keybinding[]; + websocket?: WebSocketConfig; + texthooker?: TexthookerConfig; + ankiConnect?: AnkiConnectConfig; + shortcuts?: ShortcutsConfig; + secondarySub?: SecondarySubConfig; + subsync?: SubsyncConfig; + subtitleStyle?: SubtitleStyleConfig; + auto_start_overlay?: boolean; + bind_visible_overlay_to_mpv_sub_visibility?: boolean; + jimaku?: JimakuConfig; + anilist?: AnilistConfig; + jellyfin?: JellyfinConfig; + discordPresence?: DiscordPresenceConfig; + invisibleOverlay?: InvisibleOverlayConfig; + youtubeSubgen?: YoutubeSubgenConfig; + immersionTracking?: ImmersionTrackingConfig; + logging?: { + level?: 'debug' | 'info' | 'warn' | 'error'; + }; +} + +export type RawConfig = Config; + +export interface ResolvedConfig { + subtitlePosition: SubtitlePosition; + keybindings: Keybinding[]; + websocket: Required; + texthooker: Required; + ankiConnect: AnkiConnectConfig & { + enabled: boolean; + url: string; + pollingRate: number; + tags: string[]; + fields: { + audio: string; + image: string; + sentence: string; + miscInfo: string; + translation: string; + }; + ai: { + enabled: boolean; + alwaysUseAiTranslation: boolean; + apiKey: string; + model: string; + baseUrl: string; + targetLanguage: string; + systemPrompt: string; + }; + media: { + generateAudio: boolean; + generateImage: boolean; + imageType: 'static' | 'avif'; + imageFormat: 'jpg' | 'png' | 'webp'; + imageQuality: number; + imageMaxWidth?: number; + imageMaxHeight?: number; + animatedFps: number; + animatedMaxWidth: number; + animatedMaxHeight?: number; + animatedCrf: number; + audioPadding: number; + fallbackDuration: number; + maxMediaDuration: number; + }; + nPlusOne: { + highlightEnabled: boolean; + refreshMinutes: number; + matchMode: NPlusOneMatchMode; + decks: string[]; + nPlusOne: string; + knownWord: string; + minSentenceWords: number; + }; + behavior: { + overwriteAudio: boolean; + overwriteImage: boolean; + mediaInsertMode: 'append' | 'prepend'; + highlightWord: boolean; + notificationType: 'osd' | 'system' | 'both' | 'none'; + autoUpdateNewCards: boolean; + }; + metadata: { + pattern: string; + }; + isLapis: { + enabled: boolean; + sentenceCardModel: string; + }; + isKiku: { + enabled: boolean; + fieldGrouping: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto: boolean; + }; + }; + shortcuts: Required; + secondarySub: Required; + subsync: Required; + subtitleStyle: Required> & { + secondary: Required>; + frequencyDictionary: { + enabled: boolean; + sourcePath: string; + topX: number; + mode: FrequencyDictionaryMode; + singleColor: string; + bandedColors: [string, string, string, string, string]; + }; + }; + auto_start_overlay: boolean; + bind_visible_overlay_to_mpv_sub_visibility: boolean; + jimaku: JimakuConfig & { + apiBaseUrl: string; + languagePreference: JimakuLanguagePreference; + maxEntryResults: number; + }; + anilist: { + enabled: boolean; + accessToken: string; + }; + jellyfin: { + enabled: boolean; + serverUrl: string; + username: string; + deviceId: string; + clientName: string; + clientVersion: string; + defaultLibraryId: string; + remoteControlEnabled: boolean; + remoteControlAutoConnect: boolean; + autoAnnounce: boolean; + remoteControlDeviceName: string; + pullPictures: boolean; + iconCacheDir: string; + directPlayPreferred: boolean; + directPlayContainers: string[]; + transcodeVideoCodec: string; + }; + discordPresence: { + enabled: boolean; + updateIntervalMs: number; + debounceMs: number; + }; + invisibleOverlay: Required; + youtubeSubgen: YoutubeSubgenConfig & { + mode: YoutubeSubgenMode; + whisperBin: string; + whisperModel: string; + primarySubLanguages: string[]; + }; + immersionTracking: { + enabled: boolean; + dbPath?: string; + batchSize: number; + flushIntervalMs: number; + queueCap: number; + payloadCapBytes: number; + maintenanceIntervalMs: number; + retention: { + eventsDays: number; + telemetryDays: number; + dailyRollupsDays: number; + monthlyRollupsDays: number; + vacuumIntervalDays: number; + }; + }; + logging: { + level: 'debug' | 'info' | 'warn' | 'error'; + }; +} + +export interface ConfigValidationWarning { + path: string; + value: unknown; + fallback: unknown; + message: string; +} + +export interface SubsyncSourceTrack { + id: number; + label: string; +} + +export interface SubsyncManualPayload { + sourceTracks: SubsyncSourceTrack[]; +} + +export interface SubsyncManualRunRequest { + engine: 'alass' | 'ffsubsync'; + sourceTrackId?: number | null; +} + +export interface SubsyncResult { + ok: boolean; + message: string; +} + +export interface ClipboardAppendResult { + ok: boolean; + message: string; +} + +export interface SubtitleData { + text: string; + tokens: MergedToken[] | null; +} + +export interface MpvSubtitleRenderMetrics { + subPos: number; + subFontSize: number; + subScale: number; + subMarginY: number; + subMarginX: number; + subFont: string; + subSpacing: number; + subBold: boolean; + subItalic: boolean; + subBorderSize: number; + subShadowOffset: number; + subAssOverride: string; + subScaleByWindow: boolean; + subUseMargins: boolean; + osdHeight: number; + osdDimensions: { + w: number; + h: number; + ml: number; + mr: number; + mt: number; + mb: number; + } | null; +} + +export type OverlayLayer = 'visible' | 'invisible'; + +export interface OverlayContentRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface OverlayContentMeasurement { + layer: OverlayLayer; + measuredAtMs: number; + viewport: { + width: number; + height: number; + }; + contentRect: OverlayContentRect | null; +} + +export interface MecabStatus { + available: boolean; + enabled: boolean; + path: string | null; +} + +export type JimakuConfidence = 'high' | 'medium' | 'low'; + +export interface JimakuMediaInfo { + title: string; + season: number | null; + episode: number | null; + confidence: JimakuConfidence; + filename: string; + rawTitle: string; +} + +export interface JimakuSearchQuery { + query: string; +} + +export interface JimakuEntryFlags { + anime?: boolean; + movie?: boolean; + adult?: boolean; + external?: boolean; + unverified?: boolean; +} + +export interface JimakuEntry { + id: number; + name: string; + english_name?: string | null; + japanese_name?: string | null; + flags?: JimakuEntryFlags; + last_modified?: string; +} + +export interface JimakuFilesQuery { + entryId: number; + episode?: number | null; +} + +export interface JimakuFileEntry { + name: string; + url: string; + size: number; + last_modified: string; +} + +export interface JimakuDownloadQuery { + entryId: number; + url: string; + name: string; +} + +export interface JimakuApiError { + error: string; + code?: number; + retryAfter?: number; +} + +export type JimakuApiResponse = { ok: true; data: T } | { ok: false; error: JimakuApiError }; + +export type JimakuDownloadResult = + | { ok: true; path: string } + | { ok: false; error: JimakuApiError }; + +export interface ConfigHotReloadPayload { + keybindings: Keybinding[]; + subtitleStyle: SubtitleStyleConfig | null; + secondarySubMode: SecondarySubMode; +} + +export interface SubtitleHoverTokenPayload { + tokenIndex: number | null; +} + +export interface ElectronAPI { + getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | null; + onSubtitle: (callback: (data: SubtitleData) => void) => void; + onVisibility: (callback: (visible: boolean) => void) => void; + onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; + getOverlayVisibility: () => Promise; + getCurrentSubtitle: () => Promise; + getCurrentSubtitleRaw: () => Promise; + getCurrentSubtitleAss: () => Promise; + getMpvSubtitleRenderMetrics: () => Promise; + onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void; + onSubtitleAss: (callback: (assText: string) => void) => void; + onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => void; + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; + openYomitanSettings: () => void; + getSubtitlePosition: () => Promise; + saveSubtitlePosition: (position: SubtitlePosition) => void; + getMecabStatus: () => Promise; + setMecabEnabled: (enabled: boolean) => void; + sendMpvCommand: (command: (string | number)[]) => void; + getKeybindings: () => Promise; + getConfiguredShortcuts: () => Promise>; + getJimakuMediaInfo: () => Promise; + jimakuSearchEntries: (query: JimakuSearchQuery) => Promise>; + jimakuListFiles: (query: JimakuFilesQuery) => Promise>; + jimakuDownloadFile: (query: JimakuDownloadQuery) => Promise; + quitApp: () => void; + toggleDevTools: () => void; + toggleOverlay: () => void; + getAnkiConnectStatus: () => Promise; + setAnkiConnectEnabled: (enabled: boolean) => void; + clearAnkiConnectHistory: () => void; + onSecondarySub: (callback: (text: string) => void) => void; + onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void; + getSecondarySubMode: () => Promise; + getCurrentSecondarySub: () => Promise; + focusMainWindow: () => Promise; + getSubtitleStyle: () => Promise; + onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void; + kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise; + kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void; + getRuntimeOptions: () => Promise; + setRuntimeOptionValue: ( + id: RuntimeOptionId, + value: RuntimeOptionValue, + ) => Promise; + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; + onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; + onOpenRuntimeOptions: (callback: () => void) => void; + onOpenJimaku: (callback: () => void) => void; + appendClipboardVideoToQueue: () => Promise; + notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void; + reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; + reportHoveredSubtitleToken: (tokenIndex: number | null) => void; + onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts new file mode 100644 index 0000000..dcc7254 --- /dev/null +++ b/src/window-trackers/base-tracker.ts @@ -0,0 +1,68 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import { WindowGeometry } from '../types'; + +export type GeometryChangeCallback = (geometry: WindowGeometry) => void; +export type WindowFoundCallback = (geometry: WindowGeometry) => void; +export type WindowLostCallback = () => void; + +export abstract class BaseWindowTracker { + protected currentGeometry: WindowGeometry | null = null; + protected windowFound: boolean = false; + public onGeometryChange: GeometryChangeCallback | null = null; + public onWindowFound: WindowFoundCallback | null = null; + public onWindowLost: WindowLostCallback | null = null; + + abstract start(): void; + abstract stop(): void; + + getGeometry(): WindowGeometry | null { + return this.currentGeometry; + } + + isTracking(): boolean { + return this.windowFound; + } + + protected updateGeometry(newGeometry: WindowGeometry | null): void { + if (newGeometry) { + if (!this.windowFound) { + this.windowFound = true; + if (this.onWindowFound) this.onWindowFound(newGeometry); + } + + if ( + !this.currentGeometry || + this.currentGeometry.x !== newGeometry.x || + this.currentGeometry.y !== newGeometry.y || + this.currentGeometry.width !== newGeometry.width || + this.currentGeometry.height !== newGeometry.height + ) { + this.currentGeometry = newGeometry; + if (this.onGeometryChange) this.onGeometryChange(newGeometry); + } + } else { + if (this.windowFound) { + this.windowFound = false; + this.currentGeometry = null; + if (this.onWindowLost) this.onWindowLost(); + } + } + } +} diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts new file mode 100644 index 0000000..e8ef904 --- /dev/null +++ b/src/window-trackers/hyprland-tracker.ts @@ -0,0 +1,155 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import * as net from 'net'; +import { execSync } from 'child_process'; +import { BaseWindowTracker } from './base-tracker'; +import { createLogger } from '../logger'; + +const log = createLogger('tracker').child('hyprland'); + +interface HyprlandClient { + class: string; + at: [number, number]; + size: [number, number]; + pid?: number; +} + +export class HyprlandWindowTracker extends BaseWindowTracker { + private pollInterval: ReturnType | null = null; + private eventSocket: net.Socket | null = null; + private readonly targetMpvSocketPath: string | null; + + constructor(targetMpvSocketPath?: string) { + super(); + this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + } + + start(): void { + this.pollInterval = setInterval(() => this.pollGeometry(), 250); + this.pollGeometry(); + this.connectEventSocket(); + } + + stop(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + if (this.eventSocket) { + this.eventSocket.destroy(); + this.eventSocket = null; + } + } + + private connectEventSocket(): void { + const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE; + if (!hyprlandSig) { + log.info('HYPRLAND_INSTANCE_SIGNATURE not set, skipping event socket'); + return; + } + + const xdgRuntime = process.env.XDG_RUNTIME_DIR || '/tmp'; + const socketPath = `${xdgRuntime}/hypr/${hyprlandSig}/.socket2.sock`; + this.eventSocket = new net.Socket(); + + this.eventSocket.on('connect', () => { + log.info('Connected to Hyprland event socket'); + }); + + this.eventSocket.on('data', (data: Buffer) => { + const events = data.toString().split('\n'); + for (const event of events) { + if ( + event.includes('movewindow') || + event.includes('windowtitle') || + event.includes('openwindow') || + event.includes('closewindow') || + event.includes('fullscreen') + ) { + this.pollGeometry(); + } + } + }); + + this.eventSocket.on('error', (err: Error) => { + log.error('Hyprland event socket error:', err.message); + }); + + this.eventSocket.on('close', () => { + log.info('Hyprland event socket closed'); + }); + + this.eventSocket.connect(socketPath); + } + + private pollGeometry(): void { + try { + const output = execSync('hyprctl clients -j', { encoding: 'utf-8' }); + const clients: HyprlandClient[] = JSON.parse(output); + const mpvWindow = this.findTargetWindow(clients); + + if (mpvWindow) { + this.updateGeometry({ + x: mpvWindow.at[0], + y: mpvWindow.at[1], + width: mpvWindow.size[0], + height: mpvWindow.size[1], + }); + } else { + this.updateGeometry(null); + } + } catch (err) { + // hyprctl not available or failed - silent fail + } + } + + private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null { + const mpvWindows = clients.filter((client) => client.class === 'mpv'); + if (!this.targetMpvSocketPath) { + return mpvWindows[0] || null; + } + + for (const mpvWindow of mpvWindows) { + if (!mpvWindow.pid) { + continue; + } + + const commandLine = this.getWindowCommandLine(mpvWindow.pid); + if (!commandLine) { + continue; + } + + if ( + commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || + commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) + ) { + return mpvWindow; + } + } + + return null; + } + + private getWindowCommandLine(pid: number): string | null { + const commandLine = execSync(`ps -p ${pid} -o args=`, { + encoding: 'utf-8', + }).trim(); + return commandLine || null; + } +} diff --git a/src/window-trackers/index.ts b/src/window-trackers/index.ts new file mode 100644 index 0000000..fb635e5 --- /dev/null +++ b/src/window-trackers/index.ts @@ -0,0 +1,85 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import { BaseWindowTracker } from './base-tracker'; +import { HyprlandWindowTracker } from './hyprland-tracker'; +import { SwayWindowTracker } from './sway-tracker'; +import { X11WindowTracker } from './x11-tracker'; +import { MacOSWindowTracker } from './macos-tracker'; +import { createLogger } from '../logger'; + +const log = createLogger('tracker'); + +export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | null; +export type Backend = 'auto' | Exclude; + +export function detectCompositor(): Compositor { + if (process.platform === 'darwin') return 'macos'; + if (process.env.HYPRLAND_INSTANCE_SIGNATURE) return 'hyprland'; + if (process.env.SWAYSOCK) return 'sway'; + if (process.platform === 'linux') return 'x11'; + return null; +} + +function normalizeCompositor(value: string): Compositor | null { + const normalized = value.trim().toLowerCase(); + if (normalized === 'hyprland') return 'hyprland'; + if (normalized === 'sway') return 'sway'; + if (normalized === 'x11') return 'x11'; + if (normalized === 'macos') return 'macos'; + return null; +} + +export function createWindowTracker( + override?: string | null, + targetMpvSocketPath?: string | null, +): BaseWindowTracker | null { + let compositor = detectCompositor(); + + if (override && override !== 'auto') { + const normalized = normalizeCompositor(override); + if (normalized) { + compositor = normalized; + } else { + log.warn(`Unsupported backend override "${override}", falling back to auto.`); + } + } + log.info(`Detected compositor: ${compositor || 'none'}`); + + switch (compositor) { + case 'hyprland': + return new HyprlandWindowTracker(targetMpvSocketPath?.trim() || undefined); + case 'sway': + return new SwayWindowTracker(targetMpvSocketPath?.trim() || undefined); + case 'x11': + return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined); + case 'macos': + return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined); + default: + log.warn('No supported compositor detected. Window tracking disabled.'); + return null; + } +} + +export { + BaseWindowTracker, + HyprlandWindowTracker, + SwayWindowTracker, + X11WindowTracker, + MacOSWindowTracker, +}; diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts new file mode 100644 index 0000000..13b9272 --- /dev/null +++ b/src/window-trackers/macos-tracker.ts @@ -0,0 +1,210 @@ +/* + subminer - Yomitan integration for mpv + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import { execFile } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { BaseWindowTracker } from './base-tracker'; +import { createLogger } from '../logger'; + +const log = createLogger('tracker').child('macos'); + +export class MacOSWindowTracker extends BaseWindowTracker { + private pollInterval: ReturnType | null = null; + private pollInFlight = false; + private helperPath: string | null = null; + private helperType: 'binary' | 'swift' | null = null; + private lastExecErrorFingerprint: string | null = null; + private lastExecErrorLoggedAtMs = 0; + private readonly targetMpvSocketPath: string | null; + + constructor(targetMpvSocketPath?: string) { + super(); + this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + this.detectHelper(); + } + + private materializeAsarHelper(sourcePath: string, helperType: 'binary' | 'swift'): string | null { + if (!sourcePath.includes('.asar')) { + return sourcePath; + } + + const fileName = + helperType === 'binary' ? 'get-mpv-window-macos' : 'get-mpv-window-macos.swift'; + const targetDir = path.join(os.tmpdir(), 'subminer', 'helpers'); + const targetPath = path.join(targetDir, fileName); + + try { + fs.mkdirSync(targetDir, { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o755); + log.info(`Materialized macOS helper from asar: ${targetPath}`); + return targetPath; + } catch (error) { + log.warn(`Failed to materialize helper from asar: ${sourcePath}`, error); + return null; + } + } + + private tryUseHelper(candidatePath: string, helperType: 'binary' | 'swift'): boolean { + if (!fs.existsSync(candidatePath)) { + return false; + } + + const resolvedPath = this.materializeAsarHelper(candidatePath, helperType); + if (!resolvedPath) { + return false; + } + + this.helperPath = resolvedPath; + this.helperType = helperType; + log.info(`Using macOS helper (${helperType}): ${resolvedPath}`); + return true; + } + + private detectHelper(): void { + const shouldFilterBySocket = this.targetMpvSocketPath !== null; + + // Fall back to Swift helper first when filtering by socket path to avoid + // stale prebuilt binaries that don't support the new socket filter argument. + const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift'); + if (shouldFilterBySocket && this.tryUseHelper(swiftPath, 'swift')) { + return; + } + + // Prefer resources path (outside asar) in packaged apps. + const resourcesPath = process.resourcesPath; + if (resourcesPath) { + const resourcesBinaryPath = path.join(resourcesPath, 'scripts', 'get-mpv-window-macos'); + if (this.tryUseHelper(resourcesBinaryPath, 'binary')) { + return; + } + } + + // Dist binary path (development / unpacked installs). + const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos'); + if (this.tryUseHelper(distBinaryPath, 'binary')) { + return; + } + + // Fall back to Swift script for development or if binary filtering is not + // supported in the current environment. + if (this.tryUseHelper(swiftPath, 'swift')) { + return; + } + + log.warn('macOS window tracking helper not found'); + } + + private maybeLogExecError(err: Error, stderr: string): void { + const now = Date.now(); + const fingerprint = `${err.message}|${stderr.trim()}`; + const shouldLog = + this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000; + if (!shouldLog) { + return; + } + this.lastExecErrorFingerprint = fingerprint; + this.lastExecErrorLoggedAtMs = now; + log.warn('macOS helper execution failed', { + helperPath: this.helperPath, + helperType: this.helperType, + error: err.message, + stderr: stderr.trim(), + }); + } + + start(): void { + this.pollInterval = setInterval(() => this.pollGeometry(), 250); + this.pollGeometry(); + } + + stop(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + + private pollGeometry(): void { + if (this.pollInFlight || !this.helperPath || !this.helperType) { + return; + } + + this.pollInFlight = true; + + // Use Core Graphics API via Swift helper for reliable window detection + // This works with both bundled and unbundled mpv installations + const command = this.helperType === 'binary' ? this.helperPath : 'swift'; + const args = this.helperType === 'binary' ? [] : [this.helperPath]; + if (this.targetMpvSocketPath) { + args.push(this.targetMpvSocketPath); + } + + execFile( + command, + args, + { + encoding: 'utf-8', + timeout: 1000, + maxBuffer: 1024 * 1024, + }, + (err, stdout, stderr) => { + if (err) { + this.maybeLogExecError(err, stderr || ''); + this.updateGeometry(null); + this.pollInFlight = false; + return; + } + + const result = (stdout || '').trim(); + if (result && result !== 'not-found') { + const parts = result.split(','); + if (parts.length === 4) { + const x = parseInt(parts[0]!, 10); + const y = parseInt(parts[1]!, 10); + const width = parseInt(parts[2]!, 10); + const height = parseInt(parts[3]!, 10); + + if ( + Number.isFinite(x) && + Number.isFinite(y) && + Number.isFinite(width) && + Number.isFinite(height) && + width > 0 && + height > 0 + ) { + this.updateGeometry({ + x, + y, + width, + height, + }); + this.pollInFlight = false; + return; + } + } + } + + this.updateGeometry(null); + this.pollInFlight = false; + }, + ); + } +} diff --git a/src/window-trackers/sway-tracker.ts b/src/window-trackers/sway-tracker.ts new file mode 100644 index 0000000..c63e5f1 --- /dev/null +++ b/src/window-trackers/sway-tracker.ts @@ -0,0 +1,132 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import { execSync } from 'child_process'; +import { BaseWindowTracker } from './base-tracker'; + +interface SwayRect { + x: number; + y: number; + width: number; + height: number; +} + +interface SwayNode { + pid?: number; + app_id?: string; + window_properties?: { class?: string }; + rect?: SwayRect; + nodes?: SwayNode[]; + floating_nodes?: SwayNode[]; +} + +export class SwayWindowTracker extends BaseWindowTracker { + private pollInterval: ReturnType | null = null; + private readonly targetMpvSocketPath: string | null; + + constructor(targetMpvSocketPath?: string) { + super(); + this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + } + + start(): void { + this.pollInterval = setInterval(() => this.pollGeometry(), 250); + this.pollGeometry(); + } + + stop(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + + private collectMpvWindows(node: SwayNode): SwayNode[] { + const windows: SwayNode[] = []; + if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') { + windows.push(node); + } + + if (node.nodes) { + for (const child of node.nodes) { + windows.push(...this.collectMpvWindows(child)); + } + } + + if (node.floating_nodes) { + for (const child of node.floating_nodes) { + windows.push(...this.collectMpvWindows(child)); + } + } + + return windows; + } + + private findTargetSocketWindow(node: SwayNode): SwayNode | null { + const windows = this.collectMpvWindows(node); + if (!this.targetMpvSocketPath) { + return windows[0] || null; + } + + return windows.find((candidate) => this.isWindowForTargetSocket(candidate)) || null; + } + + private isWindowForTargetSocket(node: SwayNode): boolean { + if (!node.pid) { + return false; + } + + const commandLine = this.getWindowCommandLine(node.pid); + if (!commandLine) { + return false; + } + + return ( + commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || + commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) + ); + } + + private getWindowCommandLine(pid: number): string | null { + const commandLine = execSync(`ps -p ${pid} -o args=`, { + encoding: 'utf-8', + }).trim(); + return commandLine || null; + } + + private pollGeometry(): void { + try { + const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' }); + const tree: SwayNode = JSON.parse(output); + const mpvWindow = this.findTargetSocketWindow(tree); + + if (mpvWindow && mpvWindow.rect) { + this.updateGeometry({ + x: mpvWindow.rect.x, + y: mpvWindow.rect.y, + width: mpvWindow.rect.width, + height: mpvWindow.rect.height, + }); + } else { + this.updateGeometry(null); + } + } catch (err) { + // swaymsg not available or failed - silent fail + } + } +} diff --git a/src/window-trackers/x11-tracker.test.ts b/src/window-trackers/x11-tracker.test.ts new file mode 100644 index 0000000..0d1c42b --- /dev/null +++ b/src/window-trackers/x11-tracker.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseX11WindowGeometry, parseX11WindowPid, X11WindowTracker } from './x11-tracker'; + +test('parseX11WindowGeometry parses xwininfo output', () => { + const geometry = parseX11WindowGeometry(` +Absolute upper-left X: 120 +Absolute upper-left Y: 240 +Width: 1280 +Height: 720 +`); + assert.deepEqual(geometry, { + x: 120, + y: 240, + width: 1280, + height: 720, + }); +}); + +test('parseX11WindowPid parses xprop output', () => { + assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = 4242'), 4242); + assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null); +}); + +test('X11WindowTracker skips overlapping polls while one command is in flight', async () => { + let commandCalls = 0; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const tracker = new X11WindowTracker(undefined, async (command) => { + commandCalls += 1; + if (command === 'xdotool') { + await gate; + return '123'; + } + if (command === 'xwininfo') { + return `Absolute upper-left X: 0 +Absolute upper-left Y: 0 +Width: 640 +Height: 360`; + } + return ''; + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(commandCalls, 1); + + assert.ok(release); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}); diff --git a/src/window-trackers/x11-tracker.ts b/src/window-trackers/x11-tracker.ts new file mode 100644 index 0000000..43274d0 --- /dev/null +++ b/src/window-trackers/x11-tracker.ts @@ -0,0 +1,199 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import { execFile } from 'child_process'; +import { BaseWindowTracker } from './base-tracker'; + +type CommandRunner = (command: string, args: string[]) => Promise; + +function execFileUtf8(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); +} + +export function parseX11WindowGeometry(winInfo: string): { + x: number; + y: number; + width: number; + height: number; +} | null { + const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/); + const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/); + const widthMatch = winInfo.match(/Width:\s*(\d+)/); + const heightMatch = winInfo.match(/Height:\s*(\d+)/); + if (!xMatch || !yMatch || !widthMatch || !heightMatch) { + return null; + } + return { + x: parseInt(xMatch[1]!, 10), + y: parseInt(yMatch[1]!, 10), + width: parseInt(widthMatch[1]!, 10), + height: parseInt(heightMatch[1]!, 10), + }; +} + +export function parseX11WindowPid(raw: string): number | null { + const pidMatch = raw.match(/= (\d+)/); + if (!pidMatch) { + return null; + } + const pid = Number.parseInt(pidMatch[1]!, 10); + return Number.isInteger(pid) ? pid : null; +} + +export class X11WindowTracker extends BaseWindowTracker { + private pollInterval: ReturnType | null = null; + private readonly targetMpvSocketPath: string | null; + private readonly runCommand: CommandRunner; + private pollInFlight = false; + private currentPollIntervalMs = 750; + private readonly stablePollIntervalMs = 250; + + constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8) { + super(); + this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + this.runCommand = runCommand; + } + + start(): void { + this.resetPollInterval(this.currentPollIntervalMs); + this.pollGeometry(); + } + + stop(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + + private resetPollInterval(intervalMs: number): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + this.pollInterval = setInterval(() => this.pollGeometry(), intervalMs); + } + + private pollGeometry(): void { + if (this.pollInFlight) { + return; + } + this.pollInFlight = true; + void this.pollGeometryAsync() + .catch(() => { + this.updateGeometry(null); + }) + .finally(() => { + this.pollInFlight = false; + }); + } + + private async pollGeometryAsync(): Promise { + const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']); + const windowIds = windowIdsOutput.trim(); + if (!windowIds) { + this.updateGeometry(null); + return; + } + + const windowIdList = windowIds.split(/\s+/).filter(Boolean); + if (windowIdList.length === 0) { + this.updateGeometry(null); + return; + } + + const windowId = await this.findTargetWindowId(windowIdList); + if (!windowId) { + this.updateGeometry(null); + return; + } + + const winInfo = await this.runCommand('xwininfo', ['-id', windowId]); + const geometry = parseX11WindowGeometry(winInfo); + if (!geometry) { + this.updateGeometry(null); + return; + } + + this.updateGeometry(geometry); + if (this.pollInterval && this.currentPollIntervalMs !== this.stablePollIntervalMs) { + this.currentPollIntervalMs = this.stablePollIntervalMs; + this.resetPollInterval(this.currentPollIntervalMs); + } + } + + private async findTargetWindowId(windowIds: string[]): Promise { + if (!this.targetMpvSocketPath) { + return windowIds[0] ?? null; + } + + for (const windowId of windowIds) { + if (await this.isWindowForTargetSocket(windowId)) { + return windowId; + } + } + + return null; + } + + private async isWindowForTargetSocket(windowId: string): Promise { + const pid = await this.getWindowPid(windowId); + if (pid === null) { + return false; + } + + const commandLine = await this.getWindowCommandLine(pid); + if (!commandLine) { + return false; + } + + return ( + commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || + commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) + ); + } + + private async getWindowPid(windowId: string): Promise { + let windowPid: string; + try { + windowPid = await this.runCommand('xprop', ['-id', windowId, '_NET_WM_PID']); + } catch { + return null; + } + return parseX11WindowPid(windowPid); + } + + private async getWindowCommandLine(pid: number): Promise { + let raw: string; + try { + raw = await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']); + } catch { + return null; + } + const commandLine = raw.trim(); + return commandLine || null; + } +}