mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
234
src/anki-connect.ts
Normal file
234
src/anki-connect.ts
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private isRetryableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const code = (error as Record<string, unknown>).code;
|
||||
const message =
|
||||
typeof (error as Record<string, unknown>).message === 'string'
|
||||
? ((error as Record<string, unknown>).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<string, unknown> = {},
|
||||
options: { timeout?: number; maxRetries?: number } = {},
|
||||
): Promise<unknown> {
|
||||
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<AnkiConnectResponse>(
|
||||
'',
|
||||
{
|
||||
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<number[]> {
|
||||
const result = await this.invoke('findNotes', { query }, options);
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.invoke('notesInfo', { notes: noteIds });
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
}
|
||||
|
||||
async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> {
|
||||
await this.invoke('updateNoteFields', {
|
||||
note: {
|
||||
id: noteId,
|
||||
fields,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async storeMediaFile(filename: string, data: Buffer): Promise<void> {
|
||||
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<string, string>,
|
||||
tags: string[] = [],
|
||||
): Promise<number> {
|
||||
const note: {
|
||||
deckName: string;
|
||||
modelName: string;
|
||||
fields: Record<string, string>;
|
||||
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<void> {
|
||||
if (noteIds.length === 0 || tags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.invoke('addTags', {
|
||||
notes: noteIds,
|
||||
tags: tags.join(' '),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNotes(noteIds: number[]): Promise<void> {
|
||||
await this.invoke('deleteNotes', { notes: noteIds });
|
||||
}
|
||||
|
||||
async retrieveMediaFile(filename: string): Promise<string> {
|
||||
const result = await this.invoke('retrieveMediaFile', { filename });
|
||||
return (result as string) || '';
|
||||
}
|
||||
|
||||
resetBackoff(): void {
|
||||
this.backoffMs = 200;
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
}
|
||||
268
src/anki-integration.test.ts
Normal file
268
src/anki-integration.test.ts
Normal file
@@ -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<number[]>;
|
||||
onNotesInfo?: () => Promise<unknown[]>;
|
||||
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<number[]>;
|
||||
notesInfo: () => Promise<unknown[]>;
|
||||
};
|
||||
|
||||
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<number[]>;
|
||||
notesInfo: () => Promise<unknown[]>;
|
||||
};
|
||||
};
|
||||
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<AnkiConnectConfig>;
|
||||
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<string, string> = {};
|
||||
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<void>((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,
|
||||
'<span data-group-id="101">[sound:keep.mp3]</span><span data-group-id="202">[sound:new.mp3]</span>',
|
||||
);
|
||||
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, '<span data-group-id="22">[sound:generated.mp3]</span>');
|
||||
});
|
||||
1120
src/anki-integration.ts
Normal file
1120
src/anki-integration.ts
Normal file
File diff suppressed because it is too large
Load Diff
155
src/anki-integration/ai.ts
Normal file
155
src/anki-integration/ai.ts
Normal file
@@ -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<string | null> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
717
src/anki-integration/card-creation.ts
Normal file
717
src/anki-integration/card-creation.ts
Normal file
@@ -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<string, { value: string }>;
|
||||
}
|
||||
|
||||
type CardKind = 'sentence' | 'audio';
|
||||
|
||||
interface CardCreationClient {
|
||||
addNote(
|
||||
deck: string,
|
||||
modelName: string,
|
||||
fields: Record<string, string>,
|
||||
tags?: string[],
|
||||
): Promise<number>;
|
||||
addTags(noteIds: number[], tags: string[]): Promise<void>;
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
||||
}
|
||||
|
||||
interface CardCreationMediaGenerator {
|
||||
generateAudio(
|
||||
path: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: string,
|
||||
timestamp: number,
|
||||
options: {
|
||||
format: 'jpg' | 'png' | 'webp';
|
||||
quality?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
},
|
||||
): Promise<Buffer | null>;
|
||||
generateAnimatedImage(
|
||||
path: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
options?: {
|
||||
fps?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
crf?: number;
|
||||
},
|
||||
): Promise<Buffer | null>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
beginUpdateProgress: (initialMessage: string) => void;
|
||||
endUpdateProgress: () => void;
|
||||
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: CardCreationNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
) => string | null;
|
||||
resolveNoteFieldName: (noteInfo: CardCreationNoteInfo, preferredName?: string) => string | null;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
||||
setCardTypeFields: (
|
||||
updatedFields: Record<string, string>,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
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,
|
||||
`<img src="${imageFilename}">`,
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
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] = `<img src="${imageFilename}">`;
|
||||
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<boolean> {
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
|
||||
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] = `<img src="${imageFilename}">`;
|
||||
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<Buffer | null> {
|
||||
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<Buffer | null> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
265
src/anki-integration/duplicate.test.ts
Normal file
265
src/anki-integration/duplicate.test.ts
Normal file
@@ -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: '<span data-x="1">貴様</span>' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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));
|
||||
});
|
||||
194
src/anki-integration/duplicate.ts
Normal file
194
src/anki-integration/duplicate.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
export interface NoteField {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface NoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, NoteField>;
|
||||
}
|
||||
|
||||
export interface DuplicateDetectionDeps {
|
||||
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>;
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown>;
|
||||
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<number | null> {
|
||||
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<number>();
|
||||
const executedQueries = new Set<string>();
|
||||
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<number>,
|
||||
excludeNoteId: number,
|
||||
sourceValues: string[],
|
||||
deps: DuplicateDetectionDeps,
|
||||
): Promise<number | null> {
|
||||
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<string>();
|
||||
|
||||
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');
|
||||
}
|
||||
461
src/anki-integration/field-grouping-merge.ts
Normal file
461
src/anki-integration/field-grouping-merge.ts
Normal file
@@ -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<string, { value: string }>;
|
||||
}
|
||||
|
||||
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<string, { value: string }>) => Record<string, string>;
|
||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
||||
generateMediaForMerge: () => Promise<FieldGroupingMergeMedia>;
|
||||
warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void;
|
||||
}
|
||||
|
||||
export class FieldGroupingMergeCollaborator {
|
||||
private readonly strictGroupingFieldDefaults = new Set<string>([
|
||||
'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<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
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<Record<string, string>> {
|
||||
const config = this.deps.getConfig();
|
||||
const groupableFields = this.getGroupableFieldNames();
|
||||
const keepFieldNames = Object.keys(keepNoteInfo.fields);
|
||||
const sourceFields: Record<string, string> = {};
|
||||
const resolvedKeepFieldByPreferred = new Map<string, string>();
|
||||
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<string, string> = {};
|
||||
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 = /<span\s+data-group-id="[^"]*">[\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(/<img\b[^>]*>/gi);
|
||||
if (!matches || matches.length === 0) return '';
|
||||
return matches[matches.length - 1]!;
|
||||
}
|
||||
|
||||
private extractImageTags(value: string): string[] {
|
||||
const matches = value.match(/<img\b[^>]*>/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(/<img\b/i, `<img data-group-id="${groupId}"`);
|
||||
}
|
||||
|
||||
private extractSpanEntries(
|
||||
value: string,
|
||||
fieldName: string,
|
||||
): { groupId: number; content: string }[] {
|
||||
const entries: { groupId: number; content: string }[] = [];
|
||||
const malformedIdRegex = /<span\s+[^>]*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 = /<span\s+data-group-id="(\d+)"[^>]*>([\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 && /<span\b/i.test(value)) {
|
||||
this.deps.warnFieldParseOnce(fieldName, 'no-usable-span-entries');
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private parseStrictEntries(
|
||||
value: string,
|
||||
fallbackGroupId: number,
|
||||
fieldName: string,
|
||||
): { groupId: number; content: string }[] {
|
||||
const entries = this.extractSpanEntries(value, fieldName);
|
||||
if (entries.length === 0) {
|
||||
const ungrouped = this.normalizeStrictGroupedValue(
|
||||
this.extractUngroupedValue(value),
|
||||
fieldName,
|
||||
);
|
||||
if (ungrouped) {
|
||||
entries.push({ groupId: fallbackGroupId, content: ungrouped });
|
||||
}
|
||||
}
|
||||
|
||||
const unique: { groupId: number; content: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<string> {
|
||||
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) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
|
||||
.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) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (!existingValue.trim()) return newValue;
|
||||
if (!newValue.trim()) return existingValue;
|
||||
|
||||
const hasGroups = /data-group-id/.test(existingValue);
|
||||
|
||||
if (!hasGroups) {
|
||||
return `<span data-group-id="${keepGroupId}">${existingValue}</span>\n` + newValue;
|
||||
}
|
||||
|
||||
const groupedSpanRegex = /<span\s+data-group-id="[^"]*">[\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 += `<span data-group-id="${keepGroupId}">${before.trim()}</span>\n`;
|
||||
}
|
||||
result += match[0] + '\n';
|
||||
lastEnd = match.index + match[0].length;
|
||||
}
|
||||
|
||||
const after = existingValue.slice(lastEnd);
|
||||
if (after.trim()) {
|
||||
result += `\n<span data-group-id="${keepGroupId}">${after.trim()}</span>`;
|
||||
}
|
||||
|
||||
return result + '\n' + newValue;
|
||||
}
|
||||
}
|
||||
114
src/anki-integration/field-grouping-workflow.test.ts
Normal file
114
src/anki-integration/field-grouping-workflow.test.ts
Normal file
@@ -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<string, { value: string }>;
|
||||
};
|
||||
|
||||
function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
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<string, string>) => {
|
||||
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<string, { value: string }>) => {
|
||||
const out: Record<string, string> = {};
|
||||
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);
|
||||
});
|
||||
214
src/anki-integration/field-grouping-workflow.ts
Normal file
214
src/anki-integration/field-grouping-workflow.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
|
||||
|
||||
export interface FieldGroupingWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export interface FieldGroupingWorkflowDeps {
|
||||
client: {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
};
|
||||
getConfig: () => {
|
||||
fields?: {
|
||||
audio?: string;
|
||||
image?: string;
|
||||
};
|
||||
};
|
||||
getEffectiveSentenceCardConfig: () => {
|
||||
sentenceField: string;
|
||||
audioField: string;
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
};
|
||||
getCurrentSubtitleText: () => string | undefined;
|
||||
getFieldGroupingCallback:
|
||||
| (() => Promise<
|
||||
| ((data: {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}) => Promise<KikuFieldGroupingChoice>)
|
||||
| null
|
||||
>)
|
||||
| (() =>
|
||||
| ((data: {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}) => Promise<KikuFieldGroupingChoice>)
|
||||
| null);
|
||||
computeFieldGroupingMergedFields: (
|
||||
keepNoteId: number,
|
||||
deleteNoteId: number,
|
||||
keepNoteInfo: FieldGroupingWorkflowNoteInfo,
|
||||
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
|
||||
includeGeneratedMedia: boolean,
|
||||
) => Promise<Record<string, string>>;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
removeTrackedNoteId: (noteId: number) => void;
|
||||
showStatusNotification: (message: string) => void;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<KikuFieldGroupingChoice>)
|
||||
| null
|
||||
> {
|
||||
const callback = this.deps.getFieldGroupingCallback();
|
||||
if (callback instanceof Promise) {
|
||||
return callback;
|
||||
}
|
||||
return callback;
|
||||
}
|
||||
}
|
||||
236
src/anki-integration/field-grouping.ts
Normal file
236
src/anki-integration/field-grouping.ts
Normal file
@@ -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<string, { value: string }>;
|
||||
}
|
||||
|
||||
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: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>;
|
||||
showOsdNotification: (text: string) => void;
|
||||
findNotes: (
|
||||
query: string,
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
},
|
||||
) => Promise<number[]>;
|
||||
notesInfo: (noteIds: number[]) => Promise<FieldGroupingNoteInfo[]>;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
findDuplicateNote: (
|
||||
expression: string,
|
||||
excludeNoteId: number,
|
||||
noteInfo: FieldGroupingNoteInfo,
|
||||
) => Promise<number | null>;
|
||||
hasAllConfiguredFields: (
|
||||
noteInfo: FieldGroupingNoteInfo,
|
||||
configuredFieldNames: (string | undefined)[],
|
||||
) => boolean;
|
||||
processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise<void>;
|
||||
getSentenceCardImageFieldName: () => string | undefined;
|
||||
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
|
||||
computeFieldGroupingMergedFields: (
|
||||
keepNoteId: number,
|
||||
deleteNoteId: number,
|
||||
keepNoteInfo: FieldGroupingNoteInfo,
|
||||
deleteNoteInfo: FieldGroupingNoteInfo,
|
||||
includeGeneratedMedia: boolean,
|
||||
) => Promise<Record<string, string>>;
|
||||
getNoteFieldMap: (noteInfo: FieldGroupingNoteInfo) => Record<string, string>;
|
||||
handleFieldGroupingAuto: (
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
newNoteInfo: FieldGroupingNoteInfo,
|
||||
expression: string,
|
||||
) => Promise<void>;
|
||||
handleFieldGroupingManual: (
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
newNoteInfo: FieldGroupingNoteInfo,
|
||||
expression: string,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class FieldGroupingService {
|
||||
constructor(private readonly deps: FieldGroupingDeps) {}
|
||||
|
||||
async triggerFieldGroupingForLastAddedCard(): Promise<void> {
|
||||
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<KikuMergePreviewResponse> {
|
||||
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<string, string> = {};
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
388
src/anki-integration/known-word-cache.ts
Normal file
388
src/anki-integration/known-word-cache.ts
Normal file
@@ -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<string, { value: string }>;
|
||||
}
|
||||
|
||||
interface KnownWordCacheState {
|
||||
readonly version: 1;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly words: string[];
|
||||
}
|
||||
|
||||
interface KnownWordCacheClient {
|
||||
findNotes: (
|
||||
query: string,
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface KnownWordCacheDeps {
|
||||
client: KnownWordCacheClient;
|
||||
getConfig: () => AnkiConnectConfig;
|
||||
knownWordCacheStatePath?: string;
|
||||
showStatusNotification: (message: string) => void;
|
||||
}
|
||||
|
||||
export class KnownWordCacheManager {
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsScope = '';
|
||||
private knownWords: Set<string> = new Set();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<KnownWordCacheState>;
|
||||
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');
|
||||
}
|
||||
173
src/anki-integration/note-update-workflow.test.ts
Normal file
173
src/anki-integration/note-update-workflow.test.ts
Normal file
@@ -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<string, string> }> = [];
|
||||
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<string, string>) => {
|
||||
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<string, { value: string }>) => {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string>) => 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);
|
||||
});
|
||||
242
src/anki-integration/note-update-workflow.ts
Normal file
242
src/anki-integration/note-update-workflow.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export interface NoteUpdateWorkflowDeps {
|
||||
client: {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||
};
|
||||
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<string, { value: string }>) => Record<string, string>;
|
||||
findDuplicateNote: (
|
||||
expression: string,
|
||||
excludeNoteId: number,
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
) => Promise<number | null>;
|
||||
handleFieldGroupingAuto: (
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
newNoteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
expression: string,
|
||||
) => Promise<void>;
|
||||
handleFieldGroupingManual: (
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
newNoteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
expression: string,
|
||||
) => Promise<boolean>;
|
||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => 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<Buffer | null>;
|
||||
generateImageFilename: () => string;
|
||||
generateImage: () => Promise<Buffer | null>;
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
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,
|
||||
`<img src="${imageFilename}">`,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/anki-integration/polling.ts
Normal file
119
src/anki-integration/polling.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export interface PollingRunnerDeps {
|
||||
getDeck: () => string | undefined;
|
||||
getPollingRate: () => number;
|
||||
findNotes: (
|
||||
query: string,
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
},
|
||||
) => Promise<number[]>;
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
isUpdateInProgress: () => boolean;
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
getTrackedNoteIds: () => Set<number>;
|
||||
setTrackedNoteIds: (noteIds: Set<number>) => void;
|
||||
showStatusNotification: (message: string) => void;
|
||||
logDebug: (...args: unknown[]) => void;
|
||||
logInfo: (...args: unknown[]) => void;
|
||||
logWarn: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export class PollingRunner {
|
||||
private pollingInterval: ReturnType<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
if (this.pollingInterval) {
|
||||
return;
|
||||
}
|
||||
return this.pollOnce();
|
||||
}
|
||||
}
|
||||
104
src/anki-integration/ui-feedback.ts
Normal file
104
src/anki-integration/ui-feedback.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NotificationOptions } from '../types';
|
||||
|
||||
export interface UiFeedbackState {
|
||||
progressDepth: number;
|
||||
progressTimer: ReturnType<typeof setInterval> | 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<typeof setInterval>) => 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<T>(
|
||||
state: UiFeedbackState,
|
||||
options: UiFeedbackOptions,
|
||||
initialMessage: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
beginUpdateProgress(state, initialMessage, () =>
|
||||
showProgressTick(state, options.showOsdNotification),
|
||||
);
|
||||
options.setUpdateInProgress(true);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
options.setUpdateInProgress(false);
|
||||
endUpdateProgress(state, clearInterval);
|
||||
}
|
||||
}
|
||||
103
src/cli/args.test.ts
Normal file
103
src/cli/args.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
352
src/cli/args.ts
Normal file
352
src/cli/args.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
27
src/cli/help.test.ts
Normal file
27
src/cli/help.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
80
src/cli/help.ts
Normal file
80
src/cli/help.ts
Normal file
@@ -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
|
||||
`);
|
||||
}
|
||||
1112
src/config/config.test.ts
Normal file
1112
src/config/config.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
101
src/config/definitions.ts
Normal file
101
src/config/definitions.ts
Normal file
@@ -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<string, unknown>;
|
||||
const patchObject = patch as Record<string, unknown>;
|
||||
|
||||
const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): 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<string, unknown>, value as Record<string, unknown>);
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mergeInto(clone, patchObject);
|
||||
return clone as RawConfig;
|
||||
}
|
||||
61
src/config/definitions/defaults-core.ts
Normal file
61
src/config/definitions/defaults-core.ts
Normal file
@@ -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',
|
||||
},
|
||||
};
|
||||
20
src/config/definitions/defaults-immersion.ts
Normal file
20
src/config/definitions/defaults-immersion.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ResolvedConfig } from '../../types';
|
||||
|
||||
export const IMMERSION_DEFAULT_CONFIG: Pick<ResolvedConfig, 'immersionTracking'> = {
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
113
src/config/definitions/defaults-integrations.ts
Normal file
113
src/config/definitions/defaults-integrations.ts
Normal file
@@ -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'],
|
||||
},
|
||||
};
|
||||
42
src/config/definitions/defaults-subtitle.ts
Normal file
42
src/config/definitions/defaults-subtitle.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ResolvedConfig } from '../../types';
|
||||
|
||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
59
src/config/definitions/domain-registry.test.ts
Normal file
59
src/config/definitions/domain-registry.test.ts
Normal file
@@ -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)));
|
||||
}
|
||||
});
|
||||
49
src/config/definitions/options-core.ts
Normal file
49
src/config/definitions/options-core.ts
Normal file
@@ -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).',
|
||||
},
|
||||
];
|
||||
}
|
||||
82
src/config/definitions/options-immersion.ts
Normal file
82
src/config/definitions/options-immersion.ts
Normal file
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
235
src/config/definitions/options-integrations.ts
Normal file
235
src/config/definitions/options-integrations.ts
Normal file
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
72
src/config/definitions/options-subtitle.ts
Normal file
72
src/config/definitions/options-subtitle.ts
Normal file
@@ -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).',
|
||||
},
|
||||
];
|
||||
}
|
||||
56
src/config/definitions/runtime-options.ts
Normal file
56
src/config/definitions/runtime-options.ts
Normal file
@@ -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',
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
61
src/config/definitions/shared.ts
Normal file
61
src/config/definitions/shared.ts
Normal file
@@ -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<AnkiConnectConfig>;
|
||||
}
|
||||
|
||||
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<ResolvedConfig['keybindings']> = [
|
||||
{ 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'] },
|
||||
];
|
||||
154
src/config/definitions/template-sections.ts
Normal file
154
src/config/definitions/template-sections.ts
Normal file
@@ -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,
|
||||
];
|
||||
3
src/config/index.ts
Normal file
3
src/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './definitions';
|
||||
export * from './service';
|
||||
export * from './template';
|
||||
65
src/config/load.ts
Normal file
65
src/config/load.ts
Normal file
@@ -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<string, unknown> {
|
||||
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 };
|
||||
}
|
||||
17
src/config/parse.ts
Normal file
17
src/config/parse.ts
Normal file
@@ -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;
|
||||
}
|
||||
89
src/config/path-resolution.test.ts
Normal file
89
src/config/path-resolution.test.ts
Normal file
@@ -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'));
|
||||
});
|
||||
76
src/config/path-resolution.ts
Normal file
76
src/config/path-resolution.ts
Normal file
@@ -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]!);
|
||||
}
|
||||
33
src/config/resolve.ts
Normal file
33
src/config/resolve.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
68
src/config/resolve/anki-connect.test.ts
Normal file
68
src/config/resolve/anki-connect.test.ts
Normal file
@@ -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<typeof createWarningCollector>['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'));
|
||||
});
|
||||
728
src/config/resolve/anki-connect.ts
Normal file
728
src/config/resolve/anki-connect.ts
Normal file
@@ -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<string, unknown>) : {};
|
||||
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
|
||||
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
|
||||
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
|
||||
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<string, unknown>;
|
||||
const hasOwn = (obj: Record<string, unknown>, 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 = <T>(
|
||||
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<string, unknown>) : {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
30
src/config/resolve/context.ts
Normal file
30
src/config/resolve/context.ts
Normal file
@@ -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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
179
src/config/resolve/core-domains.ts
Normal file
179
src/config/resolve/core-domains.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/config/resolve/immersion-tracking.ts
Normal file
173
src/config/resolve/immersion-tracking.ts
Normal file
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/config/resolve/integrations.ts
Normal file
128
src/config/resolve/integrations.ts
Normal file
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/config/resolve/jellyfin.test.ts
Normal file
64
src/config/resolve/jellyfin.test.ts
Normal file
@@ -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<string, unknown>), false);
|
||||
assert.equal('userId' in (context.resolved.jellyfin as Record<string, unknown>), 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'));
|
||||
});
|
||||
38
src/config/resolve/shared.ts
Normal file
38
src/config/resolve/shared.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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];
|
||||
}
|
||||
239
src/config/resolve/subtitle-domains.ts
Normal file
239
src/config/resolve/subtitle-domains.ts
Normal file
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/config/resolve/subtitle-style.test.ts
Normal file
29
src/config/resolve/subtitle-style.test.ts
Normal file
@@ -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.',
|
||||
),
|
||||
);
|
||||
});
|
||||
28
src/config/resolve/top-level.ts
Normal file
28
src/config/resolve/top-level.ts
Normal file
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
116
src/config/service.ts
Normal file
116
src/config/service.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
135
src/config/template.ts
Normal file
135
src/config/template.ts
Normal file
@@ -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<string, unknown>).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');
|
||||
}
|
||||
19
src/config/warnings.ts
Normal file
19
src/config/warnings.ts
Normal file
@@ -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 };
|
||||
}
|
||||
85
src/core/services/anilist/anilist-token-store.test.ts
Normal file
85
src/core/services/anilist/anilist-token-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
108
src/core/services/anilist/anilist-token-store.ts
Normal file
108
src/core/services/anilist/anilist-token-store.ts
Normal file
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
93
src/core/services/anilist/anilist-update-queue.test.ts
Normal file
93
src/core/services/anilist/anilist-update-queue.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
193
src/core/services/anilist/anilist-update-queue.ts
Normal file
193
src/core/services/anilist/anilist-update-queue.ts
Normal file
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
166
src/core/services/anilist/anilist-updater.test.ts
Normal file
166
src/core/services/anilist/anilist-updater.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
299
src/core/services/anilist/anilist-updater.ts
Normal file
299
src/core/services/anilist/anilist-updater.ts
Normal file
@@ -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<T> {
|
||||
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<string> {
|
||||
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<T>(
|
||||
accessToken: string,
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<AnilistGraphQlResponse<T>> {
|
||||
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<T>;
|
||||
return payload;
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): 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<AnilistMediaGuess | null> {
|
||||
const target = mediaPath ?? mediaTitle;
|
||||
|
||||
if (target && target.trim().length > 0) {
|
||||
try {
|
||||
const stdout = await runGuessit(target);
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
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<AnilistPostWatchUpdateResult> {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
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<AnilistMediaEntryData>(
|
||||
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<AnilistSaveEntryData>(
|
||||
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}.`,
|
||||
};
|
||||
}
|
||||
153
src/core/services/anki-jimaku-ipc.test.ts
Normal file
153
src/core/services/anki-jimaku-ipc.test.ts
Normal file
@@ -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<string, (event: unknown, ...args: unknown[]) => void>;
|
||||
handleHandlers: Map<string, (event: unknown, ...args: unknown[]) => unknown>;
|
||||
} {
|
||||
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>();
|
||||
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
185
src/core/services/anki-jimaku-ipc.ts
Normal file
185
src/core/services/anki-jimaku-ipc.ts
Normal file
@@ -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> | void;
|
||||
respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void;
|
||||
buildKikuMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
|
||||
getJimakuMediaInfo: () => JimakuMediaInfo;
|
||||
searchJimakuEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||
listJimakuFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
downloadToFile: (
|
||||
url: string,
|
||||
destPath: string,
|
||||
headers: Record<string, string>,
|
||||
) => Promise<JimakuDownloadResult>;
|
||||
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<KikuMergePreviewResponse> => {
|
||||
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<JimakuApiResponse<JimakuEntry[]>> => {
|
||||
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<JimakuApiResponse<JimakuFileEntry[]>> => {
|
||||
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<JimakuDownloadResult> => {
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
255
src/core/services/anki-jimaku.test.ts
Normal file
255
src/core/services/anki-jimaku.test.ts
Normal file
@@ -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<string, (...args: unknown[]) => unknown>;
|
||||
state: {
|
||||
ankiIntegration: unknown;
|
||||
fieldGroupingResolver: ((choice: unknown) => void) | null;
|
||||
patches: boolean[];
|
||||
broadcasts: number;
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
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<string, unknown>;
|
||||
}>,
|
||||
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<string, unknown>,
|
||||
});
|
||||
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<string, (...args: unknown[]) => unknown> = {};
|
||||
registerAnkiJimakuIpcRuntime(options, (deps) => {
|
||||
registered = deps as unknown as Record<string, (...args: unknown[]) => 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'] }]);
|
||||
});
|
||||
185
src/core/services/anki-jimaku.ts
Normal file
185
src/core/services/anki-jimaku.ts
Normal file
@@ -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<KikuFieldGroupingChoice>;
|
||||
broadcastRuntimeOptionsChanged: () => void;
|
||||
getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
jimakuFetchJson: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<JimakuApiResponse<T>>;
|
||||
getJimakuMaxEntryResults: () => number;
|
||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
downloadToFile: (
|
||||
url: string,
|
||||
destPath: string,
|
||||
headers: Record<string, string>,
|
||||
) => 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<JimakuEntry[]>('/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<JimakuFileEntry[]>(
|
||||
`/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'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
139
src/core/services/app-lifecycle.ts
Normal file
139
src/core/services/app-lifecycle.ts
Normal file
@@ -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>) => void;
|
||||
onWindowAllClosed: (handler: () => void) => void;
|
||||
onWillQuit: (handler: () => void) => void;
|
||||
onActivate: (handler: () => void) => void;
|
||||
isDarwinPlatform: () => boolean;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
restoreWindowsOnActivate: () => void;
|
||||
shouldQuitOnWindowAllClosed: () => boolean;
|
||||
}
|
||||
|
||||
interface AppLike {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
on: (...args: any[]) => unknown;
|
||||
whenReady: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
241
src/core/services/app-ready.test.ts
Normal file
241
src/core/services/app-ready.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
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<void>((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);
|
||||
});
|
||||
469
src/core/services/cli-command.test.ts
Normal file
469
src/core/services/cli-command.test.ts
Normal file
@@ -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> = {}): 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<CliCommandServiceDeps> = {}) {
|
||||
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<CliArgs>;
|
||||
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<Partial<CliArgs>> = [
|
||||
{ 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')));
|
||||
});
|
||||
458
src/core/services/cli-command.ts
Normal file
458
src/core/services/cli-command.ts
Normal file
@@ -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<void>;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWords: () => Promise<void>;
|
||||
cycleSecondarySubMode: () => void;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
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<void>;
|
||||
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<void>;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWords: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
};
|
||||
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<void>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
162
src/core/services/config-hot-reload.test.ts
Normal file
162
src/core/services/config-hot-reload.test.ts
Normal file
@@ -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<number, () => 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 }]);
|
||||
});
|
||||
165
src/core/services/config-hot-reload.ts
Normal file
165
src/core/services/config-hot-reload.ts
Normal file
@@ -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<keyof ResolvedConfig>),
|
||||
...(Object.keys(next) as Array<keyof ResolvedConfig>),
|
||||
]);
|
||||
|
||||
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 };
|
||||
113
src/core/services/discord-presence.test.ts
Normal file
113
src/core/services/discord-presence.test.ts
Normal file
@@ -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<number, () => 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<typeof setTimeout>;
|
||||
},
|
||||
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);
|
||||
});
|
||||
223
src/core/services/discord-presence.ts
Normal file
223
src/core/services/discord-presence.ts
Normal file
@@ -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<void>;
|
||||
setActivity: (activity: DiscordActivityPayload) => Promise<void>;
|
||||
clearActivity: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
type TimeoutLike = ReturnType<typeof setTimeout>;
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
141
src/core/services/field-grouping-overlay.test.ts
Normal file
141
src/core/services/field-grouping-overlay.test.ts
Normal file
@@ -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]);
|
||||
});
|
||||
81
src/core/services/field-grouping-overlay.ts
Normal file
81
src/core/services/field-grouping-overlay.ts
Normal file
@@ -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<T extends string> {
|
||||
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<T>;
|
||||
sendToVisibleOverlay?: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
options: FieldGroupingOverlayRuntimeOptions<T>,
|
||||
): {
|
||||
sendToVisibleOverlay: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
) => boolean;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
} {
|
||||
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<KikuFieldGroupingChoice>) => {
|
||||
return createFieldGroupingCallbackRuntime({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
sendToVisibleOverlay,
|
||||
createFieldGroupingCallback,
|
||||
};
|
||||
}
|
||||
66
src/core/services/field-grouping.ts
Normal file
66
src/core/services/field-grouping.ts
Normal file
@@ -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<KikuFieldGroupingChoice> {
|
||||
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
}
|
||||
81
src/core/services/frequency-dictionary.test.ts
Normal file
81
src/core/services/frequency-dictionary.test.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
195
src/core/services/frequency-dictionary.ts
Normal file
195
src/core/services/frequency-dictionary.ts
Normal file
@@ -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<string, number>,
|
||||
): { 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<string, number> {
|
||||
const terms = new Map<string, number>();
|
||||
|
||||
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;
|
||||
}
|
||||
560
src/core/services/immersion-tracker-service.test.ts
Normal file
560
src/core/services/immersion-tracker-service.test.ts
Normal file
@@ -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<ImmersionTrackerServiceCtor> {
|
||||
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<NodeDatabaseSync['prepare']>) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
654
src/core/services/immersion-tracker-service.ts
Normal file
654
src/core/services/immersion-tracker-service.ts
Normal file
@@ -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<typeof setTimeout> | null = null;
|
||||
private maintenanceTimer: ReturnType<typeof setInterval> | 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<SessionSummaryQueryRow[]> {
|
||||
return getSessionSummaries(this.db, limit);
|
||||
}
|
||||
|
||||
async getSessionTimeline(sessionId: number, limit = 200): Promise<SessionTimelineRow[]> {
|
||||
return getSessionTimeline(this.db, sessionId, limit);
|
||||
}
|
||||
|
||||
async getQueryHints(): Promise<{
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
}> {
|
||||
return getQueryHints(this.db);
|
||||
}
|
||||
|
||||
async getDailyRollups(limit = 60): Promise<ImmersionSessionRollupRow[]> {
|
||||
return getDailyRollups(this.db, limit);
|
||||
}
|
||||
|
||||
async getMonthlyRollups(limit = 24): Promise<ImmersionSessionRollupRow[]> {
|
||||
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 || '<empty>'} title=${normalizedTitle || '<empty>'}`,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
90
src/core/services/immersion-tracker/maintenance.ts
Normal file
90
src/core/services/immersion-tracker/maintenance.ts
Normal file
@@ -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
|
||||
`);
|
||||
}
|
||||
148
src/core/services/immersion-tracker/metadata.test.ts
Normal file
148
src/core/services/immersion-tracker/metadata.test.ts
Normal file
@@ -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<Spawn>;
|
||||
}) 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<typeof import('node:fs').createReadStream>;
|
||||
},
|
||||
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<typeof import('node:fs').createReadStream>;
|
||||
},
|
||||
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);
|
||||
});
|
||||
153
src/core/services/immersion-tracker/metadata.ts
Normal file
153
src/core/services/immersion-tracker/metadata.ts
Normal file
@@ -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<string | null> {
|
||||
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<ProbeMetadata> {
|
||||
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<VideoMetadata> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
104
src/core/services/immersion-tracker/query.ts
Normal file
104
src/core/services/immersion-tracker/query.ts
Normal file
@@ -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[];
|
||||
}
|
||||
19
src/core/services/immersion-tracker/queue.ts
Normal file
19
src/core/services/immersion-tracker/queue.ts
Normal file
@@ -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 };
|
||||
}
|
||||
144
src/core/services/immersion-tracker/reducer.ts
Normal file
144
src/core/services/immersion-tracker/reducer.ts
Normal file
@@ -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<string, unknown>, 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;
|
||||
}
|
||||
37
src/core/services/immersion-tracker/session.ts
Normal file
37
src/core/services/immersion-tracker/session.ts
Normal file
@@ -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);
|
||||
}
|
||||
162
src/core/services/immersion-tracker/storage-session.test.ts
Normal file
162
src/core/services/immersion-tracker/storage-session.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
328
src/core/services/immersion-tracker/storage.ts
Normal file
328
src/core/services/immersion-tracker/storage.ts
Normal file
@@ -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<DatabaseSync['prepare']>;
|
||||
eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
167
src/core/services/immersion-tracker/types.ts
Normal file
167
src/core/services/immersion-tracker/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
112
src/core/services/index.ts
Normal file
112
src/core/services/index.ts
Normal file
@@ -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';
|
||||
92
src/core/services/ipc-command.ts
Normal file
92
src/core/services/ipc-command.ts
Normal file
@@ -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<SubsyncResult>) => Promise<SubsyncResult>;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
},
|
||||
): Promise<SubsyncResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
237
src/core/services/ipc.test.ts
Normal file
237
src/core/services/ipc.test.ts
Normal file
@@ -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<string, (event: unknown, ...args: unknown[]) => void>;
|
||||
handle: Map<string, (event: unknown, ...args: unknown[]) => 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']);
|
||||
});
|
||||
397
src/core/services/ipc.ts
Normal file
397
src/core/services/ipc.ts
Normal file
@@ -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<unknown>;
|
||||
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<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
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<unknown>;
|
||||
getCurrentSubtitleRaw: () => string;
|
||||
getCurrentSubtitleAss: () => string;
|
||||
getMpvSubtitleRenderMetrics: () => unknown;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
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();
|
||||
});
|
||||
}
|
||||
317
src/core/services/jellyfin-remote.test.ts
Normal file
317
src/core/services/jellyfin-remote.test.ts
Normal file
@@ -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<string, Array<(...args: unknown[]) => 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<string, string>[] = [];
|
||||
|
||||
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<typeof setTimeout>;
|
||||
}) 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<typeof setTimeout>;
|
||||
}) 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')));
|
||||
});
|
||||
431
src/core/services/jellyfin-remote.ts
Normal file
431
src/core/services/jellyfin-remote.ts
Normal file
@@ -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<string, string>;
|
||||
|
||||
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<typeof setTimeout> | 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<boolean> {
|
||||
await this.postCapabilities();
|
||||
return this.isRegisteredOnServer();
|
||||
}
|
||||
|
||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
EventName: state.eventName || 'start',
|
||||
});
|
||||
}
|
||||
|
||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Progress', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<Record<string, unknown>>;
|
||||
return sessions.some((session) => String(session.DeviceId || '') === this.deviceId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async postTimeline(path: string, payload: JellyfinTimelinePayload): Promise<boolean> {
|
||||
return this.postJson(path, payload);
|
||||
}
|
||||
|
||||
private async postJson(path: string, payload: unknown): Promise<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/core/services/jellyfin-token-store.ts
Normal file
140
src/core/services/jellyfin-token-store.ts
Normal file
@@ -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<JellyfinStoredSession>;
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
690
src/core/services/jellyfin.test.ts
Normal file
690
src/core/services/jellyfin.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
523
src/core/services/jellyfin.ts
Normal file
523
src/core/services/jellyfin.ts
Normal file
@@ -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<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
session: JellyfinAuthSession,
|
||||
client: JellyfinClientInfo,
|
||||
): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
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<JellyfinAuthSession> {
|
||||
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<JellyfinLibrary[]> {
|
||||
const payload = await jellyfinRequestJson<JellyfinItemsResponse>(
|
||||
`/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<Array<{ id: string; name: string; type: string; title: string }>> {
|
||||
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<JellyfinItemsResponse>(
|
||||
`/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<JellyfinSubtitleTrack[]> {
|
||||
if (!itemId.trim()) throw new Error('Missing Jellyfin item id.');
|
||||
let source: JellyfinMediaSource | undefined;
|
||||
|
||||
try {
|
||||
const playbackInfo = await jellyfinRequestJson<JellyfinPlaybackInfoResponse>(
|
||||
`/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<JellyfinItem>(
|
||||
`/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<JellyfinPlaybackPlan> {
|
||||
if (!selection.itemId) {
|
||||
throw new Error('Missing Jellyfin item id.');
|
||||
}
|
||||
|
||||
const item = await jellyfinRequestJson<JellyfinItem>(
|
||||
`/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));
|
||||
}
|
||||
71
src/core/services/jimaku.ts
Normal file
71
src/core/services/jimaku.ts
Normal file
@@ -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<string | null> {
|
||||
return resolveJimakuApiKeyFromConfig(getJimakuConfig(getResolvedConfig));
|
||||
}
|
||||
|
||||
export async function jimakuFetchJson<T>(
|
||||
endpoint: string,
|
||||
query: Record<string, string | number | boolean | null | undefined> = {},
|
||||
options: {
|
||||
getResolvedConfig: () => { jimaku?: JimakuConfig };
|
||||
defaultBaseUrl: string;
|
||||
defaultMaxEntryResults: number;
|
||||
defaultLanguagePreference: JimakuLanguagePreference;
|
||||
},
|
||||
): Promise<JimakuApiResponse<T>> {
|
||||
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<T>(endpoint, query, {
|
||||
baseUrl: getJimakuBaseUrl(options.getResolvedConfig, options.defaultBaseUrl),
|
||||
apiKey,
|
||||
});
|
||||
}
|
||||
85
src/core/services/jlpt-token-filter.ts
Normal file
85
src/core/services/jlpt-token-filter.ts
Normal file
@@ -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<string>(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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user