From 05425cc5db4bdfc38b38b25041ef43fd1b2f2e16 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 22:07:30 -0700 Subject: [PATCH 1/9] fix(anki): separate word audio mapping for animation sync (#256) --- changes/fix-animated-word-audio-sync.md | 4 +++ changes/word-audio-mapping-docs.md | 4 +++ config.example.jsonc | 3 +- docs-site/anki-integration.md | 5 ++- docs-site/configuration.md | 3 +- docs-site/public/config.example.jsonc | 3 +- .../animated-image-sync.test.ts | 35 +++++++++++++++++-- src/anki-integration/animated-image-sync.ts | 4 +-- .../definitions/defaults-integrations.ts | 1 + .../definitions/options-integrations.ts | 7 ++++ src/config/definitions/template-sections.ts | 2 +- src/config/hot-reload.ts | 1 + src/config/resolve/anki-connect.test.ts | 22 ++++++++++++ .../resolve/anki-connect/modern-fields.ts | 10 +++++- src/config/settings/registry.test.ts | 1 + src/types/anki.ts | 1 + src/types/config.ts | 1 + 17 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 changes/fix-animated-word-audio-sync.md create mode 100644 changes/word-audio-mapping-docs.md diff --git a/changes/fix-animated-word-audio-sync.md b/changes/fix-animated-word-audio-sync.md new file mode 100644 index 00000000..f4508cc4 --- /dev/null +++ b/changes/fix-animated-word-audio-sync.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Added `ankiConnect.fields.wordAudio` to read word audio separately from the generated sentence-audio destination, fixing animated images that start moving immediately when `fields.audio` points to `SentenceAudio`. diff --git a/changes/word-audio-mapping-docs.md b/changes/word-audio-mapping-docs.md new file mode 100644 index 00000000..cea4e453 --- /dev/null +++ b/changes/word-audio-mapping-docs.md @@ -0,0 +1,4 @@ +type: docs +area: anki + +- Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze. diff --git a/config.example.jsonc b/config.example.jsonc index 1119873c..6eed9b71 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -541,7 +541,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -562,6 +562,7 @@ "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 235dd2d6..113bb314 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -125,6 +125,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn "fields": { "word": "Expression", // mined word / expression text "audio": "SentenceAudio", // sentence audio clip cut from the video + "wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync "image": "Picture", // screenshot or animated clip "sentence": "Sentence", // subtitle text "miscInfo": "MiscInfo" // metadata (filename, timestamp) @@ -136,6 +137,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error. +`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults. + These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names. Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `
` newline). @@ -241,7 +244,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of } ``` -Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio. +Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images. ### Behavior options diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 879d3f77..12321710 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -975,7 +975,8 @@ This example is intentionally compact. The option table below documents availabl | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | -| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. | +| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. | +| `fields.wordAudio` | string | Existing word-audio field read for the animated image's opening freeze. Independent of the sentence-audio destination in `fields.audio`; this mapping does not write audio. See [config.example.jsonc](/config.example.jsonc) for defaults. | | `fields.image` | string | Card field for images (default: `Picture`) | | `fields.sentence` | string | Card field for sentences (default: `Sentence`) | | `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 1119873c..6eed9b71 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -541,7 +541,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -562,6 +562,7 @@ "fields": { "word": "Expression", // Card field for the mined word or expression text. "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). diff --git a/src/anki-integration/animated-image-sync.test.ts b/src/anki-integration/animated-image-sync.test.ts index 6f18cba5..2bfe309a 100644 --- a/src/anki-integration/animated-image-sync.test.ts +++ b/src/anki-integration/animated-image-sync.test.ts @@ -14,7 +14,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ config: { fields: { - audio: 'ExpressionAudio', + audio: 'SentenceAudio', + wordAudio: 'Pronunciation', }, media: { imageType: 'avif', @@ -25,7 +26,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for noteInfo: { noteId: 42, fields: { - ExpressionAudio: { + SentenceAudio: { value: '[sound:sentence.mp3]' }, + Pronunciation: { value: '[sound:word.mp3][sound:alt.ogg]', }, }, @@ -121,3 +123,32 @@ test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled assert.equal(leadInSeconds, 0); }); + +for (const sentenceAudio of ['', '[sound:sentence.mp3]']) { + test(`word audio defaults independently of sentence audio (${sentenceAudio ? 'existing' : 'new'} note)`, async () => { + const retrieved: string[] = []; + const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ + config: { + fields: { audio: 'SentenceAudio' }, + media: { imageType: 'avif' }, + }, + noteInfo: { + noteId: 42, + fields: { + ExpressionAudio: { value: '[sound:word.mp3]' }, + SentenceAudio: { value: sentenceAudio }, + }, + }, + resolveConfiguredFieldName: (noteInfo, ...preferredNames) => + preferredNames.find((name) => name !== undefined && name in noteInfo.fields) ?? null, + retrieveMediaFileBase64: async (filename) => { + retrieved.push(filename); + return 'd29yZA=='; + }, + probeAudioDurationSeconds: async (_buffer, filename) => (filename === 'word.mp3' ? 0.6 : 4), + }); + + assert.equal(leadInSeconds, 0.6); + assert.deepEqual(retrieved, ['word.mp3']); + }); +} diff --git a/src/anki-integration/animated-image-sync.ts b/src/anki-integration/animated-image-sync.ts index 25282873..96d81809 100644 --- a/src/anki-integration/animated-image-sync.ts +++ b/src/anki-integration/animated-image-sync.ts @@ -97,8 +97,8 @@ export async function resolveAnimatedImageLeadInSeconds { ); }); +test('word audio mapping defaults and validates independently of sentence audio', () => { + for (const wordAudio of [undefined, 'Pronunciation', 7]) { + const { context, warnings } = makeContext({ + fields: { + audio: 'SentenceAudio', + ...(wordAudio !== undefined ? { wordAudio } : {}), + }, + }); + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.fields.audio, 'SentenceAudio'); + assert.equal( + context.resolved.ankiConnect.fields.wordAudio, + typeof wordAudio === 'string' ? wordAudio : DEFAULT_CONFIG.ankiConnect.fields.wordAudio, + ); + assert.deepEqual( + warnings.map((warning) => warning.path), + typeof wordAudio === 'number' ? ['ankiConnect.fields.wordAudio'] : [], + ); + } +}); + test('invalid modern Anki subtrees warn and keep resolved defaults', () => { const { context, warnings } = makeContext({ fields: { word: 7 }, diff --git a/src/config/resolve/anki-connect/modern-fields.ts b/src/config/resolve/anki-connect/modern-fields.ts index c8d30ad1..228d8615 100644 --- a/src/config/resolve/anki-connect/modern-fields.ts +++ b/src/config/resolve/anki-connect/modern-fields.ts @@ -7,7 +7,15 @@ export function applyModernFieldsResolution( context: ResolveContext, fields: Record, ): void { - for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) { + for (const key of [ + 'word', + 'audio', + 'wordAudio', + 'image', + 'sentence', + 'miscInfo', + 'translation', + ] as const) { applyModernValue( context, fields, diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index cc33f30a..023bc1d5 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -364,6 +364,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => { 'ankiConnect.nPlusOne.minSentenceWords', 'ankiConnect.fields.word', 'ankiConnect.fields.audio', + 'ankiConnect.fields.wordAudio', 'ankiConnect.fields.image', 'ankiConnect.fields.sentence', 'ankiConnect.fields.miscInfo', diff --git a/src/types/anki.ts b/src/types/anki.ts index 2874b1a4..1ddcc732 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -157,6 +157,7 @@ export interface AnkiConnectConfig { fields?: { word?: string; audio?: string; + wordAudio?: string; image?: string; sentence?: string; miscInfo?: string; diff --git a/src/types/config.ts b/src/types/config.ts index 628c6d04..b6607be2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -228,6 +228,7 @@ export interface ResolvedConfig { fields: { word: string; audio: string; + wordAudio: string; image: string; sentence: string; miscInfo: string; From 917d52dd94e5a6ee5bce166ad0b52c7840da17b7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 22:48:54 -0700 Subject: [PATCH 2/9] fix(config): validate AnkiConnect and field grouping settings (#257) --- changes/fix-anki-config-validation.md | 4 + docs-site/anki-integration.md | 2 + src/config/config.test.ts | 23 +++++ src/config/resolve/anki-connect.test.ts | 95 +++++++++++++++++++ src/config/resolve/anki-connect.ts | 6 +- src/config/resolve/anki-connect/base.ts | 58 +++++++++++ .../anki-connect/field-grouping-config.ts | 53 +++++++++++ src/config/resolve/anki-connect/initialize.ts | 55 +---------- src/config/resolve/anki-connect/kiku.ts | 22 ++--- src/config/resolve/anki-connect/modern.ts | 2 + src/config/resolve/anki-connect/senren.ts | 22 ++--- 11 files changed, 253 insertions(+), 89 deletions(-) create mode 100644 changes/fix-anki-config-validation.md create mode 100644 src/config/resolve/anki-connect/base.ts create mode 100644 src/config/resolve/anki-connect/field-grouping-config.ts diff --git a/changes/fix-anki-config-validation.md b/changes/fix-anki-config-validation.md new file mode 100644 index 00000000..9cbfa760 --- /dev/null +++ b/changes/fix-anki-config-validation.md @@ -0,0 +1,4 @@ +type: fixed +area: config + +- Validate direct AnkiConnect, Kiku, and Senren settings before admitting them to runtime config, with warnings and defaults for invalid values. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 113bb314..d870695c 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -19,6 +19,8 @@ This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [La AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config. +AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`. + ## Auto-enrichment transport When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available: diff --git a/src/config/config.test.ts b/src/config/config.test.ts index e711bacd..45090419 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2818,6 +2818,29 @@ test('forces Senren off when Kiku is also enabled and validates Senren fieldGrou ); }); +test('warns and falls back when isSenren.enabled is not boolean', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "isSenren": { "enabled": "true" } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + + assert.equal( + service.getConfig().ankiConnect.isSenren.enabled, + DEFAULT_CONFIG.ankiConnect.isSenren.enabled, + ); + assert.ok( + service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'), + ); +}); + test('accepts valid ankiConnect knownWords deck object', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index 2611787e..2e0f76ee 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -32,6 +32,101 @@ test('media timing review is disabled by default and accepts a boolean override' assert.deepEqual(enabledContext.warnings, []); }); +test('invalid direct and field-grouping Anki values warn and keep defaults', () => { + const { context, warnings } = makeContext({ + enabled: 'true', + url: 8765, + pollingRate: '3000', + deck: ['Mining'], + isKiku: { + enabled: 1, + fieldGrouping: 'sometimes', + deleteDuplicateInAuto: 'false', + }, + isSenren: { + enabled: 'true', + fieldGrouping: false, + deleteDuplicateInAuto: 0, + }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.enabled, DEFAULT_CONFIG.ankiConnect.enabled); + assert.equal(context.resolved.ankiConnect.url, DEFAULT_CONFIG.ankiConnect.url); + assert.equal(context.resolved.ankiConnect.pollingRate, DEFAULT_CONFIG.ankiConnect.pollingRate); + assert.equal(context.resolved.ankiConnect.deck, DEFAULT_CONFIG.ankiConnect.deck); + assert.deepEqual(context.resolved.ankiConnect.isKiku, DEFAULT_CONFIG.ankiConnect.isKiku); + assert.deepEqual(context.resolved.ankiConnect.isSenren, DEFAULT_CONFIG.ankiConnect.isSenren); + assert.deepEqual( + warnings.map((warning) => warning.path), + [ + 'ankiConnect.enabled', + 'ankiConnect.url', + 'ankiConnect.pollingRate', + 'ankiConnect.deck', + 'ankiConnect.isKiku.enabled', + 'ankiConnect.isKiku.deleteDuplicateInAuto', + 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.isSenren.enabled', + 'ankiConnect.isSenren.deleteDuplicateInAuto', + 'ankiConnect.isSenren.fieldGrouping', + ], + ); +}); + +test('accepts valid direct and field-grouping Anki values', () => { + const { context, warnings } = makeContext({ + enabled: false, + url: 'http://127.0.0.1:9876', + pollingRate: 750, + deck: 'Mining', + isKiku: { + enabled: true, + fieldGrouping: 'manual', + deleteDuplicateInAuto: false, + }, + isSenren: { + enabled: false, + fieldGrouping: 'disabled', + deleteDuplicateInAuto: false, + }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.enabled, false); + assert.equal(context.resolved.ankiConnect.url, 'http://127.0.0.1:9876'); + assert.equal(context.resolved.ankiConnect.pollingRate, 750); + assert.equal(context.resolved.ankiConnect.deck, 'Mining'); + assert.deepEqual(context.resolved.ankiConnect.isKiku, { + enabled: true, + fieldGrouping: 'manual', + deleteDuplicateInAuto: false, + }); + assert.deepEqual(context.resolved.ankiConnect.isSenren, { + enabled: false, + fieldGrouping: 'disabled', + deleteDuplicateInAuto: false, + }); + assert.deepEqual(warnings, []); +}); + +test('ignores unknown Anki keys without warning or admitting them to resolved config', () => { + const { context, warnings } = makeContext({ + futureOption: { enabled: true }, + isKiku: { futureGroupingOption: 'future' }, + isSenren: { futureGroupingOption: 'future' }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(Object.hasOwn(context.resolved.ankiConnect, 'futureOption'), false); + assert.equal(Object.hasOwn(context.resolved.ankiConnect.isKiku, 'futureGroupingOption'), false); + assert.equal(Object.hasOwn(context.resolved.ankiConnect.isSenren, 'futureGroupingOption'), false); + assert.deepEqual(warnings, []); +}); + test('modern media duration accepts zero as the disabled cap sentinel', () => { const disabledCap = makeContext({ media: { maxMediaDuration: 0 } }); applyAnkiConnectResolution(disabledCap.context); diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 4e216344..f78791ab 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -19,11 +19,11 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { const media = isObject(ankiConnect.media) ? ankiConnect.media : {}; const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {}; - initializeAnkiConnectResolution(context, ankiConnect); + initializeAnkiConnectResolution(context); applyAnkiModernResolution(context, ankiConnect, behavior, media); applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); applyAnkiKnownWordsResolution(context, ankiConnect, behavior); - applyAnkiKikuResolution(context); - applyAnkiSenrenResolution(context); + applyAnkiKikuResolution(context, ankiConnect); + applyAnkiSenrenResolution(context, ankiConnect); applyAnkiLapisKikuResolution(context, ankiConnect); } diff --git a/src/config/resolve/anki-connect/base.ts b/src/config/resolve/anki-connect/base.ts new file mode 100644 index 00000000..b26ebf98 --- /dev/null +++ b/src/config/resolve/anki-connect/base.ts @@ -0,0 +1,58 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean, asString } from '../shared'; +import { applyModernValue, asPositiveNumber } from './modern-value'; + +export function applyAnkiBaseResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + applyModernValue( + context, + ankiConnect, + 'enabled', + 'ankiConnect.enabled', + asBoolean, + DEFAULT_CONFIG.ankiConnect.enabled, + (value) => { + context.resolved.ankiConnect.enabled = value; + }, + 'Expected boolean.', + ); + applyModernValue( + context, + ankiConnect, + 'url', + 'ankiConnect.url', + asString, + DEFAULT_CONFIG.ankiConnect.url, + (value) => { + context.resolved.ankiConnect.url = value; + }, + 'Expected string.', + ); + applyModernValue( + context, + ankiConnect, + 'pollingRate', + 'ankiConnect.pollingRate', + asPositiveNumber, + DEFAULT_CONFIG.ankiConnect.pollingRate, + (value) => { + context.resolved.ankiConnect.pollingRate = value; + }, + 'Expected positive number.', + ); + applyModernValue( + context, + ankiConnect, + 'deck', + 'ankiConnect.deck', + asString, + DEFAULT_CONFIG.ankiConnect.deck, + (value) => { + context.resolved.ankiConnect.deck = value; + }, + 'Expected string.', + ); +} diff --git a/src/config/resolve/anki-connect/field-grouping-config.ts b/src/config/resolve/anki-connect/field-grouping-config.ts new file mode 100644 index 00000000..599be33a --- /dev/null +++ b/src/config/resolve/anki-connect/field-grouping-config.ts @@ -0,0 +1,53 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean, isObject } from '../shared'; +import { applyModernValue } from './modern-value'; + +type FieldGroupingConfigKey = 'isKiku' | 'isSenren'; + +export function applyFieldGroupingConfigResolution( + context: ResolveContext, + ankiConnect: Record, + key: FieldGroupingConfigKey, +): void { + const source = ankiConnect[key]; + if (!isObject(source)) { + if (source !== undefined) { + context.warn( + `ankiConnect.${key}`, + source, + DEFAULT_CONFIG.ankiConnect[key], + 'Expected object.', + ); + } + return; + } + + for (const booleanKey of ['enabled', 'deleteDuplicateInAuto'] as const) { + applyModernValue( + context, + source, + booleanKey, + `ankiConnect.${key}.${booleanKey}`, + asBoolean, + DEFAULT_CONFIG.ankiConnect[key][booleanKey], + (value) => { + context.resolved.ankiConnect[key][booleanKey] = value; + }, + 'Expected boolean.', + ); + } + + applyModernValue( + context, + source, + 'fieldGrouping', + `ankiConnect.${key}.fieldGrouping`, + (value) => (value === 'auto' || value === 'manual' || value === 'disabled' ? value : undefined), + DEFAULT_CONFIG.ankiConnect[key].fieldGrouping, + (value) => { + context.resolved.ankiConnect[key].fieldGrouping = value; + }, + 'Expected auto, manual, or disabled.', + ); +} diff --git a/src/config/resolve/anki-connect/initialize.ts b/src/config/resolve/anki-connect/initialize.ts index 2d1a8a2d..490d6888 100644 --- a/src/config/resolve/anki-connect/initialize.ts +++ b/src/config/resolve/anki-connect/initialize.ts @@ -1,55 +1,8 @@ import type { ResolveContext } from '../context'; -import { isObject } from '../shared'; - -const LEGACY_KEYS = new Set([ - 'wordField', - 'audioField', - 'imageField', - 'sentenceField', - 'miscInfoField', - 'miscInfoPattern', - 'generateAudio', - 'generateImage', - 'imageType', - 'imageFormat', - 'imageQuality', - 'imageMaxWidth', - 'imageMaxHeight', - 'animatedFps', - 'animatedMaxWidth', - 'animatedMaxHeight', - 'animatedCrf', - 'syncAnimatedImageToWordAudio', - 'audioPadding', - 'fallbackDuration', - 'maxMediaDuration', - 'overwriteAudio', - 'overwriteImage', - 'mediaInsertMode', - 'highlightWord', - 'notificationType', - 'autoUpdateNewCards', -]); - -export function initializeAnkiConnectResolution( - context: ResolveContext, - ankiConnect: Record, -): void { - const { - knownWords: _knownWordsConfigFromAnkiConnect, - nPlusOne: _nPlusOneConfigFromAnkiConnect, - ai: _ankiAiConfig, - ...ankiConnectWithoutKnownWordsOrNPlusOne - } = ankiConnect; - const ankiConnectWithoutLegacy = Object.fromEntries( - Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)), - ); +export function initializeAnkiConnectResolution(context: ResolveContext): void { context.resolved.ankiConnect = { ...context.resolved.ankiConnect, - ...(isObject(ankiConnectWithoutLegacy) - ? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>) - : {}), fields: { ...context.resolved.ankiConnect.fields, }, @@ -73,15 +26,9 @@ export function initializeAnkiConnectResolution( }, isKiku: { ...context.resolved.ankiConnect.isKiku, - ...(isObject(ankiConnect.isKiku) - ? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) - : {}), }, isSenren: { ...context.resolved.ankiConnect.isSenren, - ...(isObject(ankiConnect.isSenren) - ? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren']) - : {}), }, lapisKiku: { ...context.resolved.ankiConnect.lapisKiku, diff --git a/src/config/resolve/anki-connect/kiku.ts b/src/config/resolve/anki-connect/kiku.ts index bce4ddb5..c2a854df 100644 --- a/src/config/resolve/anki-connect/kiku.ts +++ b/src/config/resolve/anki-connect/kiku.ts @@ -1,19 +1,9 @@ -import { DEFAULT_CONFIG } from '../../definitions'; import type { ResolveContext } from '../context'; +import { applyFieldGroupingConfigResolution } from './field-grouping-config'; -export function applyAnkiKikuResolution(context: ResolveContext): void { - 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; - } +export function applyAnkiKikuResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + applyFieldGroupingConfigResolution(context, ankiConnect, 'isKiku'); } diff --git a/src/config/resolve/anki-connect/modern.ts b/src/config/resolve/anki-connect/modern.ts index bb240cea..2ac882d8 100644 --- a/src/config/resolve/anki-connect/modern.ts +++ b/src/config/resolve/anki-connect/modern.ts @@ -1,6 +1,7 @@ import type { ResolveContext } from '../context'; import { isObject } from '../shared'; import { applyAiResolution } from './ai'; +import { applyAnkiBaseResolution } from './base'; import { applyLapisResolution } from './lapis'; import { applyModernBehaviorResolution } from './modern-behavior'; import { applyModernFieldsResolution } from './modern-fields'; @@ -18,6 +19,7 @@ export function applyAnkiModernResolution( const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {}; const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {}; + applyAnkiBaseResolution(context, ankiConnect); applyModernFieldsResolution(context, fields); applyModernMediaResolution(context, media); applyModernBehaviorResolution(context, behavior); diff --git a/src/config/resolve/anki-connect/senren.ts b/src/config/resolve/anki-connect/senren.ts index 950f1c58..e101f00d 100644 --- a/src/config/resolve/anki-connect/senren.ts +++ b/src/config/resolve/anki-connect/senren.ts @@ -1,21 +1,11 @@ -import { DEFAULT_CONFIG } from '../../definitions'; import type { ResolveContext } from '../context'; +import { applyFieldGroupingConfigResolution } from './field-grouping-config'; -export function applyAnkiSenrenResolution(context: ResolveContext): void { - if ( - context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' && - context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' && - context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled' - ) { - context.warn( - 'ankiConnect.isSenren.fieldGrouping', - context.resolved.ankiConnect.isSenren.fieldGrouping, - DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping, - 'Expected auto, manual, or disabled.', - ); - context.resolved.ankiConnect.isSenren.fieldGrouping = - DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping; - } +export function applyAnkiSenrenResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + applyFieldGroupingConfigResolution(context, ankiConnect, 'isSenren'); // Kiku and Senren field grouping write incompatible markup into the same note // fields, so only one may be active; Kiku wins to preserve pre-existing setups. From 7959e1a4e5f00d6cbeca9dc68fcc5501b81005eb Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 22:49:12 -0700 Subject: [PATCH 3/9] fix(anki): honor unlimited duration in stats mining (#258) --- changes/fix-unlimited-mining-duration.md | 4 ++ config.example.jsonc | 2 +- docs-site/anki-integration.md | 2 + docs-site/configuration.md | 2 +- docs-site/public/config.example.jsonc | 2 +- .../card-creation-manual-update.test.ts | 46 ++++++++++++++++ src/anki-integration/card-creation.ts | 15 +++--- src/anki-integration/media-duration.ts | 10 ++++ .../definitions/options-integrations.ts | 2 +- .../services/__tests__/stats-server.test.ts | 54 +++++++++++++++++++ .../services/stats-server/mining-routes.ts | 4 +- 11 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 changes/fix-unlimited-mining-duration.md create mode 100644 src/anki-integration/media-duration.ts diff --git a/changes/fix-unlimited-mining-duration.md b/changes/fix-unlimited-mining-duration.md new file mode 100644 index 00000000..76a58a3c --- /dev/null +++ b/changes/fix-unlimited-mining-duration.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration. diff --git a/config.example.jsonc b/config.example.jsonc index 6eed9b71..56b01757 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -591,7 +591,7 @@ "reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. - "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. + "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index d870695c..ee88db45 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -188,6 +188,8 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`. +Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit. See the [configuration example](/config.example.jsonc) for its default and how to disable the cap. + Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards. The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 12321710..a594ec30 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -997,7 +997,7 @@ This example is intentionally compact. The option table below documents availabl | `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | | `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | -| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | +| `media.maxMediaDuration` | number (seconds) | Maximum generated clip duration for overlay and stats-dashboard mining. See the [configuration example](/config.example.jsonc) for the default and disabling the cap. | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 6eed9b71..56b01757 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -591,7 +591,7 @@ "reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. - "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. + "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index 2a8dcb2e..4ff20aa6 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -160,6 +160,52 @@ test('manual clipboard subtitle update replaces audio in the configured field', ); }); +test('manual clipboard mining treats a zero media duration cap as unlimited', async () => { + const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = []; + const scenarios = [ + { maxMediaDuration: 0, expectedEnd: 14 }, + { maxMediaDuration: 1, expectedEnd: 13 }, + ]; + + for (const scenario of scenarios) { + const { service } = createManualUpdateService({ + getConfig: () => + ({ + deck: 'Mining', + fields: { + word: 'Expression', + sentence: 'Sentence', + audio: 'ExpressionAudio', + }, + media: { + generateAudio: true, + generateImage: false, + audioPadding: 0.25, + maxMediaDuration: scenario.maxMediaDuration, + }, + behavior: {}, + ai: false, + }) as AnkiConnectConfig, + mediaGenerator: { + generateAudio: async (_path, start, end, padding) => { + audioRanges.push({ start, end, padding }); + return Buffer.from('audio'); + }, + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }, + }); + + await service.updateLastAddedFromClipboard('字幕'); + + assert.deepEqual(audioRanges.at(-1), { + start: 12, + end: scenario.expectedEnd, + padding: 0.25, + }); + } +}); + test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => { const { service, updatedFields } = createManualUpdateService({ getConfig: () => diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 89913576..5917b292 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -21,6 +21,7 @@ import { resolveAudioStreamIndexForMediaGeneration, type MediaGenerationInputResolverOptions, } from './media-source'; +import { clampMediaEndTime } from './media-duration'; import { resolveWordCardKind } from './note-field-utils'; import type { PendingYoutubeMediaUpdate } from './pending-youtube-media'; import { resolveMpvVolumeScale } from './mpv-volume'; @@ -233,11 +234,12 @@ export class CardCreationService { let rangeEnd = Math.max(...timings.map((entry) => entry.endTime)); const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; - if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) { + const cappedRangeEnd = clampMediaEndTime(rangeStart, rangeEnd, maxMediaDuration); + if (cappedRangeEnd !== rangeEnd) { log.warn( `Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, ); - rangeEnd = rangeStart + maxMediaDuration; + rangeEnd = cappedRangeEnd; } this.deps.showOsdNotification('Updating card from clipboard...'); @@ -437,9 +439,7 @@ export class CardCreationService { } const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; - if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { - endTime = startTime + maxMediaDuration; - } + endTime = clampMediaEndTime(startTime, endTime, maxMediaDuration); this.deps.showOsdNotification('Marking card as audio card...'); await this.deps.withUpdateProgress('Marking audio card', async () => { @@ -600,11 +600,12 @@ export class CardCreationService { } const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; - if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { + const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration); + if (cappedEndTime !== endTime) { log.warn( `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, ); - endTime = startTime + maxMediaDuration; + endTime = cappedEndTime; } try { diff --git a/src/anki-integration/media-duration.ts b/src/anki-integration/media-duration.ts new file mode 100644 index 00000000..04f937d4 --- /dev/null +++ b/src/anki-integration/media-duration.ts @@ -0,0 +1,10 @@ +/** Zero or a negative cap leaves the requested end time unchanged. */ +export function clampMediaEndTime( + startTime: number, + endTime: number, + maxMediaDuration: number, +): number { + return maxMediaDuration > 0 && endTime - startTime > maxMediaDuration + ? startTime + maxMediaDuration + : endTime; +} diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 16e208a6..433f3fcd 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -295,7 +295,7 @@ export function buildIntegrationConfigOptionRegistry( path: 'ankiConnect.media.maxMediaDuration', kind: 'number', defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration, - description: 'Maximum allowed media clip duration in seconds.', + description: 'Maximum allowed media clip duration in seconds. 0 disables the cap.', }, { path: 'ankiConnect.knownWords.matchMode', diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index bc9f3674..4f5d2873 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -1762,6 +1762,60 @@ describe('stats server API routes', () => { }); }); + it('POST /api/stats/mine-card treats a zero media duration cap as unlimited', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = []; + const scenarios = [ + { maxMediaDuration: 0, expectedEnd: 12 }, + { maxMediaDuration: 1, expectedEnd: 11 }, + ]; + + for (const scenario of scenarios) { + const app = createStatsApp(createMockTracker(), { + addYomitanNote: async () => null, + createMediaGenerator: () => ({ + generateAudio: async (_path, start, end, padding) => { + audioRanges.push({ start, end, padding }); + return Buffer.from('audio'); + }, + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }), + ankiConnectConfig: { + deck: 'Mining', + media: { + generateAudio: true, + generateImage: false, + audioPadding: 0.25, + maxMediaDuration: scenario.maxMediaDuration, + }, + }, + }); + + const res = await app.request('/api/stats/mine-card?mode=word', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 10_000, + endMs: 12_000, + sentence: '猫を見た', + word: '猫', + }), + }); + + assert.equal(res.status, 502); + assert.deepEqual(audioRanges.at(-1), { + start: 10, + end: scenario.expectedEnd, + padding: 0.25, + }); + } + }); + }); + it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); diff --git a/src/core/services/stats-server/mining-routes.ts b/src/core/services/stats-server/mining-routes.ts index d93cc90e..a2e2d7ba 100644 --- a/src/core/services/stats-server/mining-routes.ts +++ b/src/core/services/stats-server/mining-routes.ts @@ -4,6 +4,7 @@ import { basename } from 'node:path'; import { AnkiConnectClient } from '../../../anki-connect.js'; import { getConfiguredWordFieldName } from '../../../anki-field-config.js'; import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js'; +import { clampMediaEndTime } from '../../../anki-integration/media-duration.js'; import { MediaGenerator } from '../../../media-generator.js'; import { statsJson } from '../../../types/stats-http-contract.js'; import { @@ -113,8 +114,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO const startSec = startMs / 1000; const endSec = endMs / 1000; - const rawDuration = endSec - startSec; - const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec; + const clampedEndSec = clampMediaEndTime(startSec, endSec, maxMediaDuration); const highlightedSentence = word ? sentence.replace( From f9c4e892dc7cbeba3e25207cad01099581f42516 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 22:49:27 -0700 Subject: [PATCH 4/9] fix(stats): reject malformed resource IDs before mutations (#259) --- changes/fix-stats-resource-id-validation.md | 4 + docs-site/immersion-tracking.md | 4 + .../services/__tests__/stats-server.test.ts | 206 +++++++++++++++++- src/core/services/stats-cover-routes.ts | 41 +--- .../services/stats-server/analytics-routes.ts | 13 +- .../stats-server/integration-routes.ts | 41 ++-- .../services/stats-server/library-routes.ts | 72 +++--- .../services/stats-server/route-support.ts | 24 +- 8 files changed, 300 insertions(+), 105 deletions(-) create mode 100644 changes/fix-stats-resource-id-validation.md diff --git a/changes/fix-stats-resource-id-validation.md b/changes/fix-stats-resource-id-validation.md new file mode 100644 index 00000000..c41ed3d1 --- /dev/null +++ b/changes/fix-stats-resource-id-validation.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 230d57a9..208918b7 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -37,6 +37,10 @@ The same immersion data powers the stats dashboard. - Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data. - Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running. +### Stats API resource IDs + +Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds. + ### Dashboard tabs #### Overview diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 4f5d2873..b4264187 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -1004,6 +1004,23 @@ describe('stats server API routes', () => { assert.equal(seenLimit, 500); }); + it('GET /api/stats/vocabulary floors fractional pagination limits', async () => { + let seenLimit = 0; + const app = createStatsApp( + createMockTracker({ + getVocabularyStats: async (limit?: number) => { + seenLimit = limit ?? 0; + return VOCABULARY_STATS; + }, + }), + ); + + const res = await app.request('/api/stats/vocabulary?limit=12.9'); + + assert.equal(res.status, 200); + assert.equal(seenLimit, 12); + }); + it('GET /api/stats/vocabulary passes excludePos to tracker', async () => { let seenArgs: unknown[] = []; const app = createStatsApp( @@ -1351,7 +1368,7 @@ describe('stats server API routes', () => { }), ); - for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) { + for (const anilistId of [-1, 0, 1.5, 9_007_199_254_740_992, '12', true, undefined]) { const res = await app.request('/api/stats/anime/1/anilist', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -1449,6 +1466,74 @@ describe('stats server API routes', () => { assert.equal(res.status, 404); }); + it('resource routes reject fractional ids before calling dependencies', async () => { + const dependencyCalls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + dependencyCalls.push('fetch'); + return new Response('{}', { status: 200 }); + }; + + try { + const app = createStatsApp( + createMockTracker({ + getWordDetail: async () => { + dependencyCalls.push('getWordDetail'); + return null; + }, + getSessionEvents: async () => { + dependencyCalls.push('getSessionEvents'); + return []; + }, + getEpisodeSessions: async () => { + dependencyCalls.push('getEpisodeSessions'); + return []; + }, + getAnimeCoverArt: async () => { + dependencyCalls.push('getAnimeCoverArt'); + return null; + }, + ensureAnimeCoverArt: async () => { + dependencyCalls.push('ensureAnimeCoverArt'); + return false; + }, + setVideoWatched: async () => { + dependencyCalls.push('setVideoWatched'); + }, + reassignAnimeAnilist: async () => { + dependencyCalls.push('reassignAnimeAnilist'); + }, + }), + ); + + const responses = await Promise.all([ + app.request('/api/stats/vocabulary/1.9/detail'), + app.request('/api/stats/sessions/1.9/events'), + app.request('/api/stats/episode/1.9/detail'), + app.request('/api/stats/anime/1.9/cover'), + app.request('/api/stats/media/1.9/watched', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: '{"watched":true}', + }), + app.request('/api/stats/anime/1.9/anilist', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: '{"anilistId":21858}', + }), + app.request('/api/stats/anki/browse?noteId=1.9', { method: 'POST' }), + ]); + + assert.deepEqual( + responses.map((response) => response.status), + [400, 400, 400, 400, 400, 400, 400], + ); + assert.deepEqual(dependencyCalls, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => { let ensureCoverArtCalls = 0; const ensureAnimeCoverArtCalls: number[] = []; @@ -1505,6 +1590,58 @@ describe('stats server API routes', () => { assert.deepEqual(ensureAnimeCoverArtCalls, [99999]); }); + it('JSON id lists reject malformed members before side effects', async () => { + const dependencyCalls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + dependencyCalls.push('fetch'); + return new Response('{}', { status: 200 }); + }; + + try { + const app = createStatsApp( + createMockTracker({ + deleteSessions: async () => { + dependencyCalls.push('deleteSessions'); + }, + mergeAnime: async () => { + dependencyCalls.push('mergeAnime'); + return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 }; + }, + getAnimeCoverArt: async () => { + dependencyCalls.push('getAnimeCoverArt'); + return null; + }, + ensureAnimeCoverArt: async () => { + dependencyCalls.push('ensureAnimeCoverArt'); + return false; + }, + }), + ); + const request = async (path: string, body: string, method = 'POST'): Promise => + await app.request(path, { + method, + headers: { 'Content-Type': 'application/json' }, + body, + }); + + const responses = await Promise.all([ + request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'), + request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'), + request('/api/stats/covers', '{"animeIds":[1,1.9]}'), + request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'), + ]); + + assert.deepEqual( + responses.map((response) => response.status), + [400, 400, 400, 400], + ); + assert.deepEqual(dependencyCalls, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => { let activeBackfills = 0; let maxActiveBackfills = 0; @@ -3316,6 +3453,46 @@ Aligned English subtitle assert.equal(deleteCalls, 0); }); + it('DELETE /api/stats/sessions rejects a partly invalid id list without deleting', async () => { + let deleteCalls = 0; + const app = createStatsApp( + createMockTracker({ + deleteSessions: async () => { + deleteCalls += 1; + }, + }), + ); + + const res = await app.request('/api/stats/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: '{"sessionIds":[4,1.9,7]}', + }); + + assert.equal(res.status, 400); + assert.equal(deleteCalls, 0); + }); + + it('DELETE /api/stats/sessions deduplicates valid ids', async () => { + let deletedSessionIds: number[] = []; + const app = createStatsApp( + createMockTracker({ + deleteSessions: async (sessionIds: number[]) => { + deletedSessionIds = sessionIds; + }, + }), + ); + + const res = await app.request('/api/stats/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: '{"sessionIds":[4,4,7]}', + }); + + assert.equal(res.status, 200); + assert.deepEqual(deletedSessionIds, [4, 7]); + }); + it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => { let deletedAnimeId: number | null = null; const app = createStatsApp( @@ -3349,6 +3526,33 @@ Aligned English subtitle assert.equal(deleteCalls, 0); }); + it('DELETE /api/stats/anime/:animeId rejects malformed anime ids before deleting', async () => { + let deletedAnimeId: number | null = null; + const app = createStatsApp( + createMockTracker({ + deleteAnime: async (animeId: number) => { + deletedAnimeId = animeId; + }, + }), + ); + + for (const animeId of [ + '1.9', + '1.0', + '1e2', + '9007199254740992', + '1%0A', + '%201', + '01', + '+1', + '0x1', + ]) { + const res = await app.request(`/api/stats/anime/${animeId}`, { method: 'DELETE' }); + assert.equal(res.status, 400, `accepted malformed anime id: ${animeId}`); + } + assert.equal(deletedAnimeId, null); + }); + it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => { let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null; const app = createStatsApp( diff --git a/src/core/services/stats-cover-routes.ts b/src/core/services/stats-cover-routes.ts index 3e70f4f3..7d4f71aa 100644 --- a/src/core/services/stats-cover-routes.ts +++ b/src/core/services/stats-cover-routes.ts @@ -3,37 +3,13 @@ import type { Hono } from 'hono'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js'; import type { StatsCoverImage } from '../../types/stats-wire.js'; +import { parsePositiveId, parsePositiveIdList } from './stats-server/route-support.js'; type StatsCoverImagePayload = StatsCoverImage | null; type StatsCoverBatchBody = Partial>; const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3; -function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { - if (raw === undefined) return fallback; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) { - return fallback; - } - const parsed = Math.floor(n); - return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); -} - -function parsePositiveIdList(raw: unknown, maxItems = 100): number[] { - if (!Array.isArray(raw)) return []; - - const ids = new Set(); - for (const rawId of raw) { - const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN; - if (Number.isFinite(id) && id > 0) { - ids.add(Math.floor(id)); - if (ids.size >= maxItems) break; - } - } - - return Array.from(ids).sort((a, b) => a - b); -} - function coverImagePayload( art: { coverBlob?: Uint8Array | null } | null | undefined, ): StatsCoverImagePayload { @@ -129,8 +105,11 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer app.post('/api/stats/covers', async (c) => { const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null; - const animeIds = parsePositiveIdList(body?.animeIds); - const videoIds = parsePositiveIdList(body?.videoIds); + const animeIds = body?.animeIds === undefined ? [] : parsePositiveIdList(body.animeIds, 100); + const videoIds = body?.videoIds === undefined ? [] : parsePositiveIdList(body.videoIds, 100); + if (!animeIds || !videoIds) return c.body(null, 400); + animeIds.sort((a, b) => a - b); + videoIds.sort((a, b) => a - b); const anime: Record = {}; const media: Record = {}; @@ -155,8 +134,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer }); app.get('/api/stats/anime/:animeId/cover', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 404); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); let art = await tracker.getAnimeCoverArt(animeId); if (!art?.coverBlob) { await tracker.ensureAnimeCoverArt(animeId); @@ -167,8 +146,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer }); app.get('/api/stats/media/:videoId/cover', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 404); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); let art = await tracker.getCoverArt(videoId); if (!art?.coverBlob) { await tracker.ensureCoverArt(videoId); diff --git a/src/core/services/stats-server/analytics-routes.ts b/src/core/services/stats-server/analytics-routes.ts index 6a9f3e6e..640915da 100644 --- a/src/core/services/stats-server/analytics-routes.ts +++ b/src/core/services/stats-server/analytics-routes.ts @@ -6,6 +6,7 @@ import { loadKnownWordsSet, parseEventTypesQuery, parseIntQuery, + parsePositiveId, parseTrendFillEmpty, parseTrendGroupBy, parseTrendRange, @@ -83,8 +84,8 @@ export function registerStatsAnalyticsRoutes( }); app.get('/api/stats/sessions/:id/timeline', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400); + const id = parsePositiveId(c.req.param('id')); + if (id === null) return c.json(statsJson('sessionTimeline', []), 400); const rawLimit = c.req.query('limit'); const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000); const timeline = await tracker.getSessionTimeline(id, limit); @@ -92,8 +93,8 @@ export function registerStatsAnalyticsRoutes( }); app.get('/api/stats/sessions/:id/events', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionEvents', []), 400); + const id = parsePositiveId(c.req.param('id')); + if (id === null) return c.json(statsJson('sessionEvents', []), 400); const limit = parseIntQuery(c.req.query('limit'), 500, 1000); const eventTypes = parseEventTypesQuery(c.req.query('types')); const events = await tracker.getSessionEvents(id, limit, eventTypes); @@ -101,8 +102,8 @@ export function registerStatsAnalyticsRoutes( }); app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400); + const id = parsePositiveId(c.req.param('id')); + if (id === null) return c.json(statsJson('sessionKnownWordsTimeline', []), 400); const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set(); diff --git a/src/core/services/stats-server/integration-routes.ts b/src/core/services/stats-server/integration-routes.ts index cdc26510..ed71b2b1 100644 --- a/src/core/services/stats-server/integration-routes.ts +++ b/src/core/services/stats-server/integration-routes.ts @@ -12,8 +12,10 @@ import { buildAnkiNotePreview, countKnownWords, enrichSessionsWithKnownWordMetrics, + isPositiveSafeInteger, loadKnownWordsSet, - parseIntQuery, + parsePositiveId, + parsePositiveIdList, } from './route-support.js'; const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; @@ -87,8 +89,8 @@ export function registerStatsIntegrationRoutes( }); app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) { + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) { return c.json( statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), 400, @@ -105,8 +107,8 @@ export function registerStatsIntegrationRoutes( }); app.get('/api/stats/media/:videoId/known-words-summary', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) { + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) { return c.json( statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), 400, @@ -123,14 +125,10 @@ export function registerStatsIntegrationRoutes( }); app.patch('/api/stats/anime/:animeId/anilist', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); - if ( - typeof body?.anilistId !== 'number' || - !Number.isInteger(body.anilistId) || - body.anilistId <= 0 - ) { + if (!isPositiveSafeInteger(body?.anilistId)) { return c.body(null, 400); } await tracker.reassignAnimeAnilist(animeId, body); @@ -140,8 +138,8 @@ export function registerStatsIntegrationRoutes( registerStatsCoverRoutes(app, tracker); app.get('/api/stats/episode/:videoId/detail', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); const rawSessions = await tracker.getEpisodeSessions(videoId); const words = await tracker.getEpisodeWords(videoId); const cardEvents = await tracker.getEpisodeCardEvents(videoId); @@ -154,8 +152,8 @@ export function registerStatsIntegrationRoutes( }); app.post('/api/stats/anki/browse', async (c) => { - const noteId = parseIntQuery(c.req.query('noteId'), 0); - if (noteId <= 0) return c.body(null, 400); + const noteId = parsePositiveId(c.req.query('noteId')); + if (noteId === null) return c.body(null, 400); const ankiConfig = getAnkiConnectConfig(); try { const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', { @@ -177,19 +175,14 @@ export function registerStatsIntegrationRoutes( app.post('/api/stats/anki/notesInfo', async (c) => { const body = await c.req.json().catch(() => null); - const noteIds: number[] = Array.isArray(body?.noteIds) - ? body.noteIds.filter( - (id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0, - ) - : []; + const noteIds = parsePositiveIdList(body?.noteIds); + if (!noteIds) return c.body(null, 400); if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', [])); const resolvedNoteIds = Array.from( new Set( noteIds.map((noteId) => { const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId); - return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0 - ? (resolvedNoteId as number) - : noteId; + return isPositiveSafeInteger(resolvedNoteId) ? resolvedNoteId : noteId; }), ), ); diff --git a/src/core/services/stats-server/library-routes.ts b/src/core/services/stats-server/library-routes.ts index dd3e678a..00b9f464 100644 --- a/src/core/services/stats-server/library-routes.ts +++ b/src/core/services/stats-server/library-routes.ts @@ -8,12 +8,14 @@ import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import { buildSentenceSearchOptions, enrichSessionsWithKnownWordMetrics, + isPositiveSafeInteger, + loadKnownWordsSet, parseBooleanQuery, parseDuplicateLineCleanupBody, parseExcludedWordsBody, parseIntQuery, + parsePositiveId, parsePositiveIdList, - loadKnownWordsSet, } from './route-support.js'; export function registerStatsLibraryRoutes( @@ -116,8 +118,8 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/vocabulary/:wordId/detail', async (c) => { - const wordId = parseIntQuery(c.req.param('wordId'), 0); - if (wordId <= 0) return c.body(null, 400); + const wordId = parsePositiveId(c.req.param('wordId')); + if (wordId === null) return c.body(null, 400); const detail = await tracker.getWordDetail(wordId); if (!detail) return c.body(null, 404); const animeAppearances = await tracker.getWordAnimeAppearances(wordId); @@ -126,8 +128,8 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/kanji/:kanjiId/detail', async (c) => { - const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0); - if (kanjiId <= 0) return c.body(null, 400); + const kanjiId = parsePositiveId(c.req.param('kanjiId')); + if (kanjiId === null) return c.body(null, 400); const detail = await tracker.getKanjiDetail(kanjiId); if (!detail) return c.body(null, 404); const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId); @@ -141,8 +143,8 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/media/:videoId', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.json(statsJson('error', null), 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.json(statsJson('error', null), 400); const [detail, rawSessions, rollups] = await Promise.all([ tracker.getMediaDetail(videoId), tracker.getMediaSessions(videoId, 100), @@ -167,16 +169,16 @@ export function registerStatsLibraryRoutes( }); app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => { - const recommendationId = parseIntQuery(c.req.param('recommendationId'), 0); - if (recommendationId <= 0) return c.body(null, 400); + const recommendationId = parsePositiveId(c.req.param('recommendationId')); + if (recommendationId === null) return c.body(null, 400); const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId); if (!dismissed) return c.body(null, 404); return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true })); }); app.get('/api/stats/anime/:animeId', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); const detail = await tracker.getAnimeDetail(animeId); if (!detail) return c.body(null, 404); const [episodes, anilistEntries] = await Promise.all([ @@ -187,22 +189,22 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/anime/:animeId/words', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); + const animeId = parsePositiveId(c.req.param('animeId')); const limit = parseIntQuery(c.req.query('limit'), 50, 200); - if (animeId <= 0) return c.body(null, 400); + if (animeId === null) return c.body(null, 400); return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit))); }); app.get('/api/stats/anime/:animeId/rollups', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); + const animeId = parsePositiveId(c.req.param('animeId')); const limit = parseIntQuery(c.req.query('limit'), 90, 365); - if (animeId <= 0) return c.body(null, 400); + if (animeId === null) return c.body(null, 400); return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit))); }); app.patch('/api/stats/media/:videoId/watched', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); const watched = typeof body?.watched === 'boolean' ? body.watched : true; await tracker.setVideoWatched(videoId, watched); @@ -211,42 +213,40 @@ export function registerStatsLibraryRoutes( app.delete('/api/stats/sessions', async (c) => { const body = await c.req.json().catch(() => null); - const ids = Array.isArray(body?.sessionIds) - ? body.sessionIds.filter( - (id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0, - ) - : []; - if (ids.length === 0) return c.body(null, 400); + const ids = parsePositiveIdList(body?.sessionIds); + if (!ids || ids.length === 0) return c.body(null, 400); await tracker.deleteSessions(ids); return c.json(statsJson('deleteSessions', { ok: true })); }); app.delete('/api/stats/sessions/:sessionId', async (c) => { - const sessionId = parseIntQuery(c.req.param('sessionId'), 0); - if (sessionId <= 0) return c.body(null, 400); + const sessionId = parsePositiveId(c.req.param('sessionId')); + if (sessionId === null) return c.body(null, 400); await tracker.deleteSession(sessionId); return c.json(statsJson('deleteSession', { ok: true })); }); app.delete('/api/stats/media/:videoId', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); await tracker.deleteVideo(videoId); return c.json(statsJson('deleteVideo', { ok: true })); }); app.delete('/api/stats/anime/:animeId', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); await tracker.deleteAnime(animeId); return c.json(statsJson('deleteAnime', { ok: true })); }); app.post('/api/stats/anime/:animeId/merge', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); - const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId); + const parsedSourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds); + if (!parsedSourceAnimeIds) return c.body(null, 400); + const sourceAnimeIds = parsedSourceAnimeIds.filter((id) => id !== animeId); if (sourceAnimeIds.length === 0) return c.body(null, 400); let summary: Awaited>; try { @@ -271,11 +271,11 @@ export function registerStatsLibraryRoutes( }); app.patch('/api/stats/media/:videoId/anime', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); - const animeId = Number.isSafeInteger(body?.animeId) ? (body.animeId as number) : 0; - if (animeId <= 0) return c.body(null, 400); + const animeId = body?.animeId; + if (!isPositiveSafeInteger(animeId)) return c.body(null, 400); try { const summary = await tracker.moveVideoToAnime(videoId, animeId); return c.json( diff --git a/src/core/services/stats-server/route-support.ts b/src/core/services/stats-server/route-support.ts index e50ef779..eda46cfc 100644 --- a/src/core/services/stats-server/route-support.ts +++ b/src/core/services/stats-server/route-support.ts @@ -48,6 +48,16 @@ export function parseIntQuery( return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); } +export function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +export function parsePositiveId(raw: string | undefined): number | null { + if (raw === undefined) return null; + const value = Number(raw); + return isPositiveSafeInteger(value) && String(value) === raw ? value : null; +} + export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' { return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all' ? raw @@ -199,16 +209,16 @@ export async function enrichSessionsWithKnownWordMetrics< ); } -/** Deduplicated positive integer ids from an untrusted JSON body field. */ -export function parsePositiveIdList(raw: unknown): number[] { - if (!Array.isArray(raw)) return []; +/** Deduplicated positive safe integer ids from an untrusted JSON body field. */ +export function parsePositiveIdList(raw: unknown, maxItems?: number): number[] | null { + if (!Array.isArray(raw)) return null; const ids = new Set(); for (const value of raw) { - if (Number.isSafeInteger(value) && (value as number) > 0) { - ids.add(value as number); - } + if (!isPositiveSafeInteger(value)) return null; + ids.add(value); } - return [...ids]; + const parsed = [...ids]; + return maxItems === undefined ? parsed : parsed.slice(0, maxItems); } export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean { From 383aed8bad03bb296c66a77a904d756bc437d3a8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 23:19:03 -0700 Subject: [PATCH 5/9] fix(stats): harden server lifecycle and verify compiled runtime (#261) --- .github/workflows/quality-gate.yml | 23 +- changes/ci-test-deduplication.md | 4 + changes/compiled-runtime-smoke.md | 4 + changes/fix-stats-server-lifecycle.md | 5 + docs-site/immersion-tracking.md | 4 + docs/workflow/verification.md | 31 +- package.json | 4 +- scripts/compiled-runtime-smoke.mjs | 345 ++++++++++++++++++ scripts/compiled-runtime-smoke.test.ts | 28 ++ scripts/run-coverage-lane.test.ts | 36 +- scripts/run-coverage-lane.ts | 18 +- src/ci-workflow.test.ts | 17 + .../services/__tests__/stats-server.test.ts | 201 +++++----- src/core/services/cli-command.test.ts | 11 + src/core/services/cli-command.ts | 13 +- src/core/services/session-actions.test.ts | 4 +- src/core/services/session-actions.ts | 4 +- src/core/services/stats-server.ts | 86 ++++- src/core/services/stats-window.ts | 24 +- src/main.ts | 41 ++- src/main/main-wiring.test.ts | 8 +- .../runtime/app-lifecycle-actions.test.ts | 69 +++- src/main/runtime/app-lifecycle-actions.ts | 51 ++- .../app-lifecycle-main-cleanup.test.ts | 17 +- .../runtime/app-lifecycle-main-cleanup.ts | 8 +- .../runtime/background-stats-startup.test.ts | 37 +- src/main/runtime/background-stats-startup.ts | 19 +- .../startup-lifecycle-composer.test.ts | 1 + .../composers/startup-lifecycle-composer.ts | 2 +- src/main/runtime/stats-cli-command.ts | 10 +- src/main/runtime/stats-server-routing.test.ts | 18 +- src/main/runtime/stats-server-routing.ts | 8 +- src/main/runtime/stats-server-runtime.test.ts | 229 +++++++++++- src/main/runtime/stats-server-runtime.ts | 252 ++++++++----- src/main/state.ts | 2 +- src/quality-gate-workflow.test.ts | 22 +- src/stats-daemon-runner.ts | 48 ++- 37 files changed, 1381 insertions(+), 323 deletions(-) create mode 100644 changes/ci-test-deduplication.md create mode 100644 changes/compiled-runtime-smoke.md create mode 100644 changes/fix-stats-server-lifecycle.md create mode 100644 scripts/compiled-runtime-smoke.mjs create mode 100644 scripts/compiled-runtime-smoke.test.ts diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 50c69c3e..2efc760c 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -93,12 +93,20 @@ jobs: sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua lua -v - - name: Test suite (source) - run: bun run test:fast + - name: Launcher unit and script suites + run: bun run test:launcher:unit:src && bun run test:scripts - name: Environment suite run: bun run test:env + - name: Upload launcher smoke artifacts (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: launcher-smoke + path: .tmp/launcher-smoke/** + if-no-files-found: ignore + - name: Coverage suite (maintained source lane) run: bun run test:coverage:src @@ -112,17 +120,6 @@ jobs: - name: Stats UI tests run: bun run test:stats - - name: Launcher smoke suite (source) - run: bun run test:launcher:smoke:src - - - name: Upload launcher smoke artifacts (on failure) - if: failure() - uses: actions/upload-artifact@v4 - with: - name: launcher-smoke - path: .tmp/launcher-smoke/** - if-no-files-found: ignore - - name: Build (bundle) run: bun run build diff --git a/changes/ci-test-deduplication.md b/changes/ci-test-deduplication.md new file mode 100644 index 00000000..7aa0feb0 --- /dev/null +++ b/changes/ci-test-deduplication.md @@ -0,0 +1,4 @@ +type: internal +area: ci + +- Removed duplicate source and launcher smoke executions from the reusable quality gate while preserving every distinct test lane and failure artifact. diff --git a/changes/compiled-runtime-smoke.md b/changes/compiled-runtime-smoke.md new file mode 100644 index 00000000..10be32b0 --- /dev/null +++ b/changes/compiled-runtime-smoke.md @@ -0,0 +1,4 @@ +type: internal +area: verification + +- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check for compiled stats startup, HTTP service, native SQLite, port conflicts, and cleanup. diff --git a/changes/fix-stats-server-lifecycle.md b/changes/fix-stats-server-lifecycle.md new file mode 100644 index 00000000..5192e8d2 --- /dev/null +++ b/changes/fix-stats-server-lifecycle.md @@ -0,0 +1,5 @@ +type: fixed +area: stats + +- Stats server startup reports port conflicts without crashing SubMiner, shares concurrent startup requests, and shows in-app startup errors through configured status notifications. +- Background stop cancels pending background startup without disconnecting foreground-only dashboards. Shutdown bounds the wait for active HTTP requests and awaits tracker finalization before exit, with a deadline for forced application exit. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 208918b7..ca45d272 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -37,6 +37,10 @@ The same immersion data powers the stats dashboard. - Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data. - Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running. +SubMiner waits for the local server to bind before reporting that the dashboard is available. If another process already uses the configured port, the command reports the startup error and the desktop app stays open. Opening the in-app dashboard also reports startup failures through your configured status notifications. + +`subminer stats -s` stops a background stats server or cancels a pending background start. It leaves a foreground-only server running, so an open in-app dashboard stays connected. Shutdown gives active HTTP requests one second to finish before closing their connections and finalizing stats. + ### Stats API resource IDs Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds. diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index 7f9c41c3..f39191ea 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -22,9 +22,15 @@ Read when: selecting the right verification lane for a change pull requests, stable tags, and prerelease tags. Keep common quality steps there instead of copying them into caller workflows. - The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv - plugin tests run for every pull request and tagged release. + plugin tests and launcher smoke run for every pull request and tagged release. Lua installation uses only the runner's Ubuntu package sources so unrelated third-party repository failures do not block the gate. +- In the reusable gate, `test:coverage:src` is also the blocking execution of the + discovered `src/**` test lane. The coverage runner returns the failing test's + status, so CI does not rerun that lane through `test:fast`. Launcher unit and + script tests still run separately because they are outside the coverage lane. +- Launcher smoke artifacts are uploaded after `test:env` fails. CI does not rerun + launcher smoke solely to collect the same artifacts. ## Default Handoff Gate @@ -49,7 +55,7 @@ bun run docs:build - Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb` - Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - Launcher/plugin: `bun run test:launcher` or `bun run test:env` -- Runtime-compat / compiled behavior: `bun run test:runtime:compat` +- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat` - Stats dashboard UI: `bun run test:stats` - Build/release scripts (`scripts/**`): `bun run test:scripts` - Packaging: build the platform package, then run `bun run test:package `. @@ -62,11 +68,30 @@ bun run docs:build ## Coverage Reporting -- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output. +- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as + `test:src` through a sharded coverage runner: one Bun coverage process per test + file, then merged LCOV output. +- A failing coverage shard stops the runner with a nonzero status. Coverage is a + source test gate, not a report-only step. - Machine-readable output lands at `coverage/test-src/lcov.info`. - Every reusable quality-gate run uploads that LCOV file as the `coverage-test-src` artifact. +## Compiled Runtime Smoke + +- `bun run test:smoke:dist` and its `test:runtime:compat` alias require an existing + full build and fail with the missing artifact paths when `dist/` or the stats UI + bundle is absent. +- The check runs the emitted stats daemon under Electron's Node runtime. It opens + the production HTTP server, queries the overview endpoint through native + libsql-backed storage, and leaves an HTTP request body unfinished before + shutting the daemon down. It verifies a clean exit and that the port and + ownership state are released despite the unfinished request. +- The check also occupies the configured port, requires startup to fail without + stale ownership state, releases the conflict, and verifies a clean retry. +- This is not a full Electron UI startup check. It does not require a display and + makes no claims about renderer, window, tray, or mpv behavior. + ## Dependency Audit Policy - `bun audit --audit-level high` blocks the reusable quality gate. diff --git a/package.json b/package.json index 339b4b61..12e2a459 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", - "test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full", + "test:smoke:dist": "env ELECTRON_RUN_AS_NODE=1 electron scripts/compiled-runtime-smoke.mjs", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts", "test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js", @@ -63,7 +63,7 @@ "test:scripts": "bun scripts/run-test-lane.mjs scripts", "test:stats": "bun scripts/run-test-lane.mjs stats", "test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src", - "test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full", + "test:runtime:compat": "bun run test:smoke:dist", "test": "bun run test:fast", "test:config": "bun scripts/run-test-lane.mjs config", "test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src", diff --git a/scripts/compiled-runtime-smoke.mjs b/scripts/compiled-runtime-smoke.mjs new file mode 100644 index 00000000..c0ae06cb --- /dev/null +++ b/scripts/compiled-runtime-smoke.mjs @@ -0,0 +1,345 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const START_TIMEOUT_MS = 15_000; +const STOP_TIMEOUT_MS = 5_000; +const POLL_INTERVAL_MS = 25; +const MAX_CHILD_OUTPUT_BYTES = 64 * 1024; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRootArgIndex = process.argv.indexOf('--repo-root'); +const repoRoot = resolve( + repoRootArgIndex === -1 ? join(scriptDir, '..') : (process.argv[repoRootArgIndex + 1] ?? ''), +); +const paths = { + mainEntry: join(repoRoot, 'dist', 'main-entry.js'), + daemonEntry: join(repoRoot, 'dist', 'stats-daemon-runner.js'), + statsServer: join(repoRoot, 'dist', 'core', 'services', 'stats-server.js'), + tracker: join(repoRoot, 'dist', 'core', 'services', 'immersion-tracker-service.js'), + statsIndex: join(repoRoot, 'stats', 'dist', 'index.html'), +}; + +function requireCompiledArtifacts() { + const missing = Object.values(paths).filter((artifactPath) => !existsSync(artifactPath)); + if (missing.length > 0) { + throw new Error( + `Compiled runtime artifacts are missing. Run \`bun run build\` before this check:\n${missing + .map((artifactPath) => ` - ${artifactPath}`) + .join('\n')}`, + ); + } +} + +function requireElectronNodeRuntime() { + if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE !== '1') { + throw new Error( + 'This check must run with Electron in Node mode. Use `bun run test:smoke:dist`.', + ); + } +} + +function delay(ms) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +function listen(server, port = 0) { + return new Promise((resolvePromise, reject) => { + const onError = (error) => { + server.off('listening', onListening); + reject(error); + }; + const onListening = () => { + server.off('error', onError); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Could not resolve the stats smoke port.')); + return; + } + resolvePromise(address.port); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, '127.0.0.1'); + }); +} + +function closeServer(server) { + return new Promise((resolvePromise, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolvePromise(); + }); + }); +} + +async function findAvailablePort() { + const reservation = createServer(); + const port = await listen(reservation); + await closeServer(reservation); + return port; +} + +function writeSmokeConfig(userDataPath, port) { + writeFileSync( + join(userDataPath, 'config.json'), + `${JSON.stringify({ stats: { serverPort: port } })}\n`, + ); +} + +function spawnDaemon(userDataPath, responsePath) { + const child = spawn( + process.execPath, + [ + paths.daemonEntry, + '--stats-user-data-path', + userDataPath, + '--stats-response-path', + responsePath, + ], + { + cwd: repoRoot, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let output = ''; + const appendOutput = (chunk) => { + if (output.length >= MAX_CHILD_OUTPUT_BYTES) return; + output += String(chunk); + if (output.length > MAX_CHILD_OUTPUT_BYTES) { + output = `${output.slice(0, MAX_CHILD_OUTPUT_BYTES)}\n[child output truncated]\n`; + } + }; + child.stdout.on('data', appendOutput); + child.stderr.on('data', appendOutput); + let exited = false; + const exit = new Promise((resolvePromise) => { + child.once('error', (error) => { + exited = true; + resolvePromise({ code: null, signal: null, error }); + }); + child.once('exit', (code, signal) => { + exited = true; + resolvePromise({ code, signal, error: null }); + }); + }); + return { child, exit, hasExited: () => exited, getOutput: () => output }; +} + +async function waitForResponse(responsePath, daemon) { + const deadline = Date.now() + START_TIMEOUT_MS; + while (Date.now() < deadline) { + if (existsSync(responsePath)) { + try { + return JSON.parse(readFileSync(responsePath, 'utf8')); + } catch { + // The daemon may still be finishing the response file write. + } + } + if (daemon.hasExited()) { + const result = await daemon.exit; + throw new Error( + `Stats daemon exited before writing a startup response (${formatExit(result)}).\n${daemon.getOutput()}`, + ); + } + await delay(POLL_INTERVAL_MS); + } + throw new Error(`Timed out waiting for stats daemon startup.\n${daemon.getOutput()}`); +} + +function formatExit(result) { + if (result.error) return result.error.message; + if (result.signal) return `signal ${result.signal}`; + return `exit code ${result.code}`; +} + +async function waitForExit(daemon, timeoutMs) { + return await Promise.race([daemon.exit, delay(timeoutMs).then(() => null)]); +} + +async function stopDaemon(daemon) { + if (daemon.hasExited()) { + return await daemon.exit; + } + daemon.child.kill('SIGTERM'); + const result = await waitForExit(daemon, STOP_TIMEOUT_MS); + if (result) return result; + daemon.child.kill('SIGKILL'); + await daemon.exit; + throw new Error(`Stats daemon did not stop after SIGTERM.\n${daemon.getOutput()}`); +} + +async function assertPortCanBind(port) { + const server = createServer(); + try { + await listen(server, port); + } finally { + if (server.listening) await closeServer(server); + } +} + +async function fetchOverview(url, daemon) { + const deadline = Date.now() + START_TIMEOUT_MS; + let lastError = null; + while (Date.now() < deadline) { + try { + return await fetch(`${url}/api/stats/overview`, { + signal: AbortSignal.timeout(START_TIMEOUT_MS), + }); + } catch (error) { + lastError = error; + } + if (daemon.hasExited()) { + const result = await daemon.exit; + throw new Error( + `Stats daemon exited before accepting HTTP requests (${formatExit(result)}).\n${daemon.getOutput()}`, + ); + } + await delay(POLL_INTERVAL_MS); + } + throw new Error( + `Timed out waiting for the stats HTTP server: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${daemon.getOutput()}`, + ); +} + +async function runHealthyStartup(userDataPath, port, responseName) { + writeSmokeConfig(userDataPath, port); + const responsePath = join(userDataPath, responseName); + const statePath = join(userDataPath, 'stats-daemon.json'); + const databasePath = join(userDataPath, 'immersion.sqlite'); + const daemon = spawnDaemon(userDataPath, responsePath); + let shutdownResult; + let unfinishedRequest; + + try { + const startup = await waitForResponse(responsePath, daemon); + assert.deepEqual(startup, { ok: true, url: `http://127.0.0.1:${port}` }); + + const response = await fetchOverview(startup.url, daemon); + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') ?? '', /^application\/json\b/); + const overview = await response.json(); + assert.equal(typeof overview, 'object'); + assert.ok(overview !== null); + assert.ok(Array.isArray(overview.sessions)); + assert.ok(Array.isArray(overview.rollups)); + assert.equal(typeof overview.hints, 'object'); + + const dashboard = await fetch(`${startup.url}/?overlay=1`, { + signal: AbortSignal.timeout(START_TIMEOUT_MS), + }); + assert.equal(dashboard.status, 200); + assert.match(dashboard.headers.get('content-type') ?? '', /^text\/html\b/); + assert.match(await dashboard.text(), /id="root"/); + + assert.ok(existsSync(databasePath), 'The compiled tracker did not create its SQLite database.'); + assert.ok( + statSync(databasePath).size > 0, + 'The compiled tracker created an empty SQLite file.', + ); + + // Leave a real request body unfinished so shutdown must bound its drain wait. + unfinishedRequest = httpRequest(new URL('/api/stats/anki/notesInfo', startup.url), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '1000', + Expect: '100-continue', + }, + signal: AbortSignal.timeout(START_TIMEOUT_MS), + }); + await new Promise((resolvePromise, reject) => { + unfinishedRequest.on('error', reject); + unfinishedRequest.once('continue', () => { + unfinishedRequest.write('{'); + resolvePromise(); + }); + unfinishedRequest.flushHeaders(); + }); + } finally { + try { + shutdownResult = await stopDaemon(daemon); + } finally { + unfinishedRequest?.destroy(); + } + } + + assert.equal(shutdownResult.code, 0, `Stats daemon shutdown failed.\n${daemon.getOutput()}`); + assert.equal(existsSync(statePath), false, 'Stats daemon state remained after shutdown.'); + await assertPortCanBind(port); +} + +async function runConflictRecovery() { + const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-conflict-')); + const reservation = createServer(); + const port = await listen(reservation); + const responsePath = join(userDataPath, 'conflict-response.json'); + const statePath = join(userDataPath, 'stats-daemon.json'); + writeSmokeConfig(userDataPath, port); + const daemon = spawnDaemon(userDataPath, responsePath); + const failures = []; + + try { + const response = await waitForResponse(responsePath, daemon); + if (response.ok !== false) { + failures.push('The stats daemon reported success while its configured port was occupied.'); + } + const result = await waitForExit(daemon, STOP_TIMEOUT_MS); + if (!result) { + failures.push('The stats daemon did not exit after its configured port failed to bind.'); + } else if (result.code === 0) { + failures.push( + 'The stats daemon exited successfully after its configured port failed to bind.', + ); + } + if (existsSync(statePath)) { + failures.push( + 'The stats daemon left ownership state behind after its configured port failed to bind.', + ); + } + } finally { + await closeServer(reservation); + if (!daemon.hasExited()) { + await stopDaemon(daemon); + } + } + + try { + await runHealthyStartup(userDataPath, port, 'recovery-response.json'); + } finally { + rmSync(userDataPath, { recursive: true, force: true }); + } + + assert.deepEqual(failures, []); +} + +async function main() { + requireCompiledArtifacts(); + requireElectronNodeRuntime(); + + const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-runtime-')); + try { + await runHealthyStartup(userDataPath, await findAvailablePort(), 'startup-response.json'); + } finally { + rmSync(userDataPath, { recursive: true, force: true }); + } + await runConflictRecovery(); + + process.stdout.write( + `Compiled runtime smoke passed with Electron ${process.versions.electron}, Node ${process.versions.node}, HTTP, and native SQLite.\n`, + ); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/compiled-runtime-smoke.test.ts b/scripts/compiled-runtime-smoke.test.ts new file mode 100644 index 00000000..e732a1d8 --- /dev/null +++ b/scripts/compiled-runtime-smoke.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +test('compiled runtime smoke fails clearly when build artifacts are missing', () => { + const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-compiled-missing-')); + try { + const result = spawnSync( + process.execPath, + ['scripts/compiled-runtime-smoke.mjs', '--repo-root', emptyRepo], + { + cwd: path.resolve(import.meta.dir, '..'), + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Compiled runtime artifacts are missing/); + assert.match(result.stderr, /dist\/main-entry\.js/); + assert.match(result.stderr, /dist\/stats-daemon-runner\.js/); + assert.doesNotMatch(result.stderr, /must run with Electron/); + } finally { + fs.rmSync(emptyRepo, { recursive: true, force: true }); + } +}); diff --git a/scripts/run-coverage-lane.test.ts b/scripts/run-coverage-lane.test.ts index 7c6f0c86..2a5fa2e7 100644 --- a/scripts/run-coverage-lane.test.ts +++ b/scripts/run-coverage-lane.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; -import { resolve } from 'node:path'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import test from 'node:test'; -import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane'; +import { mergeLcovReports, resolveCoverageDir, runCoverageLane } from './run-coverage-lane'; test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => { const merged = mergeLcovReports([ @@ -72,3 +74,33 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => { assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape'])); assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape'])); }); + +test('runCoverageLane returns a failure when a discovered test fails', () => { + const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-')); + try { + mkdirSync(join(repoRoot, 'src')); + writeFileSync( + join(repoRoot, 'src', 'failure.test.ts'), + [ + "import assert from 'node:assert/strict';", + "import test from 'node:test';", + '', + "test('intentional coverage failure', () => {", + " assert.fail('coverage runner must propagate this failure');", + '});', + '', + ].join('\n'), + ); + + assert.notEqual( + runCoverageLane({ + repoRootDir: repoRoot, + argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'], + stdio: 'pipe', + }), + 0, + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/run-coverage-lane.ts b/scripts/run-coverage-lane.ts index 3140a623..5d567b40 100644 --- a/scripts/run-coverage-lane.ts +++ b/scripts/run-coverage-lane.ts @@ -201,14 +201,18 @@ export function mergeLcovReports(reports: string[]): string { return chunks.length > 0 ? `${chunks.join('\n')}\n` : ''; } -function runCoverageLane(): number { - const laneName = process.argv[2]; +export function runCoverageLane( + options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {}, +): number { + const repoRootDir = options.repoRootDir ?? repoRoot; + const argv = options.argv ?? process.argv.slice(2); + const laneName = argv[0]; if (laneName === undefined) { process.stderr.write('Missing coverage lane name\n'); return 1; } - const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3)); + const coverageDir = resolveCoverageDir(repoRootDir, argv.slice(1)); const shardRoot = join(coverageDir, '.shards'); mkdirSync(coverageDir, { recursive: true }); rmSync(shardRoot, { recursive: true, force: true }); @@ -216,7 +220,7 @@ function runCoverageLane(): number { let files: string[]; try { - files = collectLaneFiles(repoRoot, laneName); + files = collectLaneFiles(repoRootDir, laneName); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : error}\n`); return 1; @@ -230,8 +234,8 @@ function runCoverageLane(): number { 'bun', ['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`], { - cwd: repoRoot, - stdio: 'inherit', + cwd: repoRootDir, + stdio: options.stdio ?? 'inherit', }, ); @@ -253,7 +257,7 @@ function runCoverageLane(): number { writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8'); process.stdout.write( - `Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`, + `Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`, ); return 0; } finally { diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts index 800d53c4..ce153430 100644 --- a/src/ci-workflow.test.ts +++ b/src/ci-workflow.test.ts @@ -19,6 +19,23 @@ test('package scripts expose a sharded maintained source coverage lane with lcov ); }); +test('source and coverage scripts discover the same maintained source lane', () => { + const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1]; + const coverageLane = packageJson.scripts['test:coverage:src']?.match( + /run-coverage-lane\.ts\s+([^\s]+)/, + )?.[1]; + + assert.equal(sourceLane, 'bun-src-full'); + assert.equal(coverageLane, sourceLane); +}); + +test('environment suite owns launcher smoke execution', () => { + assert.match( + packageJson.scripts['test:env'] ?? '', + /^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/, + ); +}); + test('ci delegates its gate instead of duplicating quality steps', () => { assert.match( ciWorkflow, diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index b4264187..83f3162f 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -5,7 +5,11 @@ import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import type { AddressInfo } from 'node:net'; -import { createStatsApp, startStatsServer } from '../stats-server.js'; +import { + createStatsApp, + startNodeHttpServer, + startStatsServerWithRuntime, +} from '../stats-server.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import { clearRetimedSecondarySubtitleCache, @@ -3995,102 +3999,133 @@ Aligned English subtitle assert.equal(ensureCalls, 1); }); - it('starts the stats server with Bun.serve', () => { - type BunRuntime = { - Bun: { - serve: (options: { fetch: unknown; port: number; hostname: string }) => { - stop: () => void; - }; - }; - }; - - const bun = globalThis as typeof globalThis & BunRuntime; - const originalServe = bun.Bun.serve; - let servedWith: { fetch: unknown; port: number; hostname: string } | null = null; + it('starts and stops the stats server with Bun.serve', async () => { + const servedOptions: Array<{ fetch: unknown; port: number; hostname: string }> = []; let stopCalls = 0; - - bun.Bun.serve = (options: { fetch: unknown; port: number; hostname: string }) => { - servedWith = options; - return { - stop: () => { - stopCalls += 1; - }, - }; - }; - - try { - const server = startStatsServer({ + const server = await startStatsServerWithRuntime( + { port: 3210, staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')), tracker: createMockTracker(), - }); + }, + { + bunServe: (options) => { + servedOptions.push(options); + return { + stop: () => { + stopCalls += 1; + }, + }; + }, + }, + ); - if (servedWith === null) { - throw new Error('expected Bun.serve to be called'); - } - - const servedOptions = servedWith as { - fetch: unknown; - port: number; - hostname: string; - }; - assert.equal(servedOptions.port, 3210); - assert.equal(servedOptions.hostname, '127.0.0.1'); - assert.equal(typeof servedOptions.fetch, 'function'); - - server.close(); - assert.equal(stopCalls, 1); - } finally { - bun.Bun.serve = originalServe; + const servedWith = servedOptions[0]; + if (!servedWith) { + throw new Error('expected Bun.serve to be called'); } + + assert.equal(servedWith.port, 3210); + assert.equal(servedWith.hostname, '127.0.0.1'); + assert.equal(typeof servedWith.fetch, 'function'); + + await Promise.all([server.close(), server.close()]); + assert.equal(stopCalls, 1); }); - it('falls back to node:http when Bun.serve is unavailable', () => { - type BunRuntime = { - Bun: { - serve?: (options: { fetch: unknown; port: number; hostname: string }) => { - stop: () => void; - }; - }; - }; - - const bun = globalThis as typeof globalThis & BunRuntime; - const originalServe = bun.Bun.serve; - const originalCreateServer = http.createServer; - let listenedWith: { port: number; hostname: string } | null = null; + it('waits for node:http listening and converts startup errors into rejections', async () => { + const app = createStatsApp(createMockTracker()); + const listeningServer = http.createServer(); let closeCalls = 0; - bun.Bun.serve = undefined; - ( - http as typeof http & { - createServer: typeof http.createServer; - } - ).createServer = (() => - ({ - listen: (port: number, hostname: string) => { - listenedWith = { port, hostname }; - }, - close: () => { + Object.defineProperties(listeningServer, { + listen: { + value: () => listeningServer, + }, + close: { + value: (callback?: (error?: Error) => void) => { closeCalls += 1; + callback?.(); + return listeningServer; }, - }) as unknown as ReturnType) as typeof http.createServer; + }, + }); + + let startupSettled = false; + const startup = startNodeHttpServer( + app, + { + port: 3210, + staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-events-')), + tracker: createMockTracker(), + }, + () => listeningServer, + ); + void startup.finally(() => { + startupSettled = true; + }); + await Promise.resolve(); + assert.equal(startupSettled, false); + + listeningServer.emit('listening'); + const handle = await startup; + await Promise.all([handle.close(), handle.close()]); + assert.equal(closeCalls, 1); + + const failingServer = http.createServer(); + Object.defineProperty(failingServer, 'listen', { + value: () => failingServer, + }); + const failedStartup = startNodeHttpServer( + app, + { + port: 3210, + staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')), + tracker: createMockTracker(), + }, + () => failingServer, + ); + failingServer.emit('error', Object.assign(new Error('address in use'), { code: 'EADDRINUSE' })); + await assert.rejects( + failedStartup, + (error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE', + ); + }); + + it('starts, rejects address conflicts, and stops through real node:http sockets', async () => { + const app = createStatsApp(createMockTracker()); + const server = await startNodeHttpServer(app, { + port: 0, + staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')), + tracker: createMockTracker(), + }); + await Promise.all([server.close(), server.close()]); + + const blocker = http.createServer(); + await new Promise((resolve, reject) => { + blocker.once('error', reject); + blocker.listen(0, '127.0.0.1', resolve); + }); + const address = blocker.address(); + if (!address || typeof address === 'string') { + throw new Error('expected blocker to listen on a TCP port'); + } try { - const server = startStatsServer({ - port: 0, - staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')), - tracker: createMockTracker(), - }); - - assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' }); - server.close(); - assert.equal(closeCalls, 1); + await assert.rejects( + startNodeHttpServer(app, { + port: address.port, + staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-error-')), + tracker: createMockTracker(), + }), + (error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE', + ); } finally { - bun.Bun.serve = originalServe; - ( - http as typeof http & { - createServer: typeof http.createServer; - } - ).createServer = originalCreateServer; + await new Promise((resolve, reject) => { + blocker.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); } }); }); diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 5d23f3dd..b9c5e422 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -406,6 +406,17 @@ test('handleCliCommand ensures background stats server for second-instance --sta assert.equal(ensured.length, 1); }); +test('handleCliCommand reports unexpected background stats startup failures', async () => { + const startup = Promise.reject(new Error('startup unavailable')); + const { deps, calls, osd } = createDeps({ ensureBackgroundStatsServer: () => startup }); + + handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.includes('error:ensureBackgroundStatsServer failed:')); + assert.ok(osd.includes('Stats server startup failed: startup unavailable')); +}); + test('handleCliCommand does not ensure background stats server for foreground --start', () => { const ensured: number[] = []; const { deps } = createDeps({ diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 35b1a7e4..ea6b7a35 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -107,7 +107,7 @@ export interface CliCommandServiceDeps { mode: NonNullable; source: CliCommandSource; }) => Promise; - ensureBackgroundStatsServer?: () => void; + ensureBackgroundStatsServer?: () => Promise | void; printHelp: () => void; hasMainWindow: () => boolean; getMultiCopyTimeoutMs: () => number; @@ -188,7 +188,7 @@ interface AnilistCliRuntime { interface AppCliRuntime { stop: () => void; hasMainWindow: () => boolean; - ensureBackgroundStatsServer?: () => void; + ensureBackgroundStatsServer?: () => Promise | void; runUpdateCommand: CliCommandServiceDeps['runUpdateCommand']; runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand']; runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow']; @@ -400,7 +400,14 @@ export function handleCliCommand( } if (args.start && args.background) { - deps.ensureBackgroundStatsServer?.(); + runAsyncWithOsd( + async () => { + await deps.ensureBackgroundStatsServer?.(); + }, + deps, + 'ensureBackgroundStatsServer', + 'Stats server startup failed', + ); } if (args.sessionAction) { diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index 636f794a..9e22b089 100644 --- a/src/core/services/session-actions.test.ts +++ b/src/core/services/session-actions.test.ts @@ -6,7 +6,9 @@ import { dispatchSessionAction, type SessionActionExecutorDeps } from './session function createDeps(overrides: Partial = {}) { const calls: string[] = []; const deps: SessionActionExecutorDeps = { - toggleStatsOverlay: () => calls.push('stats'), + toggleStatsOverlay: () => { + calls.push('stats'); + }, toggleVisibleOverlay: () => calls.push('visible'), copyCurrentSubtitle: () => calls.push('copy'), copySubtitleCount: (count) => calls.push(`copy:${count}`), diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index b84d6c9f..3e50ffcb 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -3,7 +3,7 @@ import type { SessionActionId } from '../../types/session-bindings'; import type { SessionActionDispatchRequest } from '../../types/runtime'; export interface SessionActionExecutorDeps { - toggleStatsOverlay: () => void; + toggleStatsOverlay: () => Promise | void; toggleVisibleOverlay: () => void; copyCurrentSubtitle: () => void; copySubtitleCount: (count: number) => void; @@ -50,7 +50,7 @@ export async function dispatchSessionAction( ): Promise { switch (request.actionId) { case 'toggleStatsOverlay': - deps.toggleStatsOverlay(); + await deps.toggleStatsOverlay(); return; case 'toggleVisibleOverlay': deps.toggleVisibleOverlay(); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 6254a20b..66113ddd 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -50,8 +50,26 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom res.end(Buffer.from(await response.arrayBuffer())); } -function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } { - const server = http.createServer((req, res) => { +export interface StatsServer { + close: () => Promise; +} + +const SHUTDOWN_GRACE_MS = 1_000; + +type BunServe = (options: { + fetch: (typeof Hono.prototype)['fetch']; + port: number; + hostname: string; +}) => { + stop: () => Promise | void; +}; + +export function startNodeHttpServer( + app: Hono, + config: StatsServerConfig, + createServer: (listener: http.RequestListener) => http.Server = http.createServer, +): Promise { + const server = createServer((req, res) => { void (async () => { try { await writeFetchResponse(res, await app.fetch(toFetchRequest(req))); @@ -61,12 +79,33 @@ function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () } })(); }); - server.listen(config.port, '127.0.0.1'); - return { - close: () => { - server.close(); - }, - }; + return new Promise((resolve, reject) => { + const handleStartupError = (error: Error): void => { + server.removeListener('listening', handleListening); + reject(error); + }; + const handleListening = (): void => { + server.removeListener('error', handleStartupError); + let closePromise: Promise | null = null; + resolve({ + close: () => { + closePromise ??= new Promise((closeResolve, closeReject) => { + const forceClose = setTimeout(() => server.closeAllConnections(), SHUTDOWN_GRACE_MS); + server.close((error) => { + clearTimeout(forceClose); + if (error) closeReject(error); + else closeResolve(); + }); + }); + return closePromise; + }, + }); + }; + + server.once('error', handleStartupError); + server.once('listening', handleListening); + server.listen(config.port, '127.0.0.1'); + }); } export interface StatsServerConfig { @@ -125,7 +164,10 @@ export function createStatsApp( return app; } -export function startStatsServer(config: StatsServerConfig): { close: () => void } { +export async function startStatsServerWithRuntime( + config: StatsServerConfig, + runtime: { bunServe: BunServe | null }, +): Promise { const app = createStatsApp(config.tracker, { staticDir: config.staticDir, knownWordCachePath: config.knownWordCachePath, @@ -144,20 +186,26 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords, }); - const bunRuntime = globalThis as typeof globalThis & { - Bun?: { - serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => { - stop: () => void; - }; - }; - }; - if (bunRuntime.Bun?.serve) { - const server = bunRuntime.Bun.serve({ + if (runtime.bunServe) { + const server = runtime.bunServe({ fetch: app.fetch, port: config.port, hostname: '127.0.0.1', }); - return { close: () => server.stop() }; + let closePromise: Promise | null = null; + return Promise.resolve({ + close: () => { + closePromise ??= Promise.resolve().then(() => server.stop()); + return closePromise; + }, + }); } return startNodeHttpServer(app, config); } + +export function startStatsServer(config: StatsServerConfig): Promise { + const bunRuntime = globalThis as typeof globalThis & { + Bun?: { serve?: BunServe }; + }; + return startStatsServerWithRuntime(config, { bunServe: bunRuntime.Bun?.serve ?? null }); +} diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index 3715c925..fba13633 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -1,5 +1,6 @@ import { BrowserWindow, dialog, ipcMain } from 'electron'; import * as path from 'path'; +import { createLogger } from '../../logger.js'; import type { WindowGeometry } from '../../types.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { @@ -26,9 +27,11 @@ import { } from './stats-window-layer.js'; let statsWindow: BrowserWindow | null = null; +let statsWindowGeneration = 0; let toggleRegistered = false; let nativeDialogLayerRegistered = false; const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState(); +const logger = createLogger('main:stats-window'); export interface StatsWindowOptions { /** Absolute path to stats/dist/ directory */ @@ -36,7 +39,9 @@ export interface StatsWindowOptions { /** Absolute path to the compiled preload-stats.js */ preloadPath: string; /** Resolve the active stats API base URL */ - getApiBaseUrl?: () => string; + getApiBaseUrl?: () => Promise | string; + /** Report server startup failure through the configured notification surface. */ + onStartupError?: (error: unknown) => void; /** Resolve the active stats toggle key from config */ getToggleKey: () => string; /** Resolve the tracked overlay/mpv bounds */ @@ -179,8 +184,16 @@ function registerStatsNativeDialogLayerHandlers(): void { * Toggle the stats overlay window: create on first call, then show/hide. * The React app stays mounted across toggles — state is preserved. */ -export function toggleStatsOverlay(options: StatsWindowOptions): void { +export async function toggleStatsOverlay(options: StatsWindowOptions): Promise { if (!statsWindow) { + const generation = statsWindowGeneration; + const apiBaseUrl = await Promise.resolve() + .then(() => options.getApiBaseUrl?.()) + .catch((error: unknown) => { + options.onStartupError?.(error); + throw error; + }); + if (generation !== statsWindowGeneration || statsWindow) return; statsWindow = new BrowserWindow( buildStatsWindowOptions({ preloadPath: options.preloadPath, @@ -195,7 +208,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { }); const indexPath = path.join(options.staticDir, 'index.html'); - statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.())); + statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(apiBaseUrl)); statsWindow.on('closed', () => { options.onVisibilityChanged?.(false); @@ -243,7 +256,9 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void { if (toggleRegistered) return; toggleRegistered = true; ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => { - toggleStatsOverlay(options); + void toggleStatsOverlay(options).catch((error: unknown) => { + logger.error('Failed to open stats overlay:', error); + }); }); } @@ -252,6 +267,7 @@ export function registerStatsOverlayToggle(options: StatsWindowOptions): void { * Call during app quit. */ export function destroyStatsWindow(): void { + statsWindowGeneration += 1; if (statsWindow && !statsWindow.isDestroyed()) { statsWindow.destroy(); statsWindow = null; diff --git a/src/main.ts b/src/main.ts index 68ad8620..a9be66f0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -421,6 +421,7 @@ import { writeStatsCliCommandResponse, } from './main/runtime/stats-cli-command'; import { createStatsServerRuntime } from './main/runtime/stats-server-runtime'; +import { createForceQuitHandler } from './main/runtime/app-lifecycle-actions'; import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { @@ -1010,11 +1011,17 @@ function requestAppQuit(): void { destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); appState.yomitanSettingsWindow = null; destroyStatsWindow(); - stopStatsServer(); + void stopStatsServer().catch((error: unknown) => { + logger.warn('Failed to stop stats server while quitting.', error); + }); if (!forceQuitTimer) { forceQuitTimer = setTimeout(() => { logger.warn('App quit timed out; forcing process exit.'); - app.exit(0); + void createForceQuitHandler({ + destroyImmersionTracker: () => appState.immersionTracker?.destroy(), + logError: (error) => logger.error('Failed to finalize stats before forced exit.', error), + exit: () => app.exit(0), + })(); }, 2000); } app.quit(); @@ -4005,8 +4012,8 @@ const { }, getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getImmersionTracker: () => appState.immersionTracker, + stopStatsServer: () => stopStatsServer(), clearImmersionTracker: () => { - stopStatsServer(); appState.statsServer = null; appState.immersionTracker = null; }, @@ -4095,7 +4102,9 @@ const immersionTrackerStartupMainDeps: Parameters< const trackerHasChanged = appState.immersionTracker !== null && appState.immersionTracker !== tracker; if (trackerHasChanged && appState.statsServer) { - stopStatsServer(); + void stopStatsServer().catch((error: unknown) => { + logger.warn('Failed to stop stats server while replacing immersion tracker.', error); + }); appState.statsServer = null; } @@ -4106,7 +4115,9 @@ const immersionTrackerStartupMainDeps: Parameters< if (!appState.statsServer) { const config = configService.getConfig(); if (config.stats.autoStartServer) { - ensureStatsServerStarted(); + void ensureStatsServerStarted().catch((error: unknown) => { + logger.warn('Failed to auto-start stats server.', error); + }); } } @@ -4114,7 +4125,12 @@ const immersionTrackerStartupMainDeps: Parameters< registerStatsOverlayToggle({ staticDir: statsDistPath, preloadPath: statsPreloadPath, - getApiBaseUrl: () => ensureStatsServerStarted().url, + getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, + onStartupError: (error) => + overlayNotificationsRuntime.showConfiguredStatusNotification( + `Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`, + { title: 'Stats' }, + ), getToggleKey: () => configService.getConfig().stats.toggleKey, resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), onVisibilityChanged: (visible) => { @@ -4196,7 +4212,7 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({ await createMecabTokenizerAndCheck(); }, getImmersionTracker: () => appState.immersionTracker, - ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted().url, + ensureStatsServerStarted: async () => (await statsStartupRuntime.ensureStatsServerStarted()).url, ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(), stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(), @@ -5488,11 +5504,16 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise { await dispatchSessionActionCore(request, { - toggleStatsOverlay: () => - toggleStatsOverlayWindow({ + toggleStatsOverlay: async () => + await toggleStatsOverlayWindow({ staticDir: statsDistPath, preloadPath: statsPreloadPath, - getApiBaseUrl: () => ensureStatsServerStarted().url, + getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, + onStartupError: (error) => + overlayNotificationsRuntime.showConfiguredStatusNotification( + `Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`, + { title: 'Stats' }, + ), getToggleKey: () => configService.getConfig().stats.toggleKey, resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), onVisibilityChanged: (visible) => { diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index d8b853d7..e3accd75 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -433,11 +433,11 @@ test('warm tokenization release can signal readiness before the first subtitle a test('stats server Yomitan note creation honors configured Anki server override policy', () => { const source = readSource('src/main/runtime/stats-server-runtime.ts'); - const startStatsServerBlock = source.match( - /statsServer = startStatsServer\(\{(?[\s\S]*?)\n \}\);/, + const statsServerConfigBlock = source.match( + /const buildStatsServerConfig[\s\S]*?return \{(?[\s\S]*?)\n \};\n \};/, )?.groups?.body; - const addYomitanNoteBlock = startStatsServerBlock?.match( - /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + const addYomitanNoteBlock = statsServerConfigBlock?.match( + /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; assert.ok(addYomitanNoteBlock); diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index e22f1774..1fff472d 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -1,12 +1,32 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + createForceQuitHandler, createOnWillQuitCleanupHandler, createRestoreWindowsOnActivateHandler, createShouldRestoreWindowsOnActivateHandler, } from './app-lifecycle-actions'; -test('on will quit cleanup handler runs all cleanup steps', () => { +test('forced quit finalizes stats before exiting, even when finalization throws', async () => { + for (const fails of [false, true]) { + const calls: string[] = []; + await createForceQuitHandler({ + destroyImmersionTracker: () => { + calls.push('finalize'); + if (fails) throw new Error('flush failed'); + }, + logError: () => { + calls.push('error'); + }, + exit: () => { + calls.push('exit'); + }, + })(); + assert.deepEqual(calls, fails ? ['finalize', 'error', 'exit'] : ['finalize', 'exit']); + } +}); + +test('on will quit cleanup handler runs all cleanup steps', async () => { const calls: string[] = []; const cleanup = createOnWillQuitCleanupHandler({ destroyTray: () => calls.push('destroy-tray'), @@ -32,7 +52,15 @@ test('on will quit cleanup handler runs all cleanup steps', () => { destroyMpvSocket: () => calls.push('destroy-socket'), clearReconnectTimer: () => calls.push('clear-reconnect'), destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'), - destroyImmersionTracker: () => calls.push('destroy-immersion'), + stopStatsServer: async () => { + calls.push('stop-stats-server-start'); + await Promise.resolve(); + calls.push('stop-stats-server-complete'); + }, + destroyImmersionTracker: async () => { + await Promise.resolve(); + calls.push('destroy-immersion'); + }, destroyAnkiIntegration: () => calls.push('destroy-anki'), destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'), clearAnilistSetupWindow: () => calls.push('clear-anilist-window'), @@ -51,8 +79,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => { stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); - cleanup(); - assert.equal(calls.length, 36); + await cleanup(); + assert.equal(calls.length, 38); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.ok(calls.includes('cleanup-jellyfin-subtitles')); @@ -63,9 +91,37 @@ test('on will quit cleanup handler runs all cleanup steps', () => { assert.ok(calls.includes('cleanup-youtube-media')); assert.ok(calls.includes('cleanup-remote-media-windows')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); + assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion')); + assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki')); }); -test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => { +test('forced quit waits for asynchronous stats finalization', async () => { + const calls: string[] = []; + await createForceQuitHandler({ + destroyImmersionTracker: async () => { + await Promise.resolve(); + calls.push('finalized'); + }, + logError: () => calls.push('error'), + exit: () => calls.push('exit'), + })(); + assert.deepEqual(calls, ['finalized', 'exit']); +}); + +test('forced quit exits when asynchronous stats finalization never settles', async () => { + const calls: string[] = []; + await createForceQuitHandler({ + destroyImmersionTracker: () => new Promise(() => {}), + logError: (error) => { + assert.match(String(error), /Stats finalization timed out/); + calls.push('timeout'); + }, + exit: () => calls.push('exit'), + })(); + assert.deepEqual(calls, ['timeout', 'exit']); +}); + +test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => { const calls: string[] = []; const cleanup = createOnWillQuitCleanupHandler({ destroyTray: () => {}, @@ -87,6 +143,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping destroyMpvSocket: () => {}, clearReconnectTimer: () => {}, destroySubtitleTimingTracker: () => {}, + stopStatsServer: () => {}, destroyImmersionTracker: () => {}, destroyAnkiIntegration: () => {}, destroyAnilistSetupWindow: () => {}, @@ -109,7 +166,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); - assert.throws(() => cleanup(), /stop failed/); + await assert.rejects(cleanup(), /stop failed/); assert.deepEqual(calls, [ 'stop-jellyfin-remote', 'cleanup-jellyfin-subtitles', diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 5a6b20f9..e754b280 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -1,3 +1,26 @@ +export function createForceQuitHandler(deps: { + destroyImmersionTracker: () => void | Promise; + logError: (error: unknown) => void; + exit: () => void; +}) { + return async () => { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + Promise.resolve().then(() => deps.destroyImmersionTracker()), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('Stats finalization timed out.')), 1_000); + }), + ]); + } catch (error) { + deps.logError(error); + } finally { + clearTimeout(timeout); + deps.exit(); + } + }; +} + export function createOnWillQuitCleanupHandler(deps: { destroyTray: () => void; stopConfigHotReload: () => void; @@ -18,7 +41,8 @@ export function createOnWillQuitCleanupHandler(deps: { destroyMpvSocket: () => void; clearReconnectTimer: () => void; destroySubtitleTimingTracker: () => void; - destroyImmersionTracker: () => void; + stopStatsServer: () => Promise | void; + destroyImmersionTracker: () => void | Promise; destroyAnkiIntegration: () => void; destroyAnilistSetupWindow: () => void; clearAnilistSetupWindow: () => void; @@ -36,7 +60,7 @@ export function createOnWillQuitCleanupHandler(deps: { cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { - return (): Promise => { + return async (): Promise => { deps.destroyTray(); deps.stopConfigHotReload(); deps.restorePreviousSecondarySubVisibility(); @@ -44,7 +68,12 @@ export function createOnWillQuitCleanupHandler(deps: { deps.unregisterAllGlobalShortcuts(); deps.stopSubtitleWebsocket(); deps.stopTexthookerService(); - const stopSyncAutoScheduler = deps.stopSyncAutoScheduler(); + const cleanupErrors: unknown[] = []; + const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch( + (error: unknown) => { + cleanupErrors.push(error); + }, + ); deps.clearWindowsVisibleOverlayForegroundPollLoop(); deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(); deps.destroyMainOverlayWindow(); @@ -56,7 +85,16 @@ export function createOnWillQuitCleanupHandler(deps: { deps.destroyMpvSocket(); deps.clearReconnectTimer(); deps.destroySubtitleTimingTracker(); - deps.destroyImmersionTracker(); + try { + await deps.stopStatsServer(); + } catch (error) { + cleanupErrors.push(error); + } + try { + await deps.destroyImmersionTracker(); + } catch (error) { + cleanupErrors.push(error); + } deps.destroyAnkiIntegration(); deps.destroyAnilistSetupWindow(); deps.clearAnilistSetupWindow(); @@ -79,7 +117,10 @@ export function createOnWillQuitCleanupHandler(deps: { deps.cleanupYoutubeMediaCache(); deps.cleanupRemoteMediaWindows(); deps.stopDiscordPresenceService(); - return Promise.resolve(stopSyncAutoScheduler); + await stopSyncAutoScheduler; + if (cleanupErrors.length > 0) { + throw cleanupErrors[0]; + } }; } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index ffe211a0..54a3bf15 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -3,11 +3,14 @@ import test from 'node:test'; import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup'; import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions'; -test('cleanup deps builder returns handlers that guard optional runtime objects', () => { +test('cleanup deps builder returns handlers that guard optional runtime objects', async () => { const calls: string[] = []; let reconnectTimer: ReturnType | null = setTimeout(() => {}, 60_000); - let immersionTracker: { destroy: () => void } | null = { - destroy: () => calls.push('destroy-immersion'), + let immersionTracker: { destroy: () => Promise } | null = { + destroy: async () => { + await Promise.resolve(); + calls.push('destroy-immersion'); + }, }; const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ @@ -54,6 +57,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }), getImmersionTracker: () => immersionTracker, + stopStatsServer: () => { + calls.push('stop-stats-server'); + }, clearImmersionTracker: () => { immersionTracker = null; calls.push('clear-immersion-ref'); @@ -81,7 +87,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' }); const cleanup = createOnWillQuitCleanupHandler(depsFactory()); - cleanup(); + await cleanup(); assert.ok(calls.includes('destroy-tray')); assert.ok(calls.includes('destroy-main-overlay-window')); @@ -94,6 +100,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' assert.ok(calls.includes('clear-reconnect-ref')); assert.ok(calls.includes('destroy-immersion')); assert.ok(calls.includes('clear-immersion-ref')); + assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref')); assert.ok(calls.includes('destroy-first-run-window')); assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('stop-jellyfin-remote')); @@ -144,6 +151,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { clearReconnectTimerRef: () => {}, getSubtitleTimingTracker: () => null, getImmersionTracker: () => null, + stopStatsServer: () => {}, clearImmersionTracker: () => {}, getAnkiIntegration: () => null, getAnilistSetupWindow: () => null, @@ -198,6 +206,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () = clearReconnectTimerRef: () => {}, getSubtitleTimingTracker: () => null, getImmersionTracker: () => null, + stopStatsServer: () => {}, clearImmersionTracker: () => {}, getAnkiIntegration: () => null, getAnilistSetupWindow: () => null, diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 9a9d4833..888c0a1f 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -44,7 +44,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { clearReconnectTimerRef: () => void; getSubtitleTimingTracker: () => Destroyable | null; - getImmersionTracker: () => Destroyable | null; + getImmersionTracker: () => { destroy: () => void | Promise } | null; + stopStatsServer: () => Promise | void; clearImmersionTracker: () => void; getAnkiIntegration: () => Destroyable | null; @@ -120,10 +121,11 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { destroySubtitleTimingTracker: () => { deps.getSubtitleTimingTracker()?.destroy(); }, - destroyImmersionTracker: () => { + stopStatsServer: () => deps.stopStatsServer(), + destroyImmersionTracker: async () => { const tracker = deps.getImmersionTracker(); if (!tracker) return; - tracker.destroy(); + await tracker.destroy(); deps.clearImmersionTracker(); }, destroyAnkiIntegration: () => { diff --git a/src/main/runtime/background-stats-startup.test.ts b/src/main/runtime/background-stats-startup.test.ts index a2477a7f..c3a5c557 100644 --- a/src/main/runtime/background-stats-startup.test.ts +++ b/src/main/runtime/background-stats-startup.test.ts @@ -24,10 +24,10 @@ function createDeps( return { deps, calls }; } -test('ensures background stats server and logs local startup', () => { +test('ensures background stats server and logs local startup', async () => { const { deps, calls } = createDeps(); - createEnsureBackgroundStatsServerHandler(deps)(); + await createEnsureBackgroundStatsServerHandler(deps)(); assert.ok(calls.includes('ensureBackgroundStatsServerStarted')); assert.ok( @@ -35,7 +35,7 @@ test('ensures background stats server and logs local startup', () => { ); }); -test('logs reuse when a background stats server is already running', () => { +test('logs reuse when a background stats server is already running', async () => { const { deps, calls } = createDeps({ ensureBackgroundStatsServerStarted: () => ({ url: 'http://127.0.0.1:3888', @@ -43,36 +43,53 @@ test('logs reuse when a background stats server is already running', () => { }), }); - createEnsureBackgroundStatsServerHandler(deps)(); + await createEnsureBackgroundStatsServerHandler(deps)(); assert.ok( calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)), ); }); -test('skips when stats.autoStartServer is disabled', () => { +test('skips when stats.autoStartServer is disabled', async () => { const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false }); - createEnsureBackgroundStatsServerHandler(deps)(); + await createEnsureBackgroundStatsServerHandler(deps)(); assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false); }); -test('skips when immersion tracking is disabled', () => { +test('skips when immersion tracking is disabled', async () => { const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false }); - createEnsureBackgroundStatsServerHandler(deps)(); + await createEnsureBackgroundStatsServerHandler(deps)(); assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false); }); -test('logs a warning instead of throwing when startup fails', () => { +test('logs a warning instead of throwing when startup fails', async () => { const { deps, calls } = createDeps({ ensureBackgroundStatsServerStarted: () => { throw new Error('port in use'); }, }); - assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)()); + await assert.doesNotReject(createEnsureBackgroundStatsServerHandler(deps)()); assert.ok(calls.some((value) => value.startsWith('warn:'))); }); + +test('logs an asynchronously reported startup failure', async () => { + const { deps, calls } = createDeps({ + ensureBackgroundStatsServerStarted: async () => { + await Promise.resolve(); + throw new Error('address in use'); + }, + }); + + await createEnsureBackgroundStatsServerHandler(deps)(); + + assert.ok(calls.some((value) => value.startsWith('warn:'))); + assert.equal( + calls.some((value) => value.startsWith('info:')), + false, + ); +}); diff --git a/src/main/runtime/background-stats-startup.ts b/src/main/runtime/background-stats-startup.ts index 3cdd5754..b03c0ad3 100644 --- a/src/main/runtime/background-stats-startup.ts +++ b/src/main/runtime/background-stats-startup.ts @@ -1,18 +1,23 @@ export interface EnsureBackgroundStatsServerDeps { isStatsAutoStartEnabled: () => boolean; isImmersionTrackingEnabled: () => boolean; - ensureBackgroundStatsServerStarted: () => { - url: string; - runningInCurrentProcess: boolean; - }; + ensureBackgroundStatsServerStarted: () => + | Promise<{ + url: string; + runningInCurrentProcess: boolean; + }> + | { + url: string; + runningInCurrentProcess: boolean; + }; logInfo: (message: string) => void; logWarn: (message: string, error?: unknown) => void; } export function createEnsureBackgroundStatsServerHandler( deps: EnsureBackgroundStatsServerDeps, -): () => void { - return () => { +): () => Promise { + return async () => { if (!deps.isStatsAutoStartEnabled()) { deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.'); return; @@ -22,7 +27,7 @@ export function createEnsureBackgroundStatsServerHandler( return; } try { - const result = deps.ensureBackgroundStatsServerStarted(); + const result = await deps.ensureBackgroundStatsServerStarted(); deps.logInfo( result.runningInCurrentProcess ? `Background start: stats server started at ${result.url}.` diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 6c31e386..a1c484a4 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -38,6 +38,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler clearReconnectTimerRef: () => {}, getSubtitleTimingTracker: () => null, getImmersionTracker: () => null, + stopStatsServer: () => {}, clearImmersionTracker: () => {}, getAnkiIntegration: () => null, getAnilistSetupWindow: () => null, diff --git a/src/main/runtime/composers/startup-lifecycle-composer.ts b/src/main/runtime/composers/startup-lifecycle-composer.ts index 07a15b8d..e8e95059 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.ts @@ -32,7 +32,7 @@ export type StartupLifecycleComposerOptions = ComposerInputs<{ export type StartupLifecycleComposerResult = ComposerOutputs<{ registerProtocolUrlHandlers: () => void; - onWillQuitCleanup: () => void; + onWillQuitCleanup: () => Promise; shouldRestoreWindowsOnActivate: () => boolean; restoreWindowsOnActivate: () => void; }>; diff --git a/src/main/runtime/stats-cli-command.ts b/src/main/runtime/stats-cli-command.ts index 955f7b2c..cb1d9cf8 100644 --- a/src/main/runtime/stats-cli-command.ts +++ b/src/main/runtime/stats-cli-command.ts @@ -57,8 +57,10 @@ export function createRunStatsCliCommandHandler(deps: { }) => Promise; rebuildLifetimeSummaries?: () => Promise; } | null; - ensureStatsServerStarted: () => string; - ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult; + ensureStatsServerStarted: () => Promise | string; + ensureBackgroundStatsServerStarted: () => + | Promise + | BackgroundStatsStartResult; stopBackgroundStatsServer: () => Promise | BackgroundStatsStopResult; openExternal: (url: string) => Promise; writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void; @@ -115,7 +117,7 @@ export function createRunStatsCliCommandHandler(deps: { } if (args.statsBackground) { - const result = deps.ensureBackgroundStatsServerStarted(); + const result = await deps.ensureBackgroundStatsServerStarted(); deps.logInfo(`Stats dashboard available at ${result.url}`); writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url }); if (!result.runningInCurrentProcess && source === 'initial') { @@ -183,7 +185,7 @@ export function createRunStatsCliCommandHandler(deps: { return; } - const url = deps.ensureStatsServerStarted(); + const url = await deps.ensureStatsServerStarted(); if (config.stats.autoOpenBrowser !== false) { await deps.openExternal(url); } diff --git a/src/main/runtime/stats-server-routing.test.ts b/src/main/runtime/stats-server-routing.test.ts index 496b3f30..c7e5b13e 100644 --- a/src/main/runtime/stats-server-routing.test.ts +++ b/src/main/runtime/stats-server-routing.test.ts @@ -23,7 +23,7 @@ function createHarness(options?: { return options?.processAlive ?? true; }, hasLocalStatsServer: () => localServerStarted, - startLocalStatsServer: () => { + startLocalStatsServer: async () => { calls.push('startLocalStatsServer'); localServerStarted = true; }, @@ -36,23 +36,23 @@ function createHarness(options?: { }; } -test('stats server routing defers to a live background daemon from another process', () => { +test('stats server routing defers to a live background daemon from another process', async () => { const { calls, handler } = createHarness({ state: { pid: 200, port: 7979, startedAtMs: 1 }, processAlive: true, }); - assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'background' }); + assert.deepEqual(await handler(), { url: 'http://127.0.0.1:7979', source: 'background' }); assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']); }); -test('stats server routing clears dead daemon state and starts local server', () => { +test('stats server routing clears dead daemon state and starts local server', async () => { const { calls, handler } = createHarness({ state: { pid: 200, port: 7979, startedAtMs: 1 }, processAlive: false, }); - assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); + assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); assert.deepEqual(calls, [ 'readBackgroundState', 'isProcessAlive', @@ -61,13 +61,13 @@ test('stats server routing clears dead daemon state and starts local server', () ]); }); -test('stats server routing clears self-owned stale state and starts local server', () => { +test('stats server routing clears self-owned stale state and starts local server', async () => { const { calls, handler } = createHarness({ state: { pid: 100, port: 7979, startedAtMs: 1 }, processAlive: true, }); - assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); + assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); assert.deepEqual(calls, [ 'readBackgroundState', 'removeBackgroundState', @@ -75,12 +75,12 @@ test('stats server routing clears self-owned stale state and starts local server ]); }); -test('stats server routing reuses a started local stats server', () => { +test('stats server routing reuses a started local stats server', async () => { const { calls, handler } = createHarness({ state: null, localServerStarted: true, }); - assert.deepEqual(handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); + assert.deepEqual(await handler(), { url: 'http://127.0.0.1:6969', source: 'local' }); assert.deepEqual(calls, ['readBackgroundState', 'removeBackgroundState']); }); diff --git a/src/main/runtime/stats-server-routing.ts b/src/main/runtime/stats-server-routing.ts index b2a42149..1fa0bb2e 100644 --- a/src/main/runtime/stats-server-routing.ts +++ b/src/main/runtime/stats-server-routing.ts @@ -6,7 +6,7 @@ type EnsureStatsServerUrlDeps = { removeBackgroundState: () => void; isProcessAlive: (pid: number) => boolean; hasLocalStatsServer: () => boolean; - startLocalStatsServer: () => void; + startLocalStatsServer: () => Promise; getConfiguredPort: () => number; }; @@ -18,8 +18,8 @@ export type EnsureStatsServerUrlResult = { url: string; source: 'background' | ' export function createEnsureStatsServerUrlHandler( deps: EnsureStatsServerUrlDeps, -): () => EnsureStatsServerUrlResult { - return () => { +): () => Promise { + return async () => { const state = deps.readBackgroundState(); if (!state) { deps.removeBackgroundState(); @@ -32,7 +32,7 @@ export function createEnsureStatsServerUrlHandler( } if (!deps.hasLocalStatsServer()) { - deps.startLocalStatsServer(); + await deps.startLocalStatsServer(); } return { url: formatStatsServerUrl(deps.getConfiguredPort()), source: 'local' }; }; diff --git a/src/main/runtime/stats-server-runtime.test.ts b/src/main/runtime/stats-server-runtime.test.ts index 060c17e9..92f4dee0 100644 --- a/src/main/runtime/stats-server-runtime.test.ts +++ b/src/main/runtime/stats-server-runtime.test.ts @@ -1,10 +1,77 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import test, { after } from 'node:test'; +import { DEFAULT_CONFIG } from '../../config'; +import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service'; +import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter'; import { createStatsServerRuntime, isSelfOwnedBackgroundStatsDaemonState, - shouldClearAppStateStatsServerOnStop, + type StatsServerRuntimeDeps, } from './stats-server-runtime'; +import type { StatsServer } from '../../core/services/stats-server'; +import type { BackgroundStatsServerState } from './stats-daemon'; + +function createDeferred() { + let settle: ((value: T) => void) | null = null; + let fail: ((error: unknown) => void) | null = null; + const promise = new Promise((resolve, reject) => { + settle = resolve; + fail = reject; + }); + return { + promise, + resolve(value: T): void { + if (!settle) throw new Error('deferred promise is unavailable'); + settle(value); + }, + reject(error: unknown): void { + if (!fail) throw new Error('deferred promise is unavailable'); + fail(error); + }, + }; +} + +function createRuntimeHarness( + startServer: NonNullable, + backgroundState: BackgroundStatsServerState | null = null, +) { + const appStateValues: Array = []; + const tracker = new ImmersionTrackerService({ dbPath: ':memory:' }); + after(() => tracker.destroy()); + const runtime = createStatsServerRuntime({ + userDataPath: '/tmp/subminer-stats-runtime-test', + statsDistPath: '/tmp/stats-dist', + getResolvedConfig: () => ({ + ...DEFAULT_CONFIG, + stats: { ...DEFAULT_CONFIG.stats, serverPort: 5175 }, + }), + getImmersionTracker: () => tracker, + setAppStateStatsServer: (server) => { + appStateValues.push(server); + }, + getMpvSocketPath: () => '/tmp/mpv.sock', + getYomitanExt: () => null, + getYomitanSession: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + getYomitanAnkiDeckName: async () => 'Mining', + getAnilistRateLimiter: () => createAnilistRateLimiter(), + resolveAnkiNoteId: (noteId) => noteId, + trackDuplicateNoteIdsForNote: () => {}, + resolveSentenceSearchHeadwords: async () => [], + ensureImmersionTrackerStarted: () => {}, + setStatsStartupInProgress: () => {}, + readBackgroundStatsServerState: () => backgroundState, + removeBackgroundStatsServerState: () => {}, + isBackgroundStatsServerProcessAlive: () => false, + startServer, + }); + return { runtime, appStateValues }; +} test('detects self-owned background stats daemon state', () => { assert.equal( @@ -13,10 +80,6 @@ test('detects self-owned background stats daemon state', () => { ); }); -test('stats server app-state reference should be cleared after private server stop', () => { - assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true); -}); - test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => { const calls: string[] = []; const runtime = createStatsServerRuntime({ @@ -57,3 +120,157 @@ test('stopBackgroundStatsServer clears stale state when daemon identity mismatch assert.deepEqual(result, { ok: true, stale: true }); assert.deepEqual(calls, ['removeBackgroundStatsServerState']); }); + +test('concurrent stats startup requests share one pending server', async () => { + const deferred = createDeferred(); + let startCalls = 0; + const server: StatsServer = { close: async () => {} }; + const { runtime, appStateValues } = createRuntimeHarness(() => { + startCalls += 1; + return deferred.promise; + }); + + const first = runtime.ensureStatsServerStarted(); + const second = runtime.ensureStatsServerStarted(); + assert.equal(startCalls, 1); + assert.deepEqual(appStateValues, []); + + deferred.resolve(server); + assert.deepEqual(await Promise.all([first, second]), [ + { url: 'http://127.0.0.1:5175', source: 'local' }, + { url: 'http://127.0.0.1:5175', source: 'local' }, + ]); + assert.deepEqual(appStateValues, [server]); +}); + +test('failed stats startup remains recoverable on the next request', async () => { + const first = createDeferred(); + const second = createDeferred(); + const attempts = [first, second]; + let startCalls = 0; + const server: StatsServer = { close: async () => {} }; + const { runtime, appStateValues } = createRuntimeHarness(() => { + const attempt = attempts[startCalls]; + startCalls += 1; + if (!attempt) throw new Error('unexpected startup attempt'); + return attempt.promise; + }); + + const failedStartup = runtime.ensureStatsServerStarted(); + first.reject(Object.assign(new Error('address in use'), { code: 'EADDRINUSE' })); + await assert.rejects(failedStartup, /address in use/); + + const retry = runtime.ensureStatsServerStarted(); + second.resolve(server); + assert.deepEqual(await retry, { url: 'http://127.0.0.1:5175', source: 'local' }); + assert.equal(startCalls, 2); + assert.deepEqual(appStateValues, [null, server]); +}); + +test('shutdown cancels pending startup and closes the late server', async () => { + const deferred = createDeferred(); + let closeCalls = 0; + const server: StatsServer = { + close: async () => { + closeCalls += 1; + }, + }; + const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise); + + const startup = runtime.ensureStatsServerStarted(); + const shutdown = runtime.stopStatsServer(); + deferred.resolve(server); + + await assert.rejects(startup, /startup was cancelled/); + await shutdown; + assert.equal(closeCalls, 1); + assert.deepEqual(appStateValues, [null, null]); +}); + +test('stopping a self-owned background server closes its local handle', async () => { + let closeCalls = 0; + const server: StatsServer = { + close: async () => { + closeCalls += 1; + }, + }; + const { runtime } = createRuntimeHarness(async () => server, { + pid: process.pid, + port: 5175, + startedAtMs: 1, + }); + await runtime.ensureStatsServerStarted(); + + assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: false }); + assert.equal(closeCalls, 1); +}); + +test('background stop leaves a foreground-only server available', async () => { + let closeCalls = 0; + let startCalls = 0; + const { runtime } = createRuntimeHarness(async () => { + startCalls += 1; + return { + close: async () => { + closeCalls += 1; + }, + }; + }); + const foreground = await runtime.ensureStatsServerStarted(); + assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true }); + assert.equal(closeCalls, 0); + assert.deepEqual(await runtime.ensureStatsServerStarted(), foreground); + assert.equal(startCalls, 1); + await runtime.stopStatsServer(); +}); + +test('background stop leaves a pending foreground-only startup alone', async () => { + const deferred = createDeferred(); + const { runtime } = createRuntimeHarness(() => deferred.promise); + const startup = runtime.ensureStatsServerStarted(); + assert.deepEqual(await runtime.stopBackgroundStatsServer(), { ok: true, stale: true }); + deferred.resolve({ close: async () => {} }); + assert.deepEqual(await startup, { url: 'http://127.0.0.1:5175', source: 'local' }); + await runtime.stopStatsServer(); +}); + +test('a startup requested during shutdown waits and then restarts', async () => { + const closeDeferred = createDeferred(); + const firstServer: StatsServer = { close: () => closeDeferred.promise }; + const secondServer: StatsServer = { close: async () => {} }; + const servers = [firstServer, secondServer]; + let startCalls = 0; + const { runtime } = createRuntimeHarness(async () => { + const server = servers[startCalls]; + startCalls += 1; + if (!server) throw new Error('unexpected startup attempt'); + return server; + }); + await runtime.ensureStatsServerStarted(); + + const shutdown = runtime.stopStatsServer(); + const restart = runtime.ensureStatsServerStarted(); + assert.equal(startCalls, 1); + + closeDeferred.resolve(); + await shutdown; + assert.deepEqual(await restart, { url: 'http://127.0.0.1:5175', source: 'local' }); + assert.equal(startCalls, 2); +}); + +test('background stop cancels startup before daemon ownership is published', async () => { + const deferred = createDeferred(); + let closeCalls = 0; + const { runtime, appStateValues } = createRuntimeHarness(() => deferred.promise); + const startup = runtime.ensureBackgroundStatsServerStarted(); + const shutdown = runtime.stopBackgroundStatsServer(); + deferred.resolve({ + close: async () => { + closeCalls += 1; + }, + }); + await assert.rejects(startup, /startup was cancelled/); + assert.deepEqual(await shutdown, { ok: true, stale: false }); + assert.equal(closeCalls, 1); + assert.equal(appStateValues.at(-1), null); +}); diff --git a/src/main/runtime/stats-server-runtime.ts b/src/main/runtime/stats-server-runtime.ts index dfe2a681..ee1aaf2e 100644 --- a/src/main/runtime/stats-server-runtime.ts +++ b/src/main/runtime/stats-server-runtime.ts @@ -4,7 +4,7 @@ import { addYomitanNoteViaSearch, syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, } from '../../core/services'; -import { startStatsServer } from '../../core/services/stats-server'; +import { startStatsServer, type StatsServer } from '../../core/services/stats-server'; import { createLogger } from '../../logger'; import type { ResolvedConfig } from '../../types/config'; import type { AppState } from '../state'; @@ -27,12 +27,6 @@ export function isSelfOwnedBackgroundStatsDaemonState(state: { return state.pid === process.pid; } -export function shouldClearAppStateStatsServerOnStop(options: { - hadStatsServer: boolean; -}): boolean { - return options.hadStatsServer; -} - export interface StatsServerRuntimeDeps { userDataPath: string; statsDistPath: string; @@ -62,19 +56,28 @@ export interface StatsServerRuntimeDeps { isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive; verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity; killProcess?: (pid: number, signal: NodeJS.Signals) => void; + startServer?: typeof startStatsServer; } export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { - stopStatsServer: () => void; + stopStatsServer: () => Promise; ensureStatsServerStarted: ReturnType; - ensureBackgroundStatsServerStarted: () => { + ensureBackgroundStatsServerStarted: () => Promise<{ url: string; runningInCurrentProcess: boolean; - }; + }>; stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>; } { - let statsServer: ReturnType | null = null; + type LocalStatsServerState = + | { kind: 'stopped' } + | { kind: 'starting'; token: symbol; promise: Promise } + | { kind: 'running'; server: StatsServer } + | { kind: 'stopping'; token: symbol; promise: Promise }; + + let localStatsServerState: LocalStatsServerState = { kind: 'stopped' }; + const pendingBackgroundStarts = new Set(); const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json'); + const startServer = deps.startServer ?? startStatsServer; const readDaemonState = deps.readBackgroundStatsServerState ?? ((statePath: string) => defaultReadBackgroundStatsServerState(statePath)); @@ -100,7 +103,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { removeDaemonState(statsDaemonStatePath); return null; } - if (state.pid === process.pid && !statsServer) { + if (state.pid === process.pid && localStatsServerState.kind !== 'running') { removeDaemonState(statsDaemonStatePath); return null; } @@ -118,74 +121,134 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { } } - function stopStatsServer(): void { - if (!statsServer) { - return; - } - statsServer.close(); - statsServer = null; - if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) { - deps.setAppStateStatsServer(null); - } - clearOwnedBackgroundStatsDaemonState(); - } - - const startLocalStatsServer = (): void => { + const buildStatsServerConfig = (): Parameters[0] => { const tracker = deps.getImmersionTracker(); if (!tracker) { throw new Error('Immersion tracker failed to initialize.'); } - if (!statsServer) { - const yomitanDeps = { - getYomitanExt: () => deps.getYomitanExt(), - getYomitanSession: () => deps.getYomitanSession(), - getYomitanParserWindow: () => deps.getYomitanParserWindow(), - setYomitanParserWindow: (w: BrowserWindow | null) => { - deps.setYomitanParserWindow(w); - }, - getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), - setYomitanParserReadyPromise: (p: Promise | null) => { - deps.setYomitanParserReadyPromise(p); - }, - getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(), - setYomitanParserInitPromise: (p: Promise | null) => { - deps.setYomitanParserInitPromise(p); - }, - }; - const yomitanLogger = createLogger('main:yomitan-stats'); - statsServer = startStatsServer({ - port: deps.getResolvedConfig().stats.serverPort, - staticDir: deps.statsDistPath, - tracker, - knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'), - mpvSocketPath: deps.getMpvSocketPath(), - getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect, - getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName, - getSecondarySubtitleLanguages: () => - deps.getResolvedConfig().secondarySub.secondarySubLanguages, - getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path, - anilistRateLimiter: deps.getAnilistRateLimiter(), - resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId), - resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term), - addYomitanNote: async (word: string) => { - const ankiConnectConfig = deps.getResolvedConfig().ankiConnect; - const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765'; - await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { - forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig), - deck: ankiConnectConfig.deck, - }); - const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); - if (result.noteId && result.duplicateNoteIds.length > 0) { - deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds); - } - return result.noteId; - }, - }); - deps.setAppStateStatsServer(statsServer); - } - deps.setAppStateStatsServer(statsServer); + const yomitanDeps = { + getYomitanExt: () => deps.getYomitanExt(), + getYomitanSession: () => deps.getYomitanSession(), + getYomitanParserWindow: () => deps.getYomitanParserWindow(), + setYomitanParserWindow: (w: BrowserWindow | null) => { + deps.setYomitanParserWindow(w); + }, + getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), + setYomitanParserReadyPromise: (p: Promise | null) => { + deps.setYomitanParserReadyPromise(p); + }, + getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(), + setYomitanParserInitPromise: (p: Promise | null) => { + deps.setYomitanParserInitPromise(p); + }, + }; + const yomitanLogger = createLogger('main:yomitan-stats'); + return { + port: deps.getResolvedConfig().stats.serverPort, + staticDir: deps.statsDistPath, + tracker, + knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'), + mpvSocketPath: deps.getMpvSocketPath(), + getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect, + getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName, + getSecondarySubtitleLanguages: () => + deps.getResolvedConfig().secondarySub.secondarySubLanguages, + getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path, + anilistRateLimiter: deps.getAnilistRateLimiter(), + resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId), + resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term), + addYomitanNote: async (word: string) => { + const ankiConnectConfig = deps.getResolvedConfig().ankiConnect; + const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765'; + await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { + forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig), + deck: ankiConnectConfig.deck, + }); + const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); + if (result.noteId && result.duplicateNoteIds.length > 0) { + deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds); + } + return result.noteId; + }, + }; }; + const beginLocalStatsServerStartup = (): Promise => { + const token = Symbol('stats-server-startup'); + const promise = startServer(buildStatsServerConfig()) + .then(async (server) => { + const state = localStatsServerState; + if (state.kind !== 'starting' || state.token !== token) { + await server.close(); + throw new Error('Stats server startup was cancelled.'); + } + localStatsServerState = { kind: 'running', server }; + deps.setAppStateStatsServer(server); + }) + .catch((error: unknown) => { + const state = localStatsServerState; + if (state.kind === 'starting' && state.token === token) { + localStatsServerState = { kind: 'stopped' }; + deps.setAppStateStatsServer(null); + } + throw error; + }); + localStatsServerState = { kind: 'starting', token, promise }; + return promise; + }; + + const startLocalStatsServer = async (): Promise => { + while (localStatsServerState.kind === 'stopping') { + await localStatsServerState.promise; + } + if (localStatsServerState.kind === 'running') { + deps.setAppStateStatsServer(localStatsServerState.server); + return; + } + if (localStatsServerState.kind === 'starting') { + await localStatsServerState.promise; + return; + } + await beginLocalStatsServerStartup(); + }; + + function stopStatsServer(): Promise { + const state = localStatsServerState; + if (state.kind === 'stopped') { + deps.setAppStateStatsServer(null); + clearOwnedBackgroundStatsDaemonState(); + return Promise.resolve(); + } + if (state.kind === 'stopping') { + return state.promise; + } + + const token = Symbol('stats-server-shutdown'); + const promise = Promise.resolve() + .then(async () => { + if (state.kind === 'starting') { + try { + await state.promise; + } catch { + // Startup owns cleanup of a server that finishes binding after cancellation. + } + return; + } + await state.server.close(); + }) + .finally(() => { + const current = localStatsServerState; + if (current.kind === 'stopping' && current.token === token) { + localStatsServerState = { kind: 'stopped' }; + } + deps.setAppStateStatsServer(null); + clearOwnedBackgroundStatsDaemonState(); + }); + localStatsServerState = { kind: 'stopping', token, promise }; + deps.setAppStateStatsServer(null); + return promise; + } + const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({ currentPid: process.pid, readBackgroundState: () => readDaemonState(statsDaemonStatePath), @@ -193,15 +256,15 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { removeDaemonState(statsDaemonStatePath); }, isProcessAlive: (pid) => isDaemonAlive(pid), - hasLocalStatsServer: () => statsServer !== null, + hasLocalStatsServer: () => localStatsServerState.kind === 'running', startLocalStatsServer, getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort, }); - const ensureBackgroundStatsServerStarted = (): { + const ensureBackgroundStatsServerStarted = async (): Promise<{ url: string; runningInCurrentProcess: boolean; - } => { + }> => { const liveDaemon = readLiveBackgroundStatsDaemonState(); if (liveDaemon && liveDaemon.pid !== process.pid) { return { @@ -217,27 +280,40 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { deps.setStatsStartupInProgress(false); } - const port = deps.getResolvedConfig().stats.serverPort; - const result = ensureStatsServerStarted(); - if (result.source === 'local') { - writeBackgroundStatsServerState(statsDaemonStatePath, { - pid: process.pid, - port, - startedAtMs: Date.now(), - }); + const request = Symbol('background-stats-startup'); + pendingBackgroundStarts.add(request); + try { + const port = deps.getResolvedConfig().stats.serverPort; + const result = await ensureStatsServerStarted(); + if (result.source === 'local') { + if (localStatsServerState.kind !== 'running') { + throw new Error('Stats server startup was cancelled.'); + } + writeBackgroundStatsServerState(statsDaemonStatePath, { + pid: process.pid, + port, + startedAtMs: Date.now(), + }); + } + return { url: result.url, runningInCurrentProcess: result.source === 'local' }; + } finally { + pendingBackgroundStarts.delete(request); } - return { url: result.url, runningInCurrentProcess: result.source === 'local' }; }; const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { const state = readDaemonState(statsDaemonStatePath); if (!state) { + if (pendingBackgroundStarts.size > 0) { + await stopStatsServer(); + return { ok: true, stale: false }; + } removeDaemonState(statsDaemonStatePath); return { ok: true, stale: true }; } if (isSelfOwnedBackgroundStatsDaemonState(state)) { - removeDaemonState(statsDaemonStatePath); - return { ok: true, stale: true }; + await stopStatsServer(); + return { ok: true, stale: false }; } if (!isDaemonAlive(state.pid)) { removeDaemonState(statsDaemonStatePath); diff --git a/src/main/state.ts b/src/main/state.ts index 728a1603..e9a963dd 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -206,7 +206,7 @@ export interface AppState { anilistSetupPageOpened: boolean; anilistRetryQueueState: AnilistRetryQueueState; firstRunSetupCompleted: boolean; - statsServer: { close: () => void } | null; + statsServer: { close: () => Promise } | null; statsStartupInProgress: boolean; } diff --git a/src/quality-gate-workflow.test.ts b/src/quality-gate-workflow.test.ts index e6056d28..cd99a7c2 100644 --- a/src/quality-gate-workflow.test.ts +++ b/src/quality-gate-workflow.test.ts @@ -22,7 +22,7 @@ test('quality gate checkout does not persist GitHub credentials', () => { ); }); -test('quality gate installs Lua and runs the environment suite before coverage', () => { +test('quality gate runs non-covered source suites and lets coverage gate the src lane', () => { assert.match(qualityGateWorkflow, /name: Install Lua/); assert.match( qualityGateWorkflow, @@ -32,7 +32,18 @@ test('quality gate installs Lua and runs the environment suite before coverage', assert.match(qualityGateWorkflow, /apt-get\s+"\$\{apt_sources\[@\]\}"\s+install\s+-y\s+lua5\.4/); assert.match( qualityGateWorkflow, - /Test suite \(source\)\n\s*run: bun run test:fast\n\s*\n\s*- name: Environment suite\n\s*run: bun run test:env\n\s*\n\s*- name: Coverage suite \(maintained source lane\)/, + /Launcher unit and script suites\n\s*run: bun run test:launcher:unit:src && bun run test:scripts/, + ); + assert.doesNotMatch(qualityGateWorkflow, /bun run test:fast/); + assert.match(qualityGateWorkflow, /run: bun run test:coverage:src/); +}); + +test('quality gate runs launcher smoke once through the environment suite and keeps artifacts', () => { + assert.match(qualityGateWorkflow, /name: Environment suite\n\s*run: bun run test:env/); + assert.doesNotMatch(qualityGateWorkflow, /run: bun run test:launcher:smoke:src/); + assert.match( + qualityGateWorkflow, + /name: Upload launcher smoke artifacts \(on failure\)[\s\S]*?if: failure\(\)[\s\S]*?path: \.tmp\/launcher-smoke\/\*\*/, ); }); @@ -42,6 +53,13 @@ test('quality gate uploads maintained source coverage', () => { assert.match(qualityGateWorkflow, /path: coverage\/test-src\/lcov\.info/); }); +test('quality gate preserves stats, compiled SQLite, and dist runtime checks', () => { + assert.match(qualityGateWorkflow, /run: bun run test:stats/); + assert.match(qualityGateWorkflow, /run: bun run build/); + assert.match(qualityGateWorkflow, /run: bun run test:immersion:sqlite:dist/); + assert.match(qualityGateWorkflow, /run: bun run test:smoke:dist/); +}); + test('quality gate keeps pull request changelog enforcement event-aware', () => { assert.match(qualityGateWorkflow, /bun run changelog:lint/); assert.match(qualityGateWorkflow, /if: github\.event_name == 'pull_request'/); diff --git a/src/stats-daemon-runner.ts b/src/stats-daemon-runner.ts index 2e0f77e2..0e59fafa 100644 --- a/src/stats-daemon-runner.ts +++ b/src/stats-daemon-runner.ts @@ -127,7 +127,8 @@ const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js'); let tracker: ImmersionTrackerService | null = null; -let statsServer: ReturnType | null = null; +let statsServer: Awaited> | null = null; +let shutdownPromise: Promise | null = null; function writeFailureResponse(message: string): void { if (!responsePath) return; @@ -147,25 +148,32 @@ function clearOwnedState(): void { } } -function shutdown(code = 0): void { - try { - statsServer?.close(); - } catch { - // ignore - } - statsServer = null; - try { - tracker?.destroy(); - } catch { - // ignore - } - tracker = null; - clearOwnedState(); - process.exit(code); +function shutdown(code = 0): Promise { + shutdownPromise ??= (async () => { + try { + await statsServer?.close(); + } catch { + // ignore + } + statsServer = null; + try { + await tracker?.destroy(); + } catch { + // ignore + } + tracker = null; + clearOwnedState(); + process.exit(code); + })(); + return shutdownPromise; } -process.on('SIGINT', () => shutdown(0)); -process.on('SIGTERM', () => shutdown(0)); +process.on('SIGINT', () => { + void shutdown(0); +}); +process.on('SIGTERM', () => { + void shutdown(0); +}); async function main(): Promise { try { @@ -198,7 +206,7 @@ async function main(): Promise { createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')), ); - statsServer = startStatsServer({ + statsServer = await startStatsServer({ port: config.stats.serverPort, staticDir: statsDistPath, tracker, @@ -237,7 +245,7 @@ async function main(): Promise { const message = error instanceof Error ? error.message : String(error); logger.error('Failed to start stats daemon', message); writeFailureResponse(message); - shutdown(1); + await shutdown(1); } } From 2f21582666c14b1890a09d2b1e4924eebad7fd5c Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 23:35:48 -0700 Subject: [PATCH 6/9] fix(overlay): cancel pending window transitions and timing reviews (#262) --- changes/fix-timing-review-cancellation.md | 4 + changes/runtime-ownership.md | 4 + docs-site/anki-integration.md | 2 + docs/architecture/README.md | 1 + src/main.ts | 138 +++-------- src/main/main-wiring.test.ts | 9 +- .../composers/startup-lifecycle-composer.ts | 17 +- src/main/runtime/domains/anilist.ts | 1 - .../linux-overlay-mode-runtime.test.ts | 93 ++++++++ .../runtime/linux-overlay-mode-runtime.ts | 94 ++++++++ src/main/runtime/media-timing-review-open.ts | 2 + src/main/runtime/media-timing-review.test.ts | 215 ++++++++++++++++++ src/main/runtime/media-timing-review.ts | 157 ++++++++++--- .../runtime/overlay-hosted-modal-open.test.ts | 73 +++++- src/main/runtime/overlay-hosted-modal-open.ts | 12 +- .../protocol-url-handlers-main-deps.test.ts | 29 --- .../protocol-url-handlers-main-deps.ts | 16 -- 17 files changed, 668 insertions(+), 199 deletions(-) create mode 100644 changes/fix-timing-review-cancellation.md create mode 100644 changes/runtime-ownership.md create mode 100644 src/main/runtime/linux-overlay-mode-runtime.test.ts create mode 100644 src/main/runtime/linux-overlay-mode-runtime.ts delete mode 100644 src/main/runtime/protocol-url-handlers-main-deps.test.ts delete mode 100644 src/main/runtime/protocol-url-handlers-main-deps.ts diff --git a/changes/fix-timing-review-cancellation.md b/changes/fix-timing-review-cancellation.md new file mode 100644 index 00000000..ffd18909 --- /dev/null +++ b/changes/fix-timing-review-cancellation.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player. diff --git a/changes/runtime-ownership.md b/changes/runtime-ownership.md new file mode 100644 index 00000000..5182284b --- /dev/null +++ b/changes/runtime-ownership.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index ee88db45..d6812179 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -214,6 +214,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session. +If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens. + ### Screenshots (static) A single frame is captured at the current playback position. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 795ed49c..1f65d433 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -30,6 +30,7 @@ Update checks and startup launcher migration share a serialized update-state sto - `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters. - `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection. +- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative. - Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments. diff --git a/src/main.ts b/src/main.ts index a9be66f0..c3c60dd9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -49,10 +49,7 @@ import { clearLinuxMpvFullscreenOverlayRefreshTimeouts, updateLinuxMpvFullscreenOverlayRefreshBurst, } from './main/runtime/linux-mpv-fullscreen-overlay-refresh'; -import { - resolveLinuxVisibleOverlayWindowModeAction, - type LinuxVisibleOverlayWindowMode, -} from './main/runtime/linux-visible-overlay-window-mode'; +import { createLinuxOverlayModeRuntime } from './main/runtime/linux-overlay-mode-runtime'; import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive'; import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus'; import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff'; @@ -1982,11 +1979,27 @@ let lastObservedTimePos = 0; let lastObservedPrimarySubtitleTrackId: number | null = null; let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = null; -let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed'; -let linuxTrackedMpvFullscreen = false; -let linuxTrackedMpvFullscreenChangedAtMs = 0; -let linuxVisibleOverlayOwnerBindingKey: string | null = null; -let linuxVisibleOverlayWindowModeSwitchToken = 0; +const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({ + isEnabled: shouldRunLinuxOverlayZOrderKeepAlive, + isVisible: () => overlayManager.getVisibleOverlayVisible(), + getWindow: () => overlayManager.getMainWindow(), + clearWindow: () => overlayManager.setMainWindow(null), + createWindow: () => { + visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); + createMainWindow(); + }, + refreshWindow: () => { + const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry(); + if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry); + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + void ensureOverlayMpvSubtitlesHidden(); + if (appState.currentSubText.trim()) { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + } + }, + now: Date.now, + logDebug: (message) => logger.debug(message), +}); let subtitleSidebarRequestedOpen = false; const SEEK_THRESHOLD_SECONDS = 3; const EXPLICIT_SEEK_INTENT_TTL_MS = 2000; @@ -2746,7 +2759,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( }, hideNonNativeOverlayWhenTargetUnfocused: () => shouldRunLinuxOverlayZOrderKeepAlive() && - linuxVisibleOverlayWindowMode === 'fullscreen-override', + linuxOverlayModeRuntime.mode === 'fullscreen-override', resolveFallbackBounds: () => { const cursorPoint = screen.getCursorScreenPoint(); const display = screen.getDisplayNearestPoint(cursorPoint); @@ -2792,9 +2805,9 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime( getBackendOverride: () => appState.backendOverride, getInitialArgs: () => appState.initialArgs, getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode, + getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode, setLinuxVisibleOverlayOwnerBindingKey: (key) => { - linuxVisibleOverlayOwnerBindingKey = key; + linuxOverlayModeRuntime.ownerBindingKey = key; }, bindVisibleOverlayToTrackedX11Window: (window) => overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window), @@ -2952,7 +2965,8 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ startTime: range.startTime, endTime: range.endTime, }), - openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload), + openModal: (payload, signal) => + openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload, signal), onPreviewEnded: (reviewId) => { // The review may live in either overlay window; the renderer ignores foreign review ids. for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) { @@ -3989,6 +4003,7 @@ const { clearWindowsVisibleOverlayForegroundPollLoop: () => visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(), clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { + linuxOverlayModeRuntime.cancelPendingTransition(); cancelLinuxMpvFullscreenOverlayRefreshBurst = null; clearLinuxMpvFullscreenOverlayRefreshTimeouts(); }, @@ -4709,7 +4724,7 @@ const { }, overlayVisibilityRuntime, syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) => - syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen), + linuxOverlayModeRuntime.sync(nextFullscreen), getOverlayInteractionActive: () => visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() || visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), @@ -5021,14 +5036,14 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({ getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(), getStatsOverlayVisible: () => appState.statsOverlayVisible, getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(), - getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode, - getLinuxTrackedMpvFullscreen: () => linuxTrackedMpvFullscreen, - getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxTrackedMpvFullscreenChangedAtMs, + getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode, + getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen, + getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs, syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) => - syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen), - getLinuxVisibleOverlayOwnerBindingKey: () => linuxVisibleOverlayOwnerBindingKey, + linuxOverlayModeRuntime.sync(fullscreen), + getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey, setLinuxVisibleOverlayOwnerBindingKey: (key) => { - linuxVisibleOverlayOwnerBindingKey = key; + linuxOverlayModeRuntime.ownerBindingKey = key; }, clearVisibleOverlayX11OwnerBinding: (window) => visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window), @@ -5113,85 +5128,6 @@ function createMainWindow(): BrowserWindow { return window; } -function createLinuxVisibleOverlayWindowForCurrentMode(token: number, fullscreen: boolean): void { - if (token !== linuxVisibleOverlayWindowModeSwitchToken) { - return; - } - if (!overlayManager.getVisibleOverlayVisible()) { - return; - } - const existingWindow = overlayManager.getMainWindow(); - if (existingWindow && !existingWindow.isDestroyed()) { - return; - } - - visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); - createMainWindow(); - const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry(); - if (trackedGeometry) { - overlayManager.setOverlayWindowBounds(trackedGeometry); - } - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - void ensureOverlayMpvSubtitlesHidden(); - if (appState.currentSubText.trim()) { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - } - logger.debug( - `Switched Linux visible overlay window mode to ${linuxVisibleOverlayWindowMode} for mpv fullscreen=${fullscreen}`, - ); -} - -function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void { - if (!shouldRunLinuxOverlayZOrderKeepAlive()) { - return; - } - if (linuxTrackedMpvFullscreen !== fullscreen) { - linuxTrackedMpvFullscreenChangedAtMs = Date.now(); - } - linuxTrackedMpvFullscreen = fullscreen; - const currentWindow = overlayManager.getMainWindow(); - const hasLiveWindow = Boolean(currentWindow && !currentWindow.isDestroyed()); - const action = resolveLinuxVisibleOverlayWindowModeAction({ - currentMode: linuxVisibleOverlayWindowMode, - fullscreen, - hasLiveWindow, - visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), - }); - - linuxVisibleOverlayWindowMode = action.nextMode; - linuxVisibleOverlayOwnerBindingKey = null; - linuxVisibleOverlayWindowModeSwitchToken += 1; - const token = linuxVisibleOverlayWindowModeSwitchToken; - if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) { - return; - } - - const previousWindow = currentWindow; - if (action.shouldDestroyCurrentWindow && previousWindow && !previousWindow.isDestroyed()) { - previousWindow.once('closed', () => { - if (overlayManager.getMainWindow() === previousWindow) { - overlayManager.setMainWindow(null); - } - if (action.createWindowTiming === 'after-current-destroyed') { - createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen); - } - }); - previousWindow.hide(); - previousWindow.destroy(); - } - - if (!action.shouldCreateWindow) { - logger.debug( - `Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`, - ); - return; - } - - if (action.createWindowTiming === 'now') { - createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen); - } -} - function initializeOverlayRuntime(): void { initializeOverlayRuntimeHandler(); if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) { @@ -6358,8 +6294,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), getLinuxX11FullscreenOverlay: () => shouldRunLinuxOverlayZOrderKeepAlive() && - linuxTrackedMpvFullscreen && - linuxVisibleOverlayWindowMode === 'fullscreen-override', + linuxOverlayModeRuntime.fullscreen && + linuxOverlayModeRuntime.mode === 'fullscreen-override', onVisibleWindowBlurred: () => visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(), onVisibleWindowFocused: () => diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index e3accd75..63f67012 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -453,7 +453,7 @@ test('Linux visible overlay recreation clears stale input state before creating const source = readMainSource(); const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts'); const actionBlock = source.match( - /function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, + /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?createWindow: \(\) => \{(?[\s\S]*?)\n \},/, )?.groups?.body; const resetBlock = runtimeSource.match( /function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n \}/, @@ -472,7 +472,7 @@ test('Linux visible overlay recreation clears stale input state before creating test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => { const source = readMainSource(); const actionBlock = source.match( - /function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, + /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?refreshWindow: \(\) => \{(?[\s\S]*?)\n \},/, )?.groups?.body; assert.ok(actionBlock); @@ -480,7 +480,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge actionBlock, /const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/, ); - assert.match(actionBlock, /if \(trackedGeometry\) \{/); + assert.match( + actionBlock, + /if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/, + ); assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/); assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/); }); diff --git a/src/main/runtime/composers/startup-lifecycle-composer.ts b/src/main/runtime/composers/startup-lifecycle-composer.ts index e8e95059..adcf5a1c 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.ts @@ -8,13 +8,10 @@ import { createBuildRestoreWindowsOnActivateMainDepsHandler, createBuildShouldRestoreWindowsOnActivateMainDepsHandler, } from '../app-lifecycle-main-activate'; -import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps'; import { registerProtocolUrlHandlers } from '../protocol-url-handlers'; import type { ComposerInputs, ComposerOutputs } from './contracts'; -type RegisterProtocolUrlHandlersMainDeps = Parameters< - typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler ->[0]; +type RegisterProtocolUrlHandlersMainDeps = Parameters[0]; type OnWillQuitCleanupDeps = Parameters[0]; type ShouldRestoreWindowsOnActivateMainDeps = Parameters< typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler @@ -40,10 +37,6 @@ export type StartupLifecycleComposerResult = ComposerOutputs<{ export function composeStartupLifecycleHandlers( options: StartupLifecycleComposerOptions, ): StartupLifecycleComposerResult { - const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler( - options.registerProtocolUrlHandlersMainDeps, - )(); - const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler( createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(), ); @@ -58,9 +51,9 @@ export function composeStartupLifecycleHandlers( return { registerProtocolUrlHandlers: () => - registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps), - onWillQuitCleanup: () => onWillQuitCleanupHandler(), - shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), - restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), + registerProtocolUrlHandlers(options.registerProtocolUrlHandlersMainDeps), + onWillQuitCleanup: onWillQuitCleanupHandler, + shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler, + restoreWindowsOnActivate: restoreWindowsOnActivateHandler, }; } diff --git a/src/main/runtime/domains/anilist.ts b/src/main/runtime/domains/anilist.ts index 6650c4ca..7cee9eda 100644 --- a/src/main/runtime/domains/anilist.ts +++ b/src/main/runtime/domains/anilist.ts @@ -13,4 +13,3 @@ export * from '../anilist-state'; export * from '../anilist-token-refresh'; export * from '../anilist-token-refresh-main-deps'; export * from '../protocol-url-handlers'; -export * from '../protocol-url-handlers-main-deps'; diff --git a/src/main/runtime/linux-overlay-mode-runtime.test.ts b/src/main/runtime/linux-overlay-mode-runtime.test.ts new file mode 100644 index 00000000..2ef3350d --- /dev/null +++ b/src/main/runtime/linux-overlay-mode-runtime.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { createLinuxOverlayModeRuntime } from './linux-overlay-mode-runtime'; + +class TestWindow extends EventEmitter { + destroyed = false; + hidden = false; + isDestroyed() { + return this.destroyed; + } + hide() { + this.hidden = true; + } + destroy() { + this.destroyed = true; + } + finishClose() { + this.emit('closed'); + } +} + +function fixture() { + const initial = new TestWindow(); + const state: { window: TestWindow | null; visible: boolean; creates: number; refreshes: number } = + { + window: initial, + visible: true, + creates: 0, + refreshes: 0, + }; + const runtime = createLinuxOverlayModeRuntime({ + isEnabled: () => true, + isVisible: () => state.visible, + getWindow: () => state.window, + clearWindow: () => { + state.window = null; + }, + createWindow: () => { + state.creates += 1; + state.window = new TestWindow(); + }, + refreshWindow: () => { + state.refreshes += 1; + }, + now: () => 42, + logDebug: () => {}, + }); + return { initial, state, runtime }; +} + +test('Linux mode transition waits for close before replacing and refreshing the window', () => { + const { initial, state, runtime } = fixture(); + runtime.ownerBindingKey = 'old-owner'; + runtime.sync(true); + assert.equal(runtime.mode, 'fullscreen-override'); + assert.equal(runtime.fullscreenChangedAtMs, 42); + assert.equal(runtime.ownerBindingKey, null); + assert.equal(initial.hidden, true); + assert.equal(state.creates, 0); + initial.finishClose(); + assert.equal(state.creates, 1); + assert.equal(state.refreshes, 1); + runtime.sync(true); + assert.equal(state.creates, 1); +}); + +test('an older close callback cannot clear or replace a newer overlay', () => { + const { initial, state, runtime } = fixture(); + runtime.sync(true); + runtime.sync(false); + const replacement = state.window; + assert.equal(state.creates, 1); + initial.finishClose(); + assert.equal(state.window, replacement); + assert.equal(state.creates, 1); + assert.equal(runtime.mode, 'managed'); +}); + +test('hiding or cancelling a transition prevents delayed window creation', () => { + for (const cancel of [false, true]) { + const { initial, state, runtime } = fixture(); + runtime.sync(true); + if (cancel) runtime.cancelPendingTransition(); + else state.visible = false; + initial.finishClose(); + assert.equal(state.creates, 0); + assert.equal(state.window, null); + state.visible = true; + runtime.sync(true); + assert.equal(state.creates, 1); + } +}); diff --git a/src/main/runtime/linux-overlay-mode-runtime.ts b/src/main/runtime/linux-overlay-mode-runtime.ts new file mode 100644 index 00000000..d2bed651 --- /dev/null +++ b/src/main/runtime/linux-overlay-mode-runtime.ts @@ -0,0 +1,94 @@ +import type { BrowserWindow } from 'electron'; +import { + resolveLinuxVisibleOverlayWindowModeAction, + type LinuxVisibleOverlayWindowMode, +} from './linux-visible-overlay-window-mode'; + +type OverlayWindow = Pick & { + once: (event: 'closed', listener: () => void) => unknown; +}; + +export function createLinuxOverlayModeRuntime(deps: { + isEnabled: () => boolean; + isVisible: () => boolean; + getWindow: () => Window | null; + clearWindow: () => void; + createWindow: () => void; + refreshWindow: () => void; + now: () => number; + logDebug: (message: string) => void; +}) { + let mode: LinuxVisibleOverlayWindowMode = 'managed'; + let fullscreen = false; + let fullscreenChangedAtMs = 0; + let ownerBindingKey: string | null = null; + let generation = 0; + + function createWindowForMode(token: number, nextFullscreen: boolean): void { + if (token !== generation || !deps.isVisible()) return; + const existing = deps.getWindow(); + if (existing && !existing.isDestroyed()) return; + deps.createWindow(); + deps.refreshWindow(); + deps.logDebug( + `Switched Linux visible overlay window mode to ${mode} for mpv fullscreen=${nextFullscreen}`, + ); + } + + function sync(nextFullscreen: boolean): void { + if (!deps.isEnabled()) return; + if (fullscreen !== nextFullscreen) fullscreenChangedAtMs = deps.now(); + fullscreen = nextFullscreen; + const current = deps.getWindow(); + const action = resolveLinuxVisibleOverlayWindowModeAction({ + currentMode: mode, + fullscreen, + hasLiveWindow: Boolean(current && !current.isDestroyed()), + visibleOverlayVisible: deps.isVisible(), + }); + mode = action.nextMode; + ownerBindingKey = null; + const token = ++generation; + if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) return; + + if (action.shouldDestroyCurrentWindow && current && !current.isDestroyed()) { + current.once('closed', () => { + if (deps.getWindow() === current) deps.clearWindow(); + if (action.createWindowTiming === 'after-current-destroyed') { + createWindowForMode(token, nextFullscreen); + } + }); + current.hide(); + current.destroy(); + } + if (!action.shouldCreateWindow) { + deps.logDebug( + `Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`, + ); + return; + } + if (action.createWindowTiming === 'now') createWindowForMode(token, nextFullscreen); + } + + return { + get mode() { + return mode; + }, + get fullscreen() { + return fullscreen; + }, + get fullscreenChangedAtMs() { + return fullscreenChangedAtMs; + }, + get ownerBindingKey() { + return ownerBindingKey; + }, + set ownerBindingKey(key: string | null) { + ownerBindingKey = key; + }, + sync, + cancelPendingTransition: () => { + generation += 1; + }, + }; +} diff --git a/src/main/runtime/media-timing-review-open.ts b/src/main/runtime/media-timing-review-open.ts index 1d2a762d..fa6655ea 100644 --- a/src/main/runtime/media-timing-review-open.ts +++ b/src/main/runtime/media-timing-review-open.ts @@ -20,11 +20,13 @@ export async function openMediaTimingReviewModal( logWarn: (message: string) => void; }, payload: MediaTimingReviewOpenPayload, + signal?: AbortSignal, ): Promise { return await retryOverlayModalOpen( { waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn }, { modal: MODAL, + signal, // The review renderer regularly needs more than the 1.5 s the other modals allow; a // premature retry re-sends the payload and reloads the waveform for nothing. timeoutMs: 4_000, diff --git a/src/main/runtime/media-timing-review.test.ts b/src/main/runtime/media-timing-review.test.ts index fad9b587..85718b14 100644 --- a/src/main/runtime/media-timing-review.test.ts +++ b/src/main/runtime/media-timing-review.test.ts @@ -8,6 +8,7 @@ import type { RemoteMediaWindowSource, } from '../../core/services/remote-media-window-cache'; import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview'; +import { openMediaTimingReviewModal } from './media-timing-review-open'; type MediaTimingPreviewSessionLike = Pick; import { @@ -16,6 +17,20 @@ import { createMediaTimingReviewRuntime, } from './media-timing-review'; +function createDeferred() { + let settle: ((value: T) => void) | null = null; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { + promise, + resolve(value: T): void { + if (!settle) throw new Error('deferred promise is unavailable'); + settle(value); + }, + }; +} + describe('buildMediaTimingReviewPayload', () => { test('starts from the padded range and leaves two seconds to drag on each side', () => { const payload = buildMediaTimingReviewPayload( @@ -764,6 +779,206 @@ test('disposing an open review settles it with original timing and restores play ]); }); +for (const pendingSetup of ['properties', 'video-source'] as const) { + test(`disposing pending ${pendingSetup} cancels side effects and permits a fresh review`, async () => { + const setupGate = createDeferred(); + const commands: Array> = []; + let blockSetup = true; + let modalOpenCalls = 0; + let previewCreateCalls = 0; + let runtime: ReturnType; + runtime = createMediaTimingReviewRuntime({ + getMpvClient: () => ({ + connected: true, + currentVideoPath: '/video/show.mkv', + requestProperty: async (name) => { + if (blockSetup && pendingSetup === 'properties') await setupGate.promise; + return name === 'pause' ? false : name === 'duration' ? 100 : null; + }, + send: ({ command }) => commands.push(command), + }), + resolveVideoSource: async () => { + if (blockSetup && pendingSetup === 'video-source') await setupGate.promise; + return { path: '/video/show.mkv' }; + }, + getCurrentMediaPath: () => '/video/show.mkv', + getMpvExecutablePath: () => 'mpv', + generateWaveform: async () => [], + createPreviewSession: () => { + previewCreateCalls += 1; + return { + start: async () => undefined, + play: async () => undefined, + stop: async () => undefined, + onPlaybackEnded: () => undefined, + dispose: () => undefined, + }; + }, + openModal: async (payload) => { + modalOpenCalls += 1; + runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } }); + return true; + }, + showStatus: () => undefined, + }); + const request = { + kind: 'word' as const, + text: '字幕', + startTime: 10, + endTime: 12, + audioPadding: 0, + maxMediaDuration: 30, + screenshotEnabled: true, + }; + + const pending = runtime.requestReview(request); + let pendingSettled = false; + void pending.finally(() => { + pendingSettled = true; + }); + await Promise.resolve(); + await runtime.dispose(); + + assert.equal(pendingSettled, true); + assert.deepEqual(await pending, { action: 'use-original' }); + assert.deepEqual(commands, []); + assert.equal(modalOpenCalls, 0); + assert.equal(previewCreateCalls, 0); + + setupGate.resolve(); + await Promise.resolve(); + + blockSetup = false; + assert.deepEqual(await runtime.requestReview(request), { action: 'use-original' }); + assert.equal(modalOpenCalls, 1); + assert.equal(previewCreateCalls, 1); + }); +} + +test('disposing during modal acknowledgement prevents the real opener from retrying', async () => { + const waiting = createDeferred(); + const acknowledgement = createDeferred(); + const commands: Array> = []; + let sendCalls = 0; + let previewDisposeCalls = 0; + let opening: Promise | undefined; + const runtime = createMediaTimingReviewRuntime({ + getMpvClient: () => ({ + connected: true, + currentVideoPath: '/video/show.mkv', + requestProperty: async (name) => (name === 'pause' ? false : null), + send: ({ command }) => commands.push(command), + }), + getCurrentMediaPath: () => '/video/show.mkv', + getMpvExecutablePath: () => 'mpv', + generateWaveform: async () => [], + createPreviewSession: () => ({ + start: async () => undefined, + play: async () => undefined, + stop: async () => undefined, + onPlaybackEnded: () => undefined, + dispose: () => { + previewDisposeCalls += 1; + }, + }), + openModal: (payload, signal) => { + opening = openMediaTimingReviewModal( + { + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: () => { + sendCalls += 1; + return true; + }, + waitForModalOpen: () => { + waiting.resolve(); + return acknowledgement.promise; + }, + logWarn: () => {}, + }, + payload, + signal, + ); + return opening; + }, + showStatus: () => {}, + }); + const pending = runtime.requestReview({ + kind: 'sentence', + text: '字幕', + startTime: 10, + endTime: 12, + audioPadding: 0, + maxMediaDuration: 30, + }); + await waiting.promise; + await runtime.dispose(); + assert.deepEqual(await pending, { action: 'use-original' }); + + acknowledgement.resolve(false); + assert.equal(await opening, false); + assert.equal(sendCalls, 1); + assert.equal(previewDisposeCalls, 1); + assert.deepEqual(commands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); +}); + +test('disposing owns a preview session whose startup is still pending', async () => { + const openedPayload = createDeferred(); + const previewStarted = createDeferred(); + const previewStartGate = createDeferred(); + let previewDisposeCalls = 0; + const runtime = createMediaTimingReviewRuntime({ + getMpvClient: () => ({ + connected: true, + currentVideoPath: '/video/show.mkv', + requestProperty: async (name) => (name === 'duration' ? 100 : null), + send: () => undefined, + }), + getCurrentMediaPath: () => '/video/show.mkv', + getMpvExecutablePath: () => 'mpv', + generateWaveform: async () => [], + createPreviewSession: () => ({ + start: async () => { + previewStarted.resolve(); + await previewStartGate.promise; + }, + play: async () => undefined, + stop: async () => undefined, + onPlaybackEnded: () => undefined, + dispose: () => { + previewDisposeCalls += 1; + }, + }), + openModal: async (payload) => { + openedPayload.resolve(payload); + return true; + }, + showStatus: () => undefined, + }); + + const pending = runtime.requestReview({ + kind: 'sentence', + text: '字幕', + startTime: 10, + endTime: 12, + audioPadding: 0, + maxMediaDuration: 30, + }); + await openedPayload.promise; + await previewStarted.promise; + + await runtime.dispose(); + assert.deepEqual(await pending, { action: 'use-original' }); + assert.equal(previewDisposeCalls, 0); + + previewStartGate.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(previewDisposeCalls, 1); +}); + test('media timing review forwards the hidden player finishing a preview to the modal', async () => { const endedReviewIds: string[] = []; const playback: { ended?: () => void } = {}; diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts index c611a0c3..abb3258d 100644 --- a/src/main/runtime/media-timing-review.ts +++ b/src/main/runtime/media-timing-review.ts @@ -78,6 +78,15 @@ interface ActiveReview { resolve: (decision: MediaTimingReviewDecision) => void; } +interface ReviewRequestLifecycle { + signal: AbortSignal; + cancelled: Promise; + settled: Promise; + isCancelled(): boolean; + cancel(): void; + markSettled(): void; +} + export interface MediaTimingReviewRuntimeDeps { getMpvClient: () => ReviewMpvClient | null; getCurrentMediaPath: () => string | null; @@ -101,7 +110,7 @@ export interface MediaTimingReviewRuntimeDeps { next: MediaTimingReviewContextLine[]; }; decisionTimeoutMs?: number; - openModal: (payload: MediaTimingReviewOpenPayload) => Promise; + openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise; /** Tells the modal that the hidden player finished the previewed clip. */ onPreviewEnded?: (reviewId: string) => void; showStatus: (message: string) => void; @@ -118,6 +127,33 @@ function booleanProperty(value: unknown): boolean | null { return null; } +function createReviewRequestLifecycle(): ReviewRequestLifecycle { + const controller = new AbortController(); + let resolveCancellation: (() => void) | null = null; + let resolveSettled: (() => void) | null = null; + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve; + }); + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); + return { + signal: controller.signal, + cancelled: cancellation, + settled, + isCancelled: () => controller.signal.aborted, + cancel: () => { + if (controller.signal.aborted) return; + controller.abort(); + resolveCancellation?.(); + }, + markSettled: () => { + resolveSettled?.(); + resolveSettled = null; + }, + }; +} + /** * Picks the subtitle lines adjacent to the mined range that the review modal can pull * onto the card. Parsed cues cover both directions; when none are loaded (e.g. the @@ -245,7 +281,7 @@ export function buildMediaTimingReviewPayload( export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) { let active: ActiveReview | null = null; - let reviewInProgress = false; + let currentRequest: ReviewRequestLifecycle | null = null; let pendingPauseRestore: ReviewMpvClient | null = null; function restorePendingPlayback(): void { @@ -311,14 +347,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep const previous = review.preview; const session = deps.createPreviewSession(); - session.onPlaybackEnded(() => { - if (active === review && review.preview?.session === started) { - deps.onPreviewEnded?.(review.payload.reviewId); - } - }); const { audioTrackId, ...previewOptions } = review.previewOptions; - const started = session - .start({ + const startSession = async (): Promise => { + if (active !== review) { + throw new Error('This timing review is no longer active.'); + } + await session.start({ mediaPath, ...previewOptions, // A cached window keeps one audio stream, so mpv's track id from the source no longer applies. @@ -327,19 +361,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep : audioTrackId !== undefined ? { audioTrackId } : {}), - }) - .then(() => session) - .catch((error) => { + }); + return session; + }; + const started = Promise.resolve() + .then(startSession) + .catch((error: unknown) => { session.dispose(); throw error; }); review.preview = { path: mediaPath, session: started }; + session.onPlaybackEnded(() => { + if (active === review && review.preview?.session === started) { + deps.onPreviewEnded?.(review.payload.reviewId); + } + }); void started.catch(() => {}); if (previous) void previous.session.then((old) => old.dispose()).catch(() => {}); return started; } - async function runReview(request: MediaTimingReviewRequest): Promise { + async function runReview( + request: MediaTimingReviewRequest, + lifecycle: ReviewRequestLifecycle, + ): Promise { const mpvClient = deps.getMpvClient(); const mediaPath = deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || ''; @@ -348,18 +393,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep return { action: 'use-original' }; } + const setupPromise = Promise.all([ + mpvClient.requestProperty?.('pause').catch(() => null) ?? null, + mpvClient.requestProperty?.('duration').catch(() => null) ?? null, + mpvClient.requestProperty?.('aid').catch(() => null) ?? null, + mpvClient.requestProperty?.('volume').catch(() => null) ?? null, + deps.resolveMediaSource?.().catch(() => null) ?? null, + request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null, + ]); + const setup = await Promise.race([ + setupPromise.then((values) => ({ kind: 'ready' as const, values })), + lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })), + ]); + if (setup.kind === 'cancelled' || lifecycle.isCancelled()) { + return { action: 'use-original' }; + } const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] = - await Promise.all([ - mpvClient.requestProperty?.('pause').catch(() => null) ?? null, - mpvClient.requestProperty?.('duration').catch(() => null) ?? null, - mpvClient.requestProperty?.('aid').catch(() => null) ?? null, - mpvClient.requestProperty?.('volume').catch(() => null) ?? null, - deps.resolveMediaSource?.().catch(() => null) ?? null, - request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null, - ]); + setup.values; const pauseState = booleanProperty(pauseRaw); - mpvClient.send({ command: ['set_property', 'pause', 'yes'] }); pendingPauseRestore = pauseState === false ? mpvClient : null; + mpvClient.send({ command: ['set_property', 'pause', 'yes'] }); + if (lifecycle.isCancelled()) { + restorePendingPlayback(); + return { action: 'use-original' }; + } let contextLines: ReturnType> | undefined; try { @@ -423,9 +480,22 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep endTime: payload.timelineEndTime, }).catch(() => {}); - const opened = await deps.openModal(payload).catch(() => false); + if (lifecycle.isCancelled() || active !== review) { + await cleanupActiveReview(review); + return { action: 'use-original' }; + } + const openModal = deps.openModal(payload, lifecycle.signal).catch(() => false); + const openResult = await Promise.race([ + openModal.then((opened) => ({ kind: 'opened' as const, opened })), + lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })), + ]); + if (openResult.kind === 'cancelled' || lifecycle.isCancelled() || active !== review) { + await cleanupActiveReview(review); + return { action: 'use-original' }; + } + const { opened } = openResult; if (!opened) { - await cleanupActiveReview(); + await cleanupActiveReview(review); deps.showStatus('Timing review could not open. Using the original subtitle timing.'); return { action: 'use-original' }; } @@ -434,33 +504,43 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep () => resolveDecision({ action: 'use-original' }), Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS), ); - let decision: MediaTimingReviewDecision; + let decision: MediaTimingReviewDecision = { action: 'use-original' }; try { - decision = await decisionPromise; + const decisionResult = await Promise.race([ + decisionPromise.then((value) => ({ kind: 'decided' as const, value })), + lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })), + ]); + if (decisionResult.kind === 'decided') { + decision = decisionResult.value; + } } finally { clearTimeout(decisionWatchdog); } - await cleanupActiveReview(); + await cleanupActiveReview(review); return decision; } async function requestReview( request: MediaTimingReviewRequest, ): Promise { - if (active || reviewInProgress) { + if (active || currentRequest) { deps.showStatus('Finish the current timing review before mining another card.'); return { action: 'use-original' }; } - reviewInProgress = true; + const lifecycle = createReviewRequestLifecycle(); + currentRequest = lifecycle; try { - return await runReview(request); + return await runReview(request, lifecycle); } catch { await cleanupActiveReview(); restorePendingPlayback(); deps.showStatus('Timing review failed. Using the original subtitle timing.'); return { action: 'use-original' }; } finally { - reviewInProgress = false; + if (currentRequest === lifecycle) { + currentRequest = null; + } + lifecycle.markSettled(); } } @@ -603,9 +683,18 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep } try { const previewSession = current.preview ? await current.preview.session : null; + if (active !== current) { + return staleReviewResult(); + } await previewSession?.stop(); + if (active !== current) { + return staleReviewResult(); + } return { ok: true }; } catch (error) { + if (active !== current) { + return staleReviewResult(); + } return { ok: false, message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`, @@ -641,8 +730,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep return { ok: true }; } - async function cleanupActiveReview(): Promise { + async function cleanupActiveReview(expected?: ActiveReview): Promise { const current = active; + if (expected && current !== expected) return; active = null; if (!current) return; deps.clearFrameCache?.(); @@ -653,9 +743,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep } async function dispose(): Promise { + const request = currentRequest; + request?.cancel(); active?.resolve({ action: 'use-original' }); await cleanupActiveReview(); restorePendingPlayback(); + await request?.settled; } return { diff --git a/src/main/runtime/overlay-hosted-modal-open.test.ts b/src/main/runtime/overlay-hosted-modal-open.test.ts index adaa8552..09913807 100644 --- a/src/main/runtime/overlay-hosted-modal-open.test.ts +++ b/src/main/runtime/overlay-hosted-modal-open.test.ts @@ -1,6 +1,77 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { openOverlayHostedModal } from './overlay-hosted-modal-open'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +test('retryOverlayModalOpen skips the first send when already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const unexpectedCall = () => assert.fail('aborted open must not send or wait'); + assert.equal( + await retryOverlayModalOpen( + { waitForModalOpen: unexpectedCall, logWarn: unexpectedCall }, + { + modal: 'media-timing-review', + timeoutMs: 4_000, + retryWarning: 'retry', + sendOpen: unexpectedCall, + signal: controller.signal, + }, + ), + false, + ); +}); + +for (const abortOnWait of [1, 2]) { + test(`retryOverlayModalOpen rejects an acknowledgement aborted during wait ${abortOnWait}`, async () => { + const controller = new AbortController(); + let waitCalls = 0; + let sendCalls = 0; + const opened = await retryOverlayModalOpen( + { + waitForModalOpen: async () => { + waitCalls += 1; + if (waitCalls === abortOnWait) { + controller.abort(); + return true; + } + return false; + }, + logWarn: () => {}, + }, + { + modal: 'media-timing-review', + timeoutMs: 4_000, + retryWarning: 'retry', + sendOpen: () => { + sendCalls += 1; + return true; + }, + signal: controller.signal, + }, + ); + assert.equal(opened, false); + assert.equal(sendCalls, abortOnWait); + assert.equal(waitCalls, abortOnWait); + }); +} + +test('retryOverlayModalOpen still retries other modals without a signal', async () => { + let sendCalls = 0; + const opened = await retryOverlayModalOpen( + { waitForModalOpen: async () => sendCalls === 2, logWarn: () => {} }, + { + modal: 'runtime-options', + timeoutMs: 1_500, + retryWarning: 'retry', + sendOpen: () => { + sendCalls += 1; + return true; + }, + }, + ); + assert.equal(opened, true); + assert.equal(sendCalls, 2); +}); test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => { const calls: string[] = []; diff --git a/src/main/runtime/overlay-hosted-modal-open.ts b/src/main/runtime/overlay-hosted-modal-open.ts index 15366ae8..f19b30f4 100644 --- a/src/main/runtime/overlay-hosted-modal-open.ts +++ b/src/main/runtime/overlay-hosted-modal-open.ts @@ -38,20 +38,24 @@ export async function retryOverlayModalOpen( timeoutMs: number; retryWarning: string; sendOpen: () => boolean; + signal?: AbortSignal; }, ): Promise { - if (!input.sendOpen()) { + if (input.signal?.aborted || !input.sendOpen()) { return false; } - if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) { + const opened = await deps.waitForModalOpen(input.modal, input.timeoutMs); + if (input.signal?.aborted) return false; + if (opened) { return true; } deps.logWarn(input.retryWarning); - if (!input.sendOpen()) { + if (input.signal?.aborted || !input.sendOpen()) { return false; } - return await deps.waitForModalOpen(input.modal, input.timeoutMs); + const retryOpened = await deps.waitForModalOpen(input.modal, input.timeoutMs); + return !input.signal?.aborted && retryOpened; } diff --git a/src/main/runtime/protocol-url-handlers-main-deps.test.ts b/src/main/runtime/protocol-url-handlers-main-deps.test.ts deleted file mode 100644 index 5a6087aa..00000000 --- a/src/main/runtime/protocol-url-handlers-main-deps.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from './protocol-url-handlers-main-deps'; - -test('protocol url handlers main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildRegisterProtocolUrlHandlersMainDepsHandler({ - registerOpenUrl: () => calls.push('open-register'), - registerSecondInstance: () => calls.push('second-register'), - handleAnilistSetupProtocolUrl: () => true, - findAnilistSetupDeepLinkArgvUrl: () => 'subminer://anilist-setup', - logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`), - logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`), - })(); - - deps.registerOpenUrl(() => {}); - deps.registerSecondInstance(() => {}); - assert.equal(deps.handleAnilistSetupProtocolUrl('subminer://anilist-setup'), true); - assert.equal(deps.findAnilistSetupDeepLinkArgvUrl(['x']), 'subminer://anilist-setup'); - deps.logUnhandledOpenUrl('subminer://noop'); - deps.logUnhandledSecondInstanceUrl('subminer://noop'); - - assert.deepEqual(calls, [ - 'open-register', - 'second-register', - 'open:subminer://noop', - 'second:subminer://noop', - ]); -}); diff --git a/src/main/runtime/protocol-url-handlers-main-deps.ts b/src/main/runtime/protocol-url-handlers-main-deps.ts deleted file mode 100644 index a2a0554f..00000000 --- a/src/main/runtime/protocol-url-handlers-main-deps.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { registerProtocolUrlHandlers } from './protocol-url-handlers'; - -type RegisterProtocolUrlHandlersMainDeps = Parameters[0]; - -export function createBuildRegisterProtocolUrlHandlersMainDepsHandler( - deps: RegisterProtocolUrlHandlersMainDeps, -) { - return (): RegisterProtocolUrlHandlersMainDeps => ({ - registerOpenUrl: (listener) => deps.registerOpenUrl(listener), - registerSecondInstance: (listener) => deps.registerSecondInstance(listener), - handleAnilistSetupProtocolUrl: (rawUrl: string) => deps.handleAnilistSetupProtocolUrl(rawUrl), - findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => deps.findAnilistSetupDeepLinkArgvUrl(argv), - logUnhandledOpenUrl: (rawUrl: string) => deps.logUnhandledOpenUrl(rawUrl), - logUnhandledSecondInstanceUrl: (rawUrl: string) => deps.logUnhandledSecondInstanceUrl(rawUrl), - }); -} From ab48a5678edf06b8d15ef8d04562b0928d31cbd8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 23:36:55 -0700 Subject: [PATCH 7/9] fix(stats): restrict local requests and serve the dashboard over HTTP (#263) --- changes/stats-request-safety.md | 6 + docs-site/immersion-tracking.md | 22 +++ .../services/__tests__/stats-server.test.ts | 125 +++++++++++++++++- src/core/services/stats-server.ts | 7 +- .../services/stats-server/request-safety.ts | 42 ++++++ src/core/services/stats-window-runtime.ts | 13 +- src/core/services/stats-window.test.ts | 19 +-- src/core/services/stats-window.ts | 12 +- src/main.ts | 2 - stats/src/App.tsx | 3 +- .../components/vocabulary/WordDetailPanel.tsx | 3 +- stats/src/lib/api-client.test.ts | 37 +----- stats/src/lib/api-client.ts | 20 +-- stats/src/lib/asset-url.test.ts | 39 ------ stats/src/lib/asset-url.ts | 24 ---- stats/src/lib/media-library-grouping.test.tsx | 4 +- 16 files changed, 221 insertions(+), 157 deletions(-) create mode 100644 changes/stats-request-safety.md create mode 100644 src/core/services/stats-server/request-safety.ts delete mode 100644 stats/src/lib/asset-url.test.ts delete mode 100644 stats/src/lib/asset-url.ts diff --git a/changes/stats-request-safety.md b/changes/stats-request-safety.md new file mode 100644 index 00000000..4f3da8f1 --- /dev/null +++ b/changes/stats-request-safety.md @@ -0,0 +1,6 @@ +type: changed +breaking: true +area: stats + +- Reject requests from untrusted browser origins and hosts before stats data, media, or Anki operations run, and require JSON for mutation bodies. +- Load the in-app stats overlay from the local server so it uses the same origin protection as the browser dashboard. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index ca45d272..d81eb34e 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -31,6 +31,28 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_ The same immersion data powers the stats dashboard. +The browser dashboard and in-app stats overlay both load from the local HTTP server. +The server accepts loopback hosts only and rejects requests from other browser origins, +including opaque origins such as `file://`. API clients without a browser origin can +still use the local API. Mutation requests with a body must use `application/json`; +bodyless deletion and Anki browse requests remain supported. Requests rejected by the +host or origin checks receive `403`; mutation bodies without a JSON content type +receive `415`. + +Use the loopback dashboard URL directly. Reverse-proxied dashboards and Tailscale +Serve URLs are unsupported because their host or browser origin is not the local +server's origin. SSH stats synchronization is unchanged. + +Scripts sending a JSON body must include the content type. For example, this +requests a duplicate-line cleanup preview without changing the database. Replace +the port if you configured a different `stats.serverPort`: + +```bash +curl http://127.0.0.1:6969/api/stats/maintenance/duplicate-lines \ + -H 'Content-Type: application/json' \ + -d '{"dryRun":true}' +``` + - In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`). - Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`). - Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 83f3162f..7f32ada2 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -444,6 +444,73 @@ async function withFakeAnkiConnect( } describe('stats server API routes', () => { + it('rejects untrusted mutation requests before merging anime', async () => { + let merges = 0; + const app = createStatsApp( + createMockTracker({ + mergeAnime: async () => { + merges += 1; + return { survivingAnimeId: 1, mergedAnimeIds: [2], movedVideos: 1 }; + }, + }), + ); + const rejectedHeaders: Record[] = [ + { Origin: 'https://attacker.example', 'Content-Type': 'text/plain' }, + { Origin: 'https://attacker.example', 'Content-Type': 'application/json' }, + { Origin: 'null', 'Content-Type': 'application/json' }, + { Origin: 'http://localhost:4321', 'Content-Type': 'application/json' }, + { Origin: 'http://localhost/', 'Content-Type': 'application/json' }, + { 'Sec-Fetch-Site': 'cross-site', 'Content-Type': 'application/json' }, + { Host: 'attacker.example', 'Content-Type': 'application/json' }, + ]; + for (const headers of rejectedHeaders) { + const response = await app.request('/api/stats/anime/1/merge', { + method: 'POST', + headers, + body: JSON.stringify({ sourceAnimeIds: [2] }), + }); + assert.equal(response.status, 403, JSON.stringify(headers)); + } + assert.equal(merges, 0); + for (const origin of [undefined, 'http://localhost']) { + const headers = new Headers({ 'Content-Type': 'application/json; charset=utf-8' }); + if (origin) headers.set('Origin', origin); + const response = await app.request('/api/stats/anime/1/merge', { + method: 'POST', + headers, + body: JSON.stringify({ sourceAnimeIds: [2] }), + }); + assert.equal(response.status, 200); + } + assert.equal(merges, 2); + }); + + it('requires JSON for mutation bodies and preserves bodyless deletion', async () => { + let deletions = 0; + const app = createStatsApp( + createMockTracker({ + deleteSession: async () => { + deletions += 1; + }, + }), + ); + const invalid = await app.request('/api/stats/sessions/1', { + method: 'DELETE', + body: '{}', + }); + assert.equal(invalid.status, 415); + assert.equal(deletions, 0); + const valid = await app.request('/api/stats/sessions/1', { method: 'DELETE' }); + assert.equal(valid.status, 200); + assert.equal(deletions, 1); + const rebound = await app.request('http://attacker.example/api/stats/sessions/1', { + method: 'DELETE', + headers: { Origin: 'http://attacker.example' }, + }); + assert.equal(rebound.status, 403); + assert.equal(deletions, 1); + }); + it('GET /api/stats/overview returns overview data', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/overview'); @@ -1153,7 +1220,7 @@ describe('stats server API routes', () => { body: JSON.stringify({ dryRun: false, lookbackDays: null }), }); - assert.equal(res.status, 415); + assert.equal(res.status, 403); assert.equal(cleanupCalls, 0); }); @@ -4128,4 +4195,60 @@ Aligned English subtitle }); } }); + + it('enforces request safety through node:http without rejecting bodyless DELETEs', async () => { + await withTempDir(async (staticDir) => { + let deletions = 0; + const tracker = createMockTracker({ + deleteSession: async () => { + deletions += 1; + }, + }); + const listener = http.createServer(); + const server = await startNodeHttpServer( + createStatsApp(tracker), + { port: 0, staticDir, tracker }, + (handler) => { + listener.on('request', handler); + return listener; + }, + ); + try { + const address = listener.address(); + assert.ok(address && typeof address !== 'string'); + const origin = `http://127.0.0.1:${address.port}`; + const url = `${origin}/api/stats/sessions/1`; + for (const headers of [undefined, { 'Content-Length': '0' }]) { + const response = await fetch(url, { method: 'DELETE', headers }); + assert.equal(response.status, 200); + await response.arrayBuffer(); + } + assert.equal(deletions, 2); + for (const headers of [ + new Headers({ Origin: 'https://attacker.example' }), + new Headers({ Origin: 'null' }), + new Headers({ Host: 'attacker.example' }), + new Headers({ 'Sec-Fetch-Site': 'same-site' }), + ]) { + const response = await fetch(url, { method: 'DELETE', headers }); + assert.equal(response.status, 403, JSON.stringify(headers)); + await response.arrayBuffer(); + } + const invalid = await fetch(url, { method: 'DELETE', body: '{}' }); + assert.equal(invalid.status, 415); + await invalid.arrayBuffer(); + assert.equal(deletions, 2); + const valid = await fetch(url, { + method: 'DELETE', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: '{}', + }); + assert.equal(valid.status, 200); + await valid.arrayBuffer(); + assert.equal(deletions, 3); + } finally { + await server.close(); + } + }); + }); }); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 66113ddd..3b39f634 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -6,6 +6,7 @@ import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js'; import type { StatsServerMediaGenerator } from './stats-server/mining-support.js'; +import { enforceStatsRequestSafety } from './stats-server/request-safety.js'; import { registerStatsAnalyticsRoutes, registerStatsIntegrationRoutes, @@ -37,7 +38,10 @@ function toFetchRequest(req: IncomingMessage): Request { method, headers: toFetchHeaders(req.headers), }; - if (method !== 'GET' && method !== 'HEAD') { + const hasBody = + req.headers['transfer-encoding'] !== undefined || + Number(req.headers['content-length'] ?? 0) > 0; + if (method !== 'GET' && method !== 'HEAD' && hasBody) { init.body = Readable.toWeb(req) as BodyInit; init.duplex = 'half'; } @@ -156,6 +160,7 @@ export function createStatsApp( }, ) { const app = new Hono(); + app.use('*', enforceStatsRequestSafety); registerStatsAnalyticsRoutes(app, tracker, options); registerStatsLibraryRoutes(app, tracker, options); registerStatsIntegrationRoutes(app, tracker, options); diff --git a/src/core/services/stats-server/request-safety.ts b/src/core/services/stats-server/request-safety.ts new file mode 100644 index 00000000..0e9e1ac8 --- /dev/null +++ b/src/core/services/stats-server/request-safety.ts @@ -0,0 +1,42 @@ +import type { MiddlewareHandler } from 'hono'; + +function isLoopbackUrl(url: URL): boolean { + return ( + url.protocol === 'http:' && + !url.username && + !url.password && + ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) + ); +} + +/** Protect the local API even when a browser can reach the loopback listener. */ +export const enforceStatsRequestSafety: MiddlewareHandler = async (c, next) => { + const url = new URL(c.req.url); + if (!isLoopbackUrl(url)) return c.body(null, 403); + + const host = c.req.header('host'); + if (host !== undefined) { + if (!/^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(host)) { + return c.body(null, 403); + } + // Node derives the request URL from Host; Bun provides them independently. + try { + if (new URL(`http://${host}`).origin !== url.origin) return c.body(null, 403); + } catch { + return c.body(null, 403); + } + } + + // Compare the serialized origin exactly. Opaque origins and malformed values + // containing credentials, paths, or multiple origins must not gain trust. + const origin = c.req.header('origin'); + if (origin !== undefined && origin !== url.origin) return c.body(null, 403); + const site = c.req.header('sec-fetch-site'); + if (site === 'cross-site' || site === 'same-site') return c.body(null, 403); + + if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) && c.req.raw.body !== null) { + const contentType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (contentType !== 'application/json') return c.body(null, 415); + } + await next(); +}; diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index 4129f04e..82ef0fc6 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -219,13 +219,8 @@ export function scheduleStatsWindowPostShowReconciles( } } -export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): { - query: Record; -} { - return { - query: { - overlay: '1', - ...(apiBaseUrl ? { apiBase: apiBaseUrl } : {}), - }, - }; +export function buildStatsWindowUrl(apiBaseUrl: string): string { + const url = new URL('/', apiBaseUrl); + url.searchParams.set('overlay', '1'); + return url.toString(); } diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index b49ebe40..da1776bb 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - buildStatsWindowLoadFileOptions, + buildStatsWindowUrl, buildStatsWindowOptions, buildStatsNativeConfirmDialogOptions, demoteVisibleStatsWindowBelowDialogs, @@ -168,21 +168,12 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke ); }); -test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => { - assert.deepEqual(buildStatsWindowLoadFileOptions(), { - query: { - overlay: '1', - }, - }); +test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', () => { + assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1'); }); -test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () => { - assert.deepEqual(buildStatsWindowLoadFileOptions('http://127.0.0.1:6123'), { - query: { - overlay: '1', - apiBase: 'http://127.0.0.1:6123', - }, - }); +test('buildStatsWindowUrl uses the active server port as the document origin', () => { + assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1'); }); test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => { diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index fba13633..ab94af7a 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -1,10 +1,9 @@ import { BrowserWindow, dialog, ipcMain } from 'electron'; -import * as path from 'path'; import { createLogger } from '../../logger.js'; import type { WindowGeometry } from '../../types.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { - buildStatsWindowLoadFileOptions, + buildStatsWindowUrl, buildStatsWindowOptions, demoteVisibleStatsWindowBelowDialogs, presentStatsWindow, @@ -34,12 +33,10 @@ const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState(); const logger = createLogger('main:stats-window'); export interface StatsWindowOptions { - /** Absolute path to stats/dist/ directory */ - staticDir: string; /** Absolute path to the compiled preload-stats.js */ preloadPath: string; /** Resolve the active stats API base URL */ - getApiBaseUrl?: () => Promise | string; + getApiBaseUrl: () => Promise | string; /** Report server startup failure through the configured notification surface. */ onStartupError?: (error: unknown) => void; /** Resolve the active stats toggle key from config */ @@ -188,7 +185,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise options.getApiBaseUrl?.()) + .then(() => options.getApiBaseUrl()) .catch((error: unknown) => { options.onStartupError?.(error); throw error; @@ -207,8 +204,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise { options.onVisibilityChanged?.(false); diff --git a/src/main.ts b/src/main.ts index c3c60dd9..a85866d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4138,7 +4138,6 @@ const immersionTrackerStartupMainDeps: Parameters< // Register stats overlay toggle IPC handler (idempotent) registerStatsOverlayToggle({ - staticDir: statsDistPath, preloadPath: statsPreloadPath, getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, onStartupError: (error) => @@ -5442,7 +5441,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro await dispatchSessionActionCore(request, { toggleStatsOverlay: async () => await toggleStatsOverlayWindow({ - staticDir: statsDistPath, preloadPath: statsPreloadPath, getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, onStartupError: (error) => diff --git a/stats/src/App.tsx b/stats/src/App.tsx index f2348d4c..018a8893 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -4,7 +4,6 @@ import { DeleteProgressToast } from './components/common/DeleteProgressToast'; import { TabBar } from './components/layout/TabBar'; import { OverviewTab } from './components/overview/OverviewTab'; import { useExcludedWords } from './hooks/useExcludedWords'; -import { assetUrl } from './lib/asset-url'; import type { TabId } from './components/layout/TabBar'; import { closeMediaDetail, @@ -142,7 +141,7 @@ export function App() { onClick={() => handleTabChange('overview')} className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity" > - +

SubMiner Stats

diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx index b44123b3..f1cd2ef4 100644 --- a/stats/src/components/vocabulary/WordDetailPanel.tsx +++ b/stats/src/components/vocabulary/WordDetailPanel.tsx @@ -1,7 +1,6 @@ import { useRef, useState, useEffect } from 'react'; import { useWordDetail } from '../../hooks/useWordDetail'; import { apiClient } from '../../lib/api-client'; -import { assetUrl } from '../../lib/asset-url'; import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { buildStatsMineCardParams, @@ -167,7 +166,7 @@ export function WordDetailPanel({ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { new Notification('Anki Card Created', { body: `Mined: ${label}`, - icon: assetUrl('favicon.png'), + icon: '/favicon.png', }); } else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') { Notification.requestPermission().then((p) => { diff --git a/stats/src/lib/api-client.test.ts b/stats/src/lib/api-client.test.ts index 0f6092ad..014e6bf9 100644 --- a/stats/src/lib/api-client.test.ts +++ b/stats/src/lib/api-client.test.ts @@ -1,36 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client'; - -test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => { - const baseUrl = resolveStatsBaseUrl({ - protocol: 'file:', - origin: 'null', - search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123', - }); - - assert.equal(baseUrl, 'http://127.0.0.1:6123'); -}); - -test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => { - const baseUrl = resolveStatsBaseUrl({ - protocol: 'http:', - origin: 'http://127.0.0.1:6123', - search: '', - }); - - assert.equal(baseUrl, 'http://127.0.0.1:6123'); -}); - -test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => { - const baseUrl = resolveStatsBaseUrl({ - protocol: 'file:', - origin: 'null', - search: '?overlay=1', - }); - - assert.equal(baseUrl, 'http://127.0.0.1:6969'); -}); +import { apiClient, BASE_URL } from './api-client'; test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => { const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as ( @@ -38,10 +8,7 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => { retryToken?: number, ) => string; - assert.equal( - getAnimeCoverUrl(42, 3), - 'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3', - ); + assert.equal(getAnimeCoverUrl(42, 3), `${BASE_URL}/api/stats/anime/42/cover?coverRetry=3`); }); test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => { diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index addf6ef6..2d7186aa 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -23,24 +23,8 @@ import type { StatsMineCardParams, StatsMineCardResponse } from './mining'; import { appendCoverRetryToken } from './cover-retry'; import { trackDelete } from './delete-progress'; -type StatsLocationLike = Pick; - -export function resolveStatsBaseUrl(location?: StatsLocationLike): string { - const resolvedLocation = - location ?? - (typeof window === 'undefined' - ? { protocol: 'file:', origin: 'null', search: '' } - : window.location); - - const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim(); - if (queryApiBase) { - return queryApiBase; - } - - return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin; -} - -export const BASE_URL = resolveStatsBaseUrl(); +// Both browser and in-app dashboards use the server that served the page. +export const BASE_URL = typeof window === 'undefined' ? '' : window.location.origin; async function fetchResponse(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, init); diff --git a/stats/src/lib/asset-url.test.ts b/stats/src/lib/asset-url.test.ts deleted file mode 100644 index 399773ed..00000000 --- a/stats/src/lib/asset-url.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { assetUrl, resolveAssetUrl } from './asset-url'; - -// vite.config.ts sets `base: './'`, so this is what the built bundle sees. -const BUILT_BASE = './'; - -test('built asset URLs are never root-absolute', () => { - assert.equal(resolveAssetUrl('favicon.png', BUILT_BASE).startsWith('/'), false); -}); - -test('built asset URL resolves next to a file:// index.html', () => { - const resolved = new URL( - resolveAssetUrl('favicon.png', BUILT_BASE), - 'file:///opt/SubMiner/stats/dist/index.html', - ); - assert.equal(resolved.href, 'file:///opt/SubMiner/stats/dist/favicon.png'); -}); - -test('built asset URL resolves against the server root when served over http', () => { - const resolved = new URL(resolveAssetUrl('favicon.png', BUILT_BASE), 'http://127.0.0.1:8770/'); - assert.equal(resolved.href, 'http://127.0.0.1:8770/favicon.png'); -}); - -test('dev server base stays root-absolute', () => { - assert.equal(resolveAssetUrl('favicon.png', '/'), '/favicon.png'); -}); - -test('a base without a trailing slash still joins cleanly', () => { - assert.equal(resolveAssetUrl('favicon.png', '/stats'), '/stats/favicon.png'); -}); - -test('a leading slash in the requested path is tolerated', () => { - assert.equal(resolveAssetUrl('/favicon.png', BUILT_BASE), './favicon.png'); -}); - -test('assetUrl falls back to a relative base outside a Vite bundle', () => { - assert.equal(assetUrl('favicon.png').startsWith('/'), false); -}); diff --git a/stats/src/lib/asset-url.ts b/stats/src/lib/asset-url.ts deleted file mode 100644 index 9a8e8d2f..00000000 --- a/stats/src/lib/asset-url.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Resolve a bundled public asset against Vite's base URL. - * - * The in-player stats window is loaded with `loadFile`, so the document lives on - * `file://`. A root-absolute path like `/favicon.png` resolves to the filesystem - * root there and 404s, while the HTTP-served web app resolves it fine. Vite - * rewrites asset refs in `index.html` but not string literals in JSX, so build - * the URL from the configured base instead of hardcoding a leading slash. - */ -export function resolveAssetUrl(path: string, base: string): string { - const normalizedBase = base.endsWith('/') ? base : `${base}/`; - return `${normalizedBase}${path.replace(/^\/+/, '')}`; -} - -function currentBase(): string { - // Vite injects BASE_URL at build time ('./' per vite.config.ts) and serves '/' - // in dev. Outside a Vite bundle (tests) there is no env, so fall back to './'. - const env = (import.meta as { env?: Record }).env; - return env?.BASE_URL || './'; -} - -export function assetUrl(path: string): string { - return resolveAssetUrl(path, currentBase()); -} diff --git a/stats/src/lib/media-library-grouping.test.tsx b/stats/src/lib/media-library-grouping.test.tsx index e57edf04..11448581 100644 --- a/stats/src/lib/media-library-grouping.test.tsx +++ b/stats/src/lib/media-library-grouping.test.tsx @@ -169,14 +169,14 @@ test('CoverImage renders explicit remote artwork when src is provided', () => { test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => { const markup = renderToStaticMarkup( {}} />); - assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/); + assert.match(markup, /src="\/api\/stats\/media\/1\/cover"/); assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/); }); test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => { assert.equal( resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2), - 'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2', + '/api/stats/media/1/cover?coverRetry=2', ); }); From 4dd30f44d191003cddf434b4495595ff6760d62f Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 21 Sep 2026 00:09:48 -0700 Subject: [PATCH 8/9] chore(yomitan): update fork to upstream 26.9.8 --- changes/yomitan-upstream-26-9-8.md | 4 ++++ vendor/subminer-yomitan | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changes/yomitan-upstream-26-9-8.md diff --git a/changes/yomitan-upstream-26-9-8.md b/changes/yomitan-upstream-26-9-8.md new file mode 100644 index 00000000..1e069022 --- /dev/null +++ b/changes/yomitan-upstream-26-9-8.md @@ -0,0 +1,4 @@ +type: changed +area: yomitan + +- Updated bundled Yomitan with upstream 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval. diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan index 99d6bf85..57516d3b 160000 --- a/vendor/subminer-yomitan +++ b/vendor/subminer-yomitan @@ -1 +1 @@ -Subproject commit 99d6bf853ccf94f10114df5834d5abc68bc8ab55 +Subproject commit 57516d3b7f3bffa604f575026cd39390067137ce From 1508863dbb09039c6adf3c25dc948bb06018ac9a Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 21 Sep 2026 00:10:00 -0700 Subject: [PATCH 9/9] feat(stats): add TMDB metadata for live-action dramas in the Library (#252) --- .github/workflows/package-release.yml | 8 + .github/workflows/prerelease.yml | 1 + .github/workflows/release.yml | 1 + changes/tmdb-linking-docs.md | 4 + changes/tmdb-live-action-library.md | 8 + config.example.jsonc | 10 + docs-site/anilist-integration.md | 2 + docs-site/configuration.md | 27 ++ docs-site/immersion-tracking.md | 10 +- docs-site/public/config.example.jsonc | 10 + docs/RELEASING.md | 5 +- docs/architecture/domains.md | 1 + .../test-support/immersion-db-schema.test.ts | 2 +- launcher/test-support/immersion-db-schema.ts | 8 +- scripts/bundled-integration-keys.mjs | 29 ++ scripts/bundled-integration-keys.test.ts | 39 ++ scripts/prepare-build-assets.mjs | 13 + src/config/definitions.ts | 2 + .../definitions/defaults-integrations.ts | 5 + .../definitions/options-integrations.ts | 14 + src/config/definitions/template-sections.ts | 8 + src/config/hot-reload.ts | 2 +- src/config/resolve/integrations.ts | 13 + src/config/settings/registry.test.ts | 3 + src/config/settings/registry.ts | 12 +- .../services/__tests__/stats-server.test.ts | 69 ++++ .../anilist/cover-art-fetcher.test.ts | 197 +++++++++ .../services/anilist/cover-art-fetcher.ts | 109 ++++- .../immersion-tracker-service.test.ts | 88 ++++ .../services/immersion-tracker-service.ts | 187 ++++++--- .../__tests__/live-action-link.test.ts | 309 ++++++++++++++ .../services/immersion-tracker/anime-merge.ts | 45 +- .../immersion-tracker/anime-season-repair.ts | 162 ++++---- .../immersion-tracker/live-action-link.ts | 179 ++++++++ .../immersion-tracker/query-library.ts | 6 + .../immersion-tracker/query-maintenance.ts | 23 ++ .../services/immersion-tracker/storage.ts | 65 ++- src/core/services/immersion-tracker/types.ts | 9 +- .../immersion-tracker/youtube-kind.test.ts | 56 ++- src/core/services/stats-server.ts | 4 + .../stats-server/integration-routes.ts | 50 ++- .../services/stats-server/library-routes.ts | 11 +- .../services/stats-sync/merge-catalog.test.ts | 60 +++ src/core/services/stats-sync/merge-catalog.ts | 65 ++- .../services/tmdb/bundled-api-key.test.ts | 22 + src/core/services/tmdb/bundled-api-key.ts | 20 + .../tmdb/live-action-resolver.test.ts | 108 +++++ .../services/tmdb/live-action-resolver.ts | 75 ++++ src/core/services/tmdb/tmdb-client.test.ts | 335 +++++++++++++++ src/core/services/tmdb/tmdb-client.ts | 311 ++++++++++++++ src/main.ts | 17 + src/main/runtime/stats-server-runtime.ts | 9 + src/prerelease-workflow.test.ts | 22 +- src/shared/media-kind.ts | 38 +- src/stats-daemon-runner.ts | 15 +- src/types/config.ts | 6 + src/types/integrations.ts | 6 + src/types/stats-http-contract.ts | 24 ++ src/types/stats-wire.ts | 7 +- stats/src/components/anime/AnimeCard.test.tsx | 2 + stats/src/components/anime/AnimeCard.tsx | 2 +- .../components/anime/AnimeCoverImage.test.tsx | 2 + .../src/components/anime/AnimeDetailView.tsx | 30 +- .../anime/AnimeDialogAccessibility.test.tsx | 2 + .../src/components/anime/AnimeHeader.test.tsx | 42 ++ stats/src/components/anime/AnimeHeader.tsx | 45 +- .../src/components/anime/AnimeMergeDialog.tsx | 36 +- .../components/anime/AnimeMergeFlow.test.tsx | 49 +++ stats/src/components/anime/AnimeTab.test.tsx | 41 ++ stats/src/components/anime/AnimeTab.tsx | 30 +- .../src/components/anime/EpisodeMove.test.tsx | 2 + .../components/anime/LibraryEntryPicker.tsx | 6 +- .../components/anime/TmdbSelector.test.tsx | 387 ++++++++++++++++++ stats/src/components/anime/TmdbSelector.tsx | 199 +++++++++ stats/src/lib/api-client.ts | 10 + stats/src/lib/yomitan-lookup.test.tsx | 2 + 76 files changed, 3595 insertions(+), 238 deletions(-) create mode 100644 changes/tmdb-linking-docs.md create mode 100644 changes/tmdb-live-action-library.md create mode 100644 scripts/bundled-integration-keys.mjs create mode 100644 scripts/bundled-integration-keys.test.ts create mode 100644 src/core/services/immersion-tracker/__tests__/live-action-link.test.ts create mode 100644 src/core/services/immersion-tracker/live-action-link.ts create mode 100644 src/core/services/stats-sync/merge-catalog.test.ts create mode 100644 src/core/services/tmdb/bundled-api-key.test.ts create mode 100644 src/core/services/tmdb/bundled-api-key.ts create mode 100644 src/core/services/tmdb/live-action-resolver.test.ts create mode 100644 src/core/services/tmdb/live-action-resolver.ts create mode 100644 src/core/services/tmdb/tmdb-client.test.ts create mode 100644 src/core/services/tmdb/tmdb-client.ts create mode 100644 stats/src/components/anime/TmdbSelector.test.tsx create mode 100644 stats/src/components/anime/TmdbSelector.tsx diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index b3ce8b14..62feb3f5 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -13,6 +13,9 @@ on: required: true APPLE_TEAM_ID: required: true + # Project TMDB key baked into release artifacts; builds stay valid without it. + SUBMINER_TMDB_API_KEY: + required: false permissions: contents: read @@ -69,6 +72,8 @@ jobs: - name: Build AppImage run: bun run build:appimage + env: + SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }} - name: Build unversioned AppImage run: | @@ -168,6 +173,7 @@ jobs: - name: Build signed + notarized macOS artifacts run: bun run build:mac env: + SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} @@ -248,6 +254,8 @@ jobs: - name: Build unsigned Windows artifacts run: bun run build:win:unsigned + env: + SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }} - name: Smoke packaged runtime assets shell: bash diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 485a83d3..fc64e1b9 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -27,6 +27,7 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }} release: needs: [package] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09a1cd57..a608aaf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }} release: needs: [package] diff --git a/changes/tmdb-linking-docs.md b/changes/tmdb-linking-docs.md new file mode 100644 index 00000000..5b852a42 --- /dev/null +++ b/changes/tmdb-linking-docs.md @@ -0,0 +1,4 @@ +type: docs +area: stats + +- Documented provider reassignment, merge compatibility, and TMDB credential command caching and retry cooldown. diff --git a/changes/tmdb-live-action-library.md b/changes/tmdb-live-action-library.md new file mode 100644 index 00000000..ca14661c --- /dev/null +++ b/changes/tmdb-live-action-library.md @@ -0,0 +1,8 @@ +type: added +area: stats + +- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source. +- Unlinked titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand. +- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library kind selector gained a Live Action option alongside Anime and YouTube. +- Provider reassignment preserves the previous link and artwork if the replacement download fails, and refreshes completion totals when the episode count changes. Merges and sync keep conflicting AniList and TMDB identities separate, and the merge dialog explains when a selection mixes the two instead of failing. +- TMDB credential commands cache successful output and wait 30 seconds before retrying failed or empty output, using the bundled key in the meantime when available. diff --git a/config.example.jsonc b/config.example.jsonc index 56b01757..8e9a3cd6 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -659,6 +659,16 @@ "maxSearchResults": 10 // Maximum TsukiHime search results returned. }, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required. + // ========================================== + // TMDB + // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show. + // Hot-reload: TMDB changes apply to the next TMDB request. + // ========================================== + "tmdb": { + "apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org). + "apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text. + }, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show. + // ========================================== // YouTube Playback Settings // Defaults for managed subtitle language preferences and YouTube subtitle loading. diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index 23a22c76..5b36cd75 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -71,6 +71,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media. +When AniList has no match, SubMiner tries [TMDB](/configuration#tmdb) next so live-action dramas and movies get a poster and synopsis too. See [Immersion tracking](/immersion-tracking#library) for how live-action entries are grouped. + If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day. ## Rate limiting diff --git a/docs-site/configuration.md b/docs-site/configuration.md index a594ec30..89d2ab40 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -156,6 +156,7 @@ The configuration file includes several main sections: - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download +- [**TMDB**](#tmdb) - Posters and synopses for live-action dramas and movies in the stats Library - [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**AniList**](#anilist) - Optional post-watch progress updates - [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile @@ -1158,6 +1159,32 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting. +### TMDB + +TMDB (The Movie Database) supplies posters, synopses, and show grouping for live-action dramas and movies in the stats [Library](/immersion-tracking#library). AniList only covers anime, so TMDB is what gives live-action titles a cover and a description. + +Release builds ship with a project TMDB key, so nothing needs to be configured. Set your own key to use your own quota, or when running SubMiner from source, where no key is bundled. Create one for free under **Settings > API** on [themoviedb.org](https://www.themoviedb.org/settings/api); either the short API key or the long "API Read Access Token" works. + +```json +{ + "tmdb": { + "apiKey": "", + "apiKeyCommand": "cat ~/.tmdb_key" + } +} +``` + +| Option | Values | Description | +| -------------------- | ------ | -------------------------------------------------------------------------------------------------- | +| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) | +| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config | + +Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output uses the bundled key when available and waits 30 seconds before the next request can retry the command. Changing either credential setting resets this cooldown. + +Changes apply to the next TMDB request without a restart. + +This product uses the TMDB API but is not endorsed or certified by TMDB. + ### Japanese subtitle generation Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index d81eb34e..de40a036 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -83,14 +83,16 @@ Local files and Jellyfin items with detected season numbers are split into seaso When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries. +**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** / **Anime** / **Live Action** / **YouTube** selector above the grid narrows the Library to one kind, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry. Changing providers downloads the replacement cover before saving the new link; a failed download leaves the previous link and artwork intact. A title without a cover clears the previous artwork. Automatic TMDB matching leaves existing AniList links unchanged. + Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard. -When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use **All Titles**, **Anime**, or **YouTube** above the grid to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations. +When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use the kind selector above the grid (**All Titles**, **Anime**, **Live Action**, **YouTube**) to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations. A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available: -- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. Anime entries and YouTube channels cannot be merged into each other. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card. -- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry of the same kind, so a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid. +- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. AniList-linked and TMDB-linked entries cannot be merged together, and YouTube channels cannot be merged with anime or live-action entries. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card. +- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. Anime and live-action entries are interchangeable here, but a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid. Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested. @@ -364,7 +366,7 @@ The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion Core tables: - `imm_videos` - video key/title/source metadata -- `imm_anime` - anime/series or YouTube channel metadata (`media_kind`) referenced by videos and lifetime tables +- `imm_anime` - series or YouTube channel metadata referenced by videos and lifetime tables, including the media kind (`anime`, `live_action` or `youtube`) and the AniList or TMDB link - `imm_anime_title_aliases` - alternate titles that resolve to the same anime row - `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard - `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 56b01757..8e9a3cd6 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -659,6 +659,16 @@ "maxSearchResults": 10 // Maximum TsukiHime search results returned. }, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required. + // ========================================== + // TMDB + // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show. + // Hot-reload: TMDB changes apply to the next TMDB request. + // ========================================== + "tmdb": { + "apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org). + "apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text. + }, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show. + // ========================================== // YouTube Playback Settings // Defaults for managed subtitle language preferences and YouTube subtitle loading. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 8438610c..ce583053 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -15,7 +15,10 @@ Stable and prerelease workflows share `.github/workflows/package-release.yml`. Both callers explicitly pass the five required macOS signing/notarization -secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow. +secrets plus the optional `SUBMINER_TMDB_API_KEY` (the project TMDB key that +`scripts/prepare-build-assets.mjs` stages into `dist/bundled-integration-keys.json`; +artifacts built without it simply require users to set `tmdb.apiKey`). +`GITHUB_TOKEN` remains automatically available to the reusable workflow. Each platform verifies its ASAR and external resources before signing, then measures the signed app and installers before upload. Missing runtime assets, foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps, diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index f0d131f1..b9272663 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -30,6 +30,7 @@ Read when: you need to find the owner module for a behavior or test surface - Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`. `snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it. - AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/` +- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair. - Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*` - Window trackers: `src/window-trackers/` - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support diff --git a/launcher/test-support/immersion-db-schema.test.ts b/launcher/test-support/immersion-db-schema.test.ts index 68632178..22d71d2e 100644 --- a/launcher/test-support/immersion-db-schema.test.ts +++ b/launcher/test-support/immersion-db-schema.test.ts @@ -31,7 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [ 'imm_lifetime_applied_sessions', 'imm_stats_excluded_words', 'idx_anime_normalized_title', - 'idx_anime_kind_title', + 'idx_anime_namespace_title', 'idx_anime_anilist_id', 'idx_videos_anime_id', 'idx_sessions_video_started', diff --git a/launcher/test-support/immersion-db-schema.ts b/launcher/test-support/immersion-db-schema.ts index 32d68730..dcf1c681 100644 --- a/launcher/test-support/immersion-db-schema.ts +++ b/launcher/test-support/immersion-db-schema.ts @@ -20,12 +20,14 @@ export const IMMERSION_DB_FIXTURE_DDL = ` title_native TEXT, episodes_total INTEGER, description TEXT, + media_kind TEXT NOT NULL DEFAULT 'anime', + tmdb_id INTEGER, + tmdb_type TEXT, metadata_json TEXT, CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT, - media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')) + LAST_UPDATE_DATE TEXT ); - CREATE UNIQUE INDEX idx_anime_kind_title ON imm_anime(media_kind, normalized_title_key); + CREATE UNIQUE INDEX idx_anime_namespace_title ON imm_anime((media_kind = 'youtube'), normalized_title_key); CREATE TABLE imm_videos( video_id INTEGER PRIMARY KEY AUTOINCREMENT, video_key TEXT NOT NULL UNIQUE, diff --git a/scripts/bundled-integration-keys.mjs b/scripts/bundled-integration-keys.mjs new file mode 100644 index 00000000..de82872b --- /dev/null +++ b/scripts/bundled-integration-keys.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Release builds carry a project-owned TMDB key so live-action lookups work + * without user setup. The key is injected from the SUBMINER_TMDB_API_KEY + * environment variable at build time (a GitHub Actions secret in CI) and never + * lives in the repository. The runtime reader is + * src/core/services/tmdb/bundled-api-key.ts; keep the file name in sync. + */ +export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json'; +export const TMDB_API_KEY_ENV = 'SUBMINER_TMDB_API_KEY'; + +/** + * Write the bundled keys file into `distDir`, or remove a stale one when no + * key is present so a keyless build never ships an older key by accident. + * Returns the names of the keys staged. + */ +export function stageBundledIntegrationKeys(distDir, env = process.env) { + const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME); + const tmdbApiKey = env[TMDB_API_KEY_ENV]?.trim() ?? ''; + if (!tmdbApiKey) { + fs.rmSync(outputPath, { force: true }); + return []; + } + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify({ tmdbApiKey })}\n`, { mode: 0o644 }); + return ['tmdb']; +} diff --git a/scripts/bundled-integration-keys.test.ts b/scripts/bundled-integration-keys.test.ts new file mode 100644 index 00000000..09d8283e --- /dev/null +++ b/scripts/bundled-integration-keys.test.ts @@ -0,0 +1,39 @@ +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 { + BUNDLED_INTEGRATION_KEYS_FILENAME, + stageBundledIntegrationKeys, +} from './bundled-integration-keys.mjs'; + +function withDistDir(work: (distDir: string) => void): void { + const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-keys-')); + try { + work(distDir); + } finally { + fs.rmSync(distDir, { recursive: true, force: true }); + } +} + +test('stageBundledIntegrationKeys writes the TMDB key from the environment', () => { + withDistDir((distDir) => { + const staged = stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' abc123 ' }); + assert.deepEqual(staged, ['tmdb']); + const written = JSON.parse( + fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'), + ); + assert.deepEqual(written, { tmdbApiKey: 'abc123' }); + }); +}); + +test('stageBundledIntegrationKeys removes a stale file when the variable is unset', () => { + withDistDir((distDir) => { + const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME); + fs.writeFileSync(outputPath, '{"tmdbApiKey":"old"}'); + assert.deepEqual(stageBundledIntegrationKeys(distDir, {}), []); + assert.equal(fs.existsSync(outputPath), false); + assert.deepEqual(stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' ' }), []); + }); +}); diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index ac49e4a4..652b7c4c 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { stageBundledIntegrationKeys, TMDB_API_KEY_ENV } from './bundled-integration-keys.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -97,6 +98,17 @@ function buildMacosHelper() { } } +// Only the key names are logged, never the values: CI masks secrets, but a +// stray echo would still leak them into local build logs. +function stageIntegrationKeys() { + const staged = stageBundledIntegrationKeys(path.join(repoRoot, 'dist')); + process.stdout.write( + staged.length > 0 + ? `Staged bundled integration keys: ${staged.join(', ')}\n` + : `No bundled integration keys (${TMDB_API_KEY_ENV} unset)\n`, + ); +} + function main() { fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), { recursive: true, @@ -106,6 +118,7 @@ function main() { copySettingsAssets(); copySyncUiAssets(); buildMacosHelper(); + stageIntegrationKeys(); } main(); diff --git a/src/config/definitions.ts b/src/config/definitions.ts index ad54e2ac..122119c0 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -42,6 +42,7 @@ const { ankiConnect, jimaku, tsukihime, + tmdb, anilist, mpv, yomitan, @@ -76,6 +77,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { auto_start_overlay, jimaku, tsukihime, + tmdb, anilist, mpv, yomitan, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 9f0c2ddd..69e2b90b 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -6,6 +6,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< | 'ankiConnect' | 'jimaku' | 'tsukihime' + | 'tmdb' | 'anilist' | 'mpv' | 'yomitan' @@ -113,6 +114,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< apiBaseUrl: 'https://api.tsukihime.org/v1', maxSearchResults: 10, }, + tmdb: { + apiKey: '', + apiKeyCommand: '', + }, mpv: { executablePath: '', launchMode: 'normal', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 433f3fcd..68545d92 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -479,6 +479,20 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.tsukihime.maxSearchResults, description: 'Maximum TsukiHime search results returned.', }, + { + path: 'tmdb.apiKey', + kind: 'string', + defaultValue: defaultConfig.tmdb.apiKey, + description: + 'Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).', + }, + { + path: 'tmdb.apiKeyCommand', + kind: 'string', + defaultValue: defaultConfig.tmdb.apiKeyCommand, + description: + 'Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.', + }, { path: 'anilist.enabled', kind: 'boolean', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index ddd20a30..d7aa3c89 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -164,6 +164,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'], key: 'tsukihime', }, + { + title: 'TMDB', + description: [ + 'TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.', + ], + notes: ['Hot-reload: TMDB changes apply to the next TMDB request.'], + key: 'tmdb', + }, { title: 'YouTube Playback Settings', description: [ diff --git a/src/config/hot-reload.ts b/src/config/hot-reload.ts index e2f068c5..d54ab633 100644 --- a/src/config/hot-reload.ts +++ b/src/config/hot-reload.ts @@ -54,7 +54,7 @@ export function getConfigHotReloadField(path: string): string | null { // These consumers read the current config when the next operation starts. if ( - ['jimaku', 'subsync', 'notifications', 'subtitleGeneration'].some((root) => + ['jimaku', 'tmdb', 'subsync', 'notifications', 'subtitleGeneration'].some((root) => pathStartsWith(path, root), ) ) { diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 6324eaee..952e6362 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -58,6 +58,19 @@ export function applyIntegrationConfig(context: ResolveContext): void { warn('ai', src.ai, resolved.ai, 'Expected object.'); } + if (isObject(src.tmdb)) { + for (const key of ['apiKey', 'apiKeyCommand'] as const) { + const value = asString(src.tmdb[key]); + if (value !== undefined) { + resolved.tmdb[key] = value; + } else if (src.tmdb[key] !== undefined) { + warn(`tmdb.${key}`, src.tmdb[key], resolved.tmdb[key], 'Expected string.'); + } + } + } else if (src.tmdb !== undefined) { + warn('tmdb', src.tmdb, resolved.tmdb, 'Expected object.'); + } + if (isObject(src.anilist)) { const enabled = asBoolean(src.anilist.enabled); if (enabled !== undefined) { diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 023bc1d5..7a8229ad 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -275,6 +275,9 @@ test('settings registry routes playback-related integrations into integrations', assert.equal(field('subsync.replace').section, 'Subtitle Sync'); assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations'); assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime'); + assert.equal(field('tmdb.apiKey').category, 'integrations'); + assert.equal(field('tmdb.apiKey').section, 'TMDB'); + assert.equal(field('tmdb.apiKey').secret, true); }); test('settings registry puts feature toggles first, then other toggles alphabetically', () => { diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index ccb90183..458f7468 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -93,7 +93,12 @@ const JSON_OBJECT_FIELDS = new Set([ 'subtitleSidebar.css', ]); -export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']); +export const SECRET_PATHS = new Set([ + 'ai.apiKey', + 'jimaku.apiKey', + 'tmdb.apiKey', + 'anilist.accessToken', +]); const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']); const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([ @@ -135,6 +140,7 @@ const SECTION_ORDER = new Map( 'Anki AI', 'AnkiConnect Proxy', 'Jimaku', + 'TMDB', 'Subtitle Sync', 'MPV Keybindings', 'Overlay Shortcuts', @@ -327,6 +333,7 @@ function humanizePath(path: string): string { .replace(/\bmpv\b/i, 'mpv') .replace(/\byomitan\b/i, 'Yomitan') .replace(/\bjimaku\b/i, 'Jimaku') + .replace(/\btmdb\b/i, 'TMDB') .replace(/\banilist\b/i, 'AniList') .replace(/\banki\b/i, 'Anki'); return spaced.charAt(0).toUpperCase() + spaced.slice(1); @@ -442,7 +449,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if (path.startsWith('mpv.') || path.startsWith('youtube.')) { return { category: 'behavior', section: topSection(path) }; } - if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) { + if (path.startsWith('jimaku.') || path.startsWith('tsukihime.') || path.startsWith('tmdb.')) { return { category: 'integrations', section: topSection(path) }; } if (path.startsWith('subsync.')) { @@ -510,6 +517,7 @@ function topSection(path: string): string { subsync: 'Subtitle Sync', texthooker: 'Texthooker', tsukihime: 'TsukiHime', + tmdb: 'TMDB', updates: 'Updates', websocket: 'WebSocket server', yomitan: 'Yomitan', diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 7f32ada2..eefc9aff 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -11,6 +11,7 @@ import { startStatsServerWithRuntime, } from '../stats-server.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; +import { INCOMPATIBLE_PROVIDER_MERGE_MESSAGE } from '../immersion-tracker/anime-merge.js'; import { clearRetimedSecondarySubtitleCache, resolveRetimedSecondarySubtitleTextFromSidecar, @@ -311,6 +312,7 @@ function createMockTracker( getKanjiOccurrences: async () => OCCURRENCES, getAnimeLibrary: async () => ANIME_LIBRARY, getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null), + hasAnime: async (animeId: number) => animeId === 1, getAnimeEpisodes: async () => ANIME_EPISODES, getAnimeAnilistEntries: async () => [], getAnimeWords: async () => ANIME_WORDS, @@ -3729,6 +3731,25 @@ Aligned English subtitle assert.equal(res.status, 404); }); + it('POST /api/stats/anime/:animeId/merge rejects mixed AniList and TMDB entries as 409', async () => { + const app = createStatsApp( + createMockTracker({ + mergeAnime: async () => { + throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE); + }, + } as Partial), + ); + + const res = await app.request('/api/stats/anime/7/merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"sourceAnimeIds":[8]}', + }); + + assert.equal(res.status, 409); + assert.deepEqual(await res.json(), { error: INCOMPATIBLE_PROVIDER_MERGE_MESSAGE }); + }); + it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => { const app = createStatsApp( createMockTracker({ @@ -4252,3 +4273,51 @@ Aligned English subtitle }); }); }); + +it('TMDB reassignment returns 404 for a missing library entry before fetching details', async () => { + const assignments: number[] = []; + let fetches = 0; + const app = createStatsApp( + createMockTracker({ + reassignAnimeTmdb: async (animeId: number) => { + assignments.push(animeId); + return { animeId, mergedAnimeIds: [] }; + }, + }), + { + tmdbClient: { + search: async () => [], + getDetails: async () => { + fetches += 1; + return { + tmdbId: 12, + tmdbType: 'tv', + titleEnglish: 'Drama', + titleNative: null, + description: null, + posterUrl: null, + episodesTotal: 10, + year: null, + originalLanguage: 'ja', + isAnimation: false, + allTitles: ['Drama'], + }; + }, + }, + }, + ); + const request = (animeId: number) => + app.request(`/api/stats/anime/${animeId}/tmdb`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tmdbId: 12, tmdbType: 'tv' }), + }); + assert.equal((await request(99999)).status, 404); + assert.equal(fetches, 0); + assert.deepEqual(assignments, []); + const response = await request(1); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.equal(fetches, 1); + assert.deepEqual(assignments, [1]); +}); diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index 1d2462dc..6851ad0a 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -540,3 +540,200 @@ test('fetchIfMissing re-resolves an unresolved season once AniList publishes the cleanupDbPath(dbPath); } }); + +for (const linkedToAnilist of [false, true]) { + test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + ensureSchema(db); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', { + canonicalTitle: 'Hanzawa Naoki - 01.mkv', + sourcePath: '/tmp/hanzawa-01.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Hanzawa Naoki', + canonicalTitle: 'Hanzawa Naoki', + anilistId: linkedToAnilist ? 42 : null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: null, + parsedTitle: 'Hanzawa Naoki', + parsedSeason: null, + parsedEpisode: 1, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: null, + }); + + const fetchCalls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + fetchCalls.push(url); + if (url.startsWith('https://graphql.anilist.co')) { + return createJsonResponse({ data: { Page: { media: [] } } }); + } + assert.equal(url, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg'); + return new Response(new Uint8Array([5, 6, 7]), { + status: 200, + headers: { 'Content-Type': 'image/jpeg' }, + }); + }) as typeof fetch; + + const resolvedTitles: string[] = []; + try { + const fetcher = createCoverArtFetcher( + { acquire: async () => {}, recordResponse: () => {} }, + console, + { + runGuessit: async () => { + throw new Error('guessit unavailable'); + }, + liveAction: { + async resolveByTitle(title) { + resolvedTitles.push(title); + if (title !== 'Hanzawa Naoki') return null; + return { + tmdbId: 61222, + tmdbType: 'tv', + titleEnglish: 'Hanzawa Naoki', + titleNative: '半沢直樹', + description: 'A banker fights back.', + posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg', + episodesTotal: 10, + year: 2013, + originalLanguage: 'ja', + isAnimation: false, + allTitles: ['Hanzawa Naoki', '半沢直樹'], + }; + }, + async resolveById() { + return null; + }, + }, + }, + ); + + const fetched = await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv'); + const stored = getCoverArt(db, videoId); + const anime = db + .prepare( + 'SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, description FROM imm_anime WHERE anime_id = ?', + ) + .get(animeId) as { mediaKind: string; tmdbId: number | null; description: string | null }; + + if (linkedToAnilist) { + assert.equal(fetched, false); + assert.equal(stored?.coverBlob, null); + assert.equal(stored?.coverUrl, null); + assert.equal(anime.mediaKind, 'anime'); + assert.equal(anime.tmdbId, null); + assert.deepEqual(resolvedTitles, []); + const requestCount = fetchCalls.length; + assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv'), false); + assert.equal(fetchCalls.length, requestCount); + return; + } + assert.equal(fetched, true); + // The raw fallback-parser title is tried first, then the tag-stripped one. + assert.deepEqual(resolvedTitles, ['Hanzawa Naoki - 01', 'Hanzawa Naoki']); + assert.equal(stored?.anilistId, null); + assert.equal(stored?.coverUrl, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg'); + assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '050607'); + assert.equal(anime.mediaKind, 'live_action'); + assert.equal(anime.tmdbId, 61222); + assert.equal(anime.description, 'A banker fights back.'); + assert.ok(fetchCalls.some((url) => url.startsWith('https://graphql.anilist.co'))); + } finally { + globalThis.fetch = originalFetch; + db.close(); + cleanupDbPath(dbPath); + } + }); +} + +test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + ensureSchema(db); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', { + canonicalTitle: 'Hanzawa Naoki - 02.mkv', + sourcePath: '/tmp/hanzawa-02.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Hanzawa Naoki', + canonicalTitle: 'Hanzawa Naoki', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: null, + parsedTitle: 'Hanzawa Naoki', + parsedSeason: null, + parsedEpisode: 2, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: null, + }); + db.prepare( + "UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?", + ).run(animeId); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + assert.equal(String(input), 'https://image.tmdb.org/t/p/w500/hanzawa.jpg'); + return new Response(new Uint8Array([1]), { status: 200 }); + }) as typeof fetch; + + const byIdCalls: Array<[string, number]> = []; + try { + const fetcher = createCoverArtFetcher( + { acquire: async () => {}, recordResponse: () => {} }, + console, + { + liveAction: { + async resolveByTitle() { + throw new Error('title search must not run for a linked entry'); + }, + async resolveById(tmdbType, tmdbId) { + byIdCalls.push([tmdbType, tmdbId]); + return { + tmdbId, + tmdbType, + titleEnglish: 'Hanzawa Naoki', + titleNative: null, + description: null, + posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg', + episodesTotal: 10, + year: null, + originalLanguage: 'ja', + isAnimation: false, + allTitles: [], + }; + }, + }, + }, + ); + + assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 02.mkv'), true); + assert.deepEqual(byIdCalls, [['tv', 61222]]); + assert.equal(getCoverArt(db, videoId)?.coverBlob?.length, 1); + } finally { + globalThis.fetch = originalFetch; + db.close(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index 13b85c64..10d20c40 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -16,6 +16,9 @@ import { type AnilistQueryExecutor, type AnilistSeasonResolution, } from './season-resolver'; +import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../immersion-tracker/live-action-link'; +import type { LiveActionMetadataResolver } from '../tmdb/live-action-resolver'; +import type { TmdbTitleDetails } from '../tmdb/tmdb-client'; const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; const NO_MATCH_RETRY_MS = 5 * 60 * 1000; @@ -39,6 +42,8 @@ interface CoverArtCandidate { interface CoverArtFetcherOptions { runGuessit?: GuessAnilistMediaInfoDeps['runGuessit']; + /** Live-action fallback consulted when AniList has no match for a title. */ + liveAction?: LiveActionMetadataResolver; } export function stripFilenameTags(raw: string): string { @@ -152,6 +157,60 @@ export function createCoverArtFetcher( return true; }; + const cacheNoMatch = (db: DatabaseSync, videoId: number): void => { + upsertCoverArt(db, videoId, { + anilistId: null, + coverUrl: null, + coverBlob: null, + titleRomaji: null, + titleEnglish: null, + episodesTotal: null, + }); + }; + + // Links the video's library entry to the TMDB title and stores its poster. + const storeLiveActionArt = async ( + db: DatabaseSync, + videoId: number, + details: TmdbTitleDetails, + ): Promise => { + const row = db + .prepare( + `SELECT v.anime_id AS animeId, a.anilist_id AS anilistId + FROM imm_videos v LEFT JOIN imm_anime a ON a.anime_id = v.anime_id + WHERE v.video_id = ?`, + ) + .get(videoId) as { animeId: number | null; anilistId: number | null } | undefined; + if (row?.anilistId != null) return false; + if (row?.animeId) { + const link = linkAnimeToTmdbTitle(db, row.animeId, details, { mode: 'auto' }); + if (link.mergedAnimeIds.length > 0) { + logger.info( + 'cover-art: folded library entries %s into %d (same TMDB title)', + link.mergedAnimeIds.join(','), + link.animeId, + ); + } + } + const coverBlob = details.posterUrl ? await downloadImage(details.posterUrl) : null; + upsertCoverArt(db, videoId, { + anilistId: null, + coverUrl: details.posterUrl, + coverBlob, + titleRomaji: null, + titleEnglish: details.titleEnglish, + episodesTotal: details.episodesTotal, + }); + logger.info( + 'cover-art: linked videoId=%d to TMDB %s/%d "%s"', + videoId, + details.tmdbType, + details.tmdbId, + details.titleEnglish ?? details.titleNative ?? '', + ); + return coverBlob !== null; + }; + const resolveCanonicalTitle = ( db: DatabaseSync, videoId: number, @@ -197,7 +256,7 @@ export function createCoverArtFetcher( ` SELECT 1 FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id - WHERE v.video_id = ? AND a.media_kind != 'anime' + WHERE v.video_id = ? AND a.media_kind = 'youtube' `, ) .get(videoId); @@ -235,18 +294,31 @@ export function createCoverArtFetcher( return false; } + // A live-action entry already knows its TMDB title; AniList has nothing + // to add and would only produce a spurious anime match. + const hasAnilistLink = Boolean( + db + .prepare( + `SELECT 1 FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id + WHERE v.video_id = ? AND a.anilist_id IS NOT NULL`, + ) + .get(videoId), + ); + const tmdbLink = getVideoTmdbLink(db, videoId); + if (tmdbLink && !hasAnilistLink) { + const details = await options.liveAction?.resolveById(tmdbLink.tmdbType, tmdbLink.tmdbId); + if (details) { + return storeLiveActionArt(db, videoId, details); + } + cacheNoMatch(db, videoId); + return false; + } + const effectiveTitle = resolveCanonicalTitle(db, videoId, canonicalTitle); const cleaned = stripFilenameTags(effectiveTitle); if (!cleaned) { logger.warn('cover-art: empty title after stripping tags for videoId=%d', videoId); - upsertCoverArt(db, videoId, { - anilistId: null, - coverUrl: null, - coverBlob: null, - titleRomaji: null, - titleEnglish: null, - episodesTotal: null, - }); + cacheNoMatch(db, videoId); return false; } @@ -304,15 +376,16 @@ export function createCoverArtFetcher( const selected = resolution?.media ?? null; if (!selected) { - logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase); - upsertCoverArt(db, videoId, { - anilistId: null, - coverUrl: null, - coverBlob: null, - titleRomaji: null, - titleEnglish: null, - episodesTotal: null, - }); + if (options.liveAction && !hasAnilistLink) { + for (const searchTitle of searchTitles) { + const details = await options.liveAction.resolveByTitle(searchTitle); + if (details) { + return storeLiveActionArt(db, videoId, details); + } + } + } + logger.info('cover-art: no Anilist or TMDB results for "%s", caching no-match', searchBase); + cacheNoMatch(db, videoId); return false; } diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 543ddb6e..6e21246d 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -5394,3 +5394,91 @@ test('getVocabularySummary keeps different known-word snapshots independent', as cleanupDbPath(dbPath); } }); + +for (const provider of ['anilist', 'tmdb'] as const) { + test(`${provider} reassignment keeps metadata and artwork on download failure, then replaces or clears both`, async () => { + const dbPath = makeDbPath(); + const originalFetch = globalThis.fetch; + let tracker: ImmersionTrackerService | null = null; + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const { db } = tracker as unknown as { db: DatabaseSync }; + db.exec(` + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (1, 'show', 'Show', 1000, 1000); + INSERT INTO imm_videos(video_id, video_key, canonical_title, source_type, anime_id, duration_ms, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (1, 'local:/tmp/show.mkv', 'Show', 1, 1, 0, 1000, 1000); + `); + const tmdb = { + tmdbId: 12, + tmdbType: 'tv' as const, + titleEnglish: 'Drama', + titleNative: null, + description: 'New description', + episodesTotal: 10, + }; + globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3])); + if (provider === 'anilist') { + await tracker.reassignAnimeTmdb(1, { ...tmdb, posterUrl: 'https://images.test/old' }); + } else { + await tracker.reassignAnimeAnilist(1, { + anilistId: 42, + coverUrl: 'https://images.test/old', + }); + } + assert.equal(await tracker.hasAnime(1), true); + assert.equal(await tracker.hasAnime(999), false); + const readMetadata = () => + db.prepare('SELECT * FROM imm_anime WHERE anime_id = 1').get() as { + media_kind: string; + anilist_id: number | null; + tmdb_id: number | null; + }; + const before = readMetadata(); + const oldArt = await tracker.getAnimeCoverArt(1); + const snapshot = (value: unknown) => + JSON.stringify(value, (key, item: unknown) => (key === '_metadata' ? undefined : item)); + const reassign = (url: string | null) => + provider === 'anilist' + ? tracker!.reassignAnimeAnilist(1, { anilistId: 99, coverUrl: url }) + : tracker!.reassignAnimeTmdb(1, { ...tmdb, posterUrl: url }); + for (const failure of ['http', 'network']) { + globalThis.fetch = async () => { + if (failure === 'network') throw new Error('offline'); + return new Response(null, { status: 503 }); + }; + await assert.rejects(reassign('https://images.test/new')); + assert.equal(snapshot(readMetadata()), snapshot(before)); + assert.equal(snapshot(await tracker.getAnimeCoverArt(1)), snapshot(oldArt)); + } + globalThis.fetch = async () => new Response(new Uint8Array([9, 8, 7])); + if (provider === 'anilist') { + // Retained sessions without lifetime summaries exercise the bootstrap + // inside the reassignment transaction. + db.exec(`INSERT INTO imm_sessions(session_uuid, video_id, started_at_ms, ended_at_ms, + status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE) + VALUES ('retained-session', 1, '1000', '2000', 2, 1000, 1000, 2000)`); + } + await reassign('https://images.test/new'); + const detail = readMetadata(); + assert.equal(detail.media_kind, provider === 'anilist' ? 'anime' : 'live_action'); + assert.equal(detail.anilist_id, provider === 'anilist' ? 99 : null); + assert.equal(detail.tmdb_id, provider === 'tmdb' ? 12 : null); + if (provider === 'anilist') { + assert.equal((await tracker.getAnimeDetail(1))?.totalActiveMs, 1000); + } + assert.deepEqual( + new Uint8Array((await tracker.getAnimeCoverArt(1))!.coverBlob!), + new Uint8Array([9, 8, 7]), + ); + await reassign(null); + assert.equal(await tracker.getAnimeCoverArt(1), null); + assert.equal(readMetadata().media_kind, detail.media_kind); + } finally { + globalThis.fetch = originalFetch; + tracker?.destroy(); + cleanupDbPath(dbPath); + } + }); +} diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 1cdc84d7..8a286bc3 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -33,6 +33,7 @@ import { applySessionLifetimeSummary, reconcileStaleActiveSessions, rebuildLifetimeSummaries as rebuildLifetimeSummaryTables, + rebuildLifetimeSummariesInTransaction, recomputeLifetimeAnimeFromMedia, recomputeLifetimeGlobalFromSummaries, repairLifetimeSummariesFromMedia, @@ -90,6 +91,7 @@ import { } from './immersion-tracker/query-library'; import { cleanupVocabularyStats, + clearAnimeCoverArt, getVideoDurationMs, markVideoWatched, upsertCoverArt, @@ -115,7 +117,7 @@ import { dismissAnimeMergeRecommendation, getAnimeMergeRecommendations, repairLegacySeasonlessAnimeRows, - resolveAnimeAnilistConflict, + resolveAnimeAnilistConflictInTransaction, type AnimeMergeRecommendation, } from './immersion-tracker/anime-season-repair'; import { @@ -124,6 +126,11 @@ import { type AnimeMergeSummary, type VideoMoveSummary, } from './immersion-tracker/anime-merge'; +import { + linkAnimeToTmdbTitleInTransaction, + type LiveActionLinkResult, + type LiveActionTitleInput, +} from './immersion-tracker/live-action-link'; import { buildVideoKey, deriveCanonicalTitle, @@ -818,6 +825,10 @@ export class ImmersionTrackerService { return getAnimeDetail(this.db, animeId); } + async hasAnime(animeId: number): Promise { + return Boolean(this.db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId)); + } + async getAnimeEpisodes(animeId: number): Promise { return getAnimeEpisodes(this.db, animeId); } @@ -1016,19 +1027,31 @@ export class ImmersionTrackerService { coverUrl?: string | null; }, ): Promise { + const coverBlob = await this.downloadReplacementCover(info.coverUrl); this.requireWriteQueueDrained('reassigning an AniList entry'); - // The user is acting on this entry, so it is the one that survives when - // another row already claims the same AniList id. - const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, { - survivor: 'target', - matchConfidence: 'manual', - }); - if (repair.anilistAssignmentBlocked) return; - this.db - .prepare( - ` + this.db.exec('BEGIN IMMEDIATE'); + try { + this.db + .prepare('UPDATE imm_anime SET tmdb_id = NULL, tmdb_type = NULL WHERE anime_id = ?') + .run(animeId); + // The user is acting on this entry, so it is the one that survives when + // another row already claims the same AniList id. + const repair = resolveAnimeAnilistConflictInTransaction(this.db, animeId, info.anilistId, { + survivor: 'target', + matchConfidence: 'manual', + }); + if (repair.anilistAssignmentBlocked) { + this.db.exec('ROLLBACK'); + return; + } + this.db + .prepare( + ` UPDATE imm_anime SET anilist_id = ?, + media_kind = 'anime', + tmdb_id = NULL, + tmdb_type = NULL, title_romaji = COALESCE(?, title_romaji), title_english = COALESCE(?, title_english), title_native = COALESCE(?, title_native), @@ -1037,46 +1060,32 @@ export class ImmersionTrackerService { LAST_UPDATE_DATE = ? WHERE anime_id = ? `, - ) - .run( - info.anilistId, - info.titleRomaji ?? null, - info.titleEnglish ?? null, - info.titleNative ?? null, - info.episodesTotal ?? null, - info.description !== undefined ? 1 : 0, - info.description ?? null, - nowMs(), - animeId, - ); - // Empty lifetime tables still need the retained-session bootstrap. Once a - // media ledger exists, only the redistributed and explicitly edited anime - // can have changed. - if (shouldBackfillLifetimeSummaries(this.db)) { - repairLifetimeSummariesFromMedia(this.db); - } else { - const affectedAnimeIds = new Set(repair.affectedAnimeIds); - affectedAnimeIds.add(animeId); - recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]); - recomputeLifetimeGlobalFromSummaries(this.db); - } - - // Update cover art for all videos in this anime - if (info.coverUrl) { - const videos = this.db - .prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?') - .all(animeId) as Array<{ video_id: number }>; - let coverBlob: Buffer | null = null; - try { - const res = await fetch(info.coverUrl); - if (res.ok) { - coverBlob = Buffer.from(await res.arrayBuffer()); - } - } catch { - /* ignore */ + ) + .run( + info.anilistId, + info.titleRomaji ?? null, + info.titleEnglish ?? null, + info.titleNative ?? null, + info.episodesTotal ?? null, + info.description !== undefined ? 1 : 0, + info.description ?? null, + nowMs(), + animeId, + ); + // Empty lifetime tables still need the retained-session bootstrap. Once a + // media ledger exists, only the redistributed and explicitly edited anime + // can have changed. + if (shouldBackfillLifetimeSummaries(this.db)) { + rebuildLifetimeSummariesInTransaction(this.db); + } else { + const affectedAnimeIds = new Set(repair.affectedAnimeIds); + affectedAnimeIds.add(animeId); + recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]); + recomputeLifetimeGlobalFromSummaries(this.db); } - for (const v of videos) { - upsertCoverArt(this.db, v.video_id, { + + if (info.coverUrl) { + this.applyCoverArtToAnimeVideos(animeId, { anilistId: info.anilistId, coverUrl: info.coverUrl, coverBlob, @@ -1084,7 +1093,85 @@ export class ImmersionTrackerService { titleEnglish: info.titleEnglish ?? null, episodesTotal: info.episodesTotal ?? null, }); + } else { + clearAnimeCoverArt(this.db, animeId); } + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + + /** + * Link a library entry to a TMDB title chosen in the dashboard. Every other + * entry pointing at the same title is folded into this one, and its poster + * replaces the art of every episode. + */ + async reassignAnimeTmdb( + animeId: number, + details: LiveActionTitleInput & { posterUrl: string | null }, + ): Promise { + const coverBlob = await this.downloadReplacementCover(details.posterUrl); + this.requireWriteQueueDrained('linking a TMDB title'); + this.db.exec('BEGIN IMMEDIATE'); + try { + const result = linkAnimeToTmdbTitleInTransaction(this.db, animeId, details, { + mode: 'manual', + }); + if (details.posterUrl) { + this.applyCoverArtToAnimeVideos(result.animeId, { + anilistId: null, + coverUrl: details.posterUrl, + coverBlob, + titleRomaji: null, + titleEnglish: details.titleEnglish, + episodesTotal: details.episodesTotal, + }); + } else { + // The user chose this title deliberately, so art from the previous link + // must not keep standing in for it. + clearAnimeCoverArt(this.db, result.animeId); + } + this.db.exec('COMMIT'); + return result; + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + + private async downloadReplacementCover(url: string | null | undefined): Promise { + if (!url) return null; + const response = await fetch(url); + if (!response.ok) throw new Error(`Cover download failed: ${response.status}`); + return Buffer.from(await response.arrayBuffer()); + } + + /** Stores the downloaded replacement against every episode of the entry. */ + private applyCoverArtToAnimeVideos( + animeId: number, + art: { + anilistId: number | null; + coverUrl: string; + coverBlob: Buffer | null; + titleRomaji: string | null; + titleEnglish: string | null; + episodesTotal: number | null; + }, + ): void { + const videos = this.db + .prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?') + .all(animeId) as Array<{ video_id: number }>; + for (const v of videos) { + upsertCoverArt(this.db, v.video_id, { + anilistId: art.anilistId, + coverUrl: art.coverUrl, + coverBlob: art.coverBlob, + titleRomaji: art.titleRomaji, + titleEnglish: art.titleEnglish, + episodesTotal: art.episodesTotal, + }); } } diff --git a/src/core/services/immersion-tracker/__tests__/live-action-link.test.ts b/src/core/services/immersion-tracker/__tests__/live-action-link.test.ts new file mode 100644 index 00000000..ebddde0f --- /dev/null +++ b/src/core/services/immersion-tracker/__tests__/live-action-link.test.ts @@ -0,0 +1,309 @@ +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 { Database } from '../sqlite.js'; +import type { DatabaseSync } from '../sqlite.js'; +import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js'; +import { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js'; +import { mergeAnimeRecords, mergeAnimeRecordsInTransaction } from '../anime-merge.js'; +import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js'; +import { getAnimeCoverArt, getCoverArt } from '../query-library.js'; +import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js'; + +const BASE_MS = 1_700_000_000_000; + +function withDb(work: (db: DatabaseSync) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-live-action-link-')); + const db = new Database(path.join(dir, 'immersion.sqlite')); + try { + applyPragmas(db); + ensureSchema(db); + work(db); + } finally { + db.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function insertAnime( + db: DatabaseSync, + animeId: number, + title: string, + anilistId: number | null = null, +) { + db.prepare( + `INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(animeId, title.toLowerCase(), title, anilistId, BASE_MS, BASE_MS); +} + +function insertEpisode(db: DatabaseSync, videoId: number, animeId: number, season: number | null) { + db.prepare( + `INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (?, ?, ?, ?, 1, 'Hanzawa Naoki', ?, ?, 1, 1440000, ?, ?)`, + ).run( + videoId, + `local:/tmp/${videoId}.mkv`, + animeId, + `Ep ${videoId}`, + season, + videoId, + BASE_MS, + BASE_MS, + ); + db.prepare( + `INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (?, 1, 1000, 0, 1, ?, ?, ?, ?)`, + ).run(videoId, String(BASE_MS), String(BASE_MS + 1000), BASE_MS, BASE_MS); +} + +interface AnimeRowView { + mediaKind: string; + tmdbId: number | null; + tmdbType: string | null; + anilistId: number | null; + titleEnglish: string | null; + titleNative: string | null; + description: string | null; +} + +// Copies the selected columns so the driver's row metadata does not leak into +// deep-equality assertions. +function animeRow(db: DatabaseSync, animeId: number): AnimeRowView | undefined { + const row = db + .prepare( + `SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, tmdb_type AS tmdbType, anilist_id AS anilistId, + title_english AS titleEnglish, title_native AS titleNative, description + FROM imm_anime WHERE anime_id = ?`, + ) + .get(animeId) as AnimeRowView | undefined; + if (!row) return undefined; + const { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description } = row; + return { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description }; +} + +function animeCount(db: DatabaseSync): number { + return (db.prepare('SELECT COUNT(*) AS n FROM imm_anime').get() as { n: number }).n; +} + +function videoOwner(db: DatabaseSync, videoId: number): number | null { + return ( + db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?').get(videoId) as { + animeId: number | null; + } + ).animeId; +} + +const HANZAWA = { + tmdbId: 61222, + tmdbType: 'tv' as const, + titleEnglish: 'Hanzawa Naoki', + titleNative: '半沢直樹', + description: 'A banker fights back.', + episodesTotal: 10, +}; + +test('a manual link overwrites metadata, drops the AniList link, and folds other holders in', () => { + withDb((db) => { + insertAnime(db, 1, 'Hanzawa Naoki', 4242); + insertAnime(db, 2, 'Hanzawa Naoki Season 2'); + insertEpisode(db, 1, 1, 1); + insertEpisode(db, 2, 2, 2); + db.prepare( + `UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = ?, tmdb_type = 'tv', description = 'old' WHERE anime_id = 2`, + ).run(HANZAWA.tmdbId); + + const result = linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' }); + + assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [2] }); + assert.deepEqual(animeRow(db, 1), { + mediaKind: 'live_action', + tmdbId: 61222, + tmdbType: 'tv', + anilistId: null, + titleEnglish: 'Hanzawa Naoki', + titleNative: '半沢直樹', + description: 'A banker fights back.', + }); + assert.equal(animeRow(db, 2), undefined); + assert.equal(animeCount(db), 1); + assert.equal(videoOwner(db, 2), 1); + assert.deepEqual(getVideoTmdbLink(db, 2), { animeId: 1, tmdbId: 61222, tmdbType: 'tv' }); + }); +}); + +test('an automatic link joins the entry that already owns the title and only fills gaps', () => { + withDb((db) => { + insertAnime(db, 1, 'Hanzawa Naoki'); + insertEpisode(db, 1, 1, 1); + linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, description: 'kept' }, { mode: 'manual' }); + const newcomer = getOrCreateAnimeRecord(db, { + parsedTitle: 'Hanzawa Naoki', + canonicalTitle: 'Hanzawa Naoki', + seasonScope: 2, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + insertEpisode(db, 2, newcomer, 2); + + const result = linkAnimeToTmdbTitle(db, newcomer, HANZAWA, { mode: 'auto' }); + + assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [newcomer] }); + assert.equal(animeRow(db, 1)?.description, 'kept'); + assert.equal(animeCount(db), 1); + assert.equal(videoOwner(db, 2), 1); + // The merged-away season title is remembered, so the next episode of that + // season lands on the survivor without a detour through a new row. + const again = getOrCreateAnimeRecord(db, { + parsedTitle: 'Hanzawa Naoki', + canonicalTitle: 'Hanzawa Naoki', + seasonScope: 2, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + assert.equal(again, 1); + }); +}); + +test('startup season repair leaves multi-season live-action entries alone', () => { + withDb((db) => { + insertAnime(db, 1, 'Hanzawa Naoki'); + insertEpisode(db, 1, 1, 1); + insertEpisode(db, 2, 1, 2); + linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' }); + + repairLegacySeasonlessAnimeRows(db); + + assert.equal(animeCount(db), 1); + assert.equal(videoOwner(db, 1), 1); + assert.equal(videoOwner(db, 2), 1); + assert.equal(getVideoTmdbLink(db, 2)?.animeId, 1); + }); +}); + +test('getVideoTmdbLink is null for anime entries and unlinked videos', () => { + withDb((db) => { + insertAnime(db, 1, 'Some Anime', 77); + insertEpisode(db, 1, 1, 1); + assert.equal(getVideoTmdbLink(db, 1), null); + assert.equal(getVideoTmdbLink(db, 99), null); + }); +}); + +test('clearAnimeCoverArt drops every episode cover of the entry and its orphaned blob', () => { + withDb((db) => { + insertAnime(db, 1, 'Hanzawa Naoki'); + insertAnime(db, 2, 'Other Show'); + insertEpisode(db, 1, 1, 1); + insertEpisode(db, 2, 1, 1); + insertEpisode(db, 3, 2, 1); + const shared = Buffer.from([1, 2, 3]); + for (const videoId of [1, 2]) { + upsertCoverArt(db, videoId, { + anilistId: 4242, + coverUrl: 'https://images.test/a.jpg', + coverBlob: shared, + titleRomaji: null, + titleEnglish: null, + episodesTotal: null, + }); + } + upsertCoverArt(db, 3, { + anilistId: 99, + coverUrl: 'https://images.test/b.jpg', + coverBlob: Buffer.from([9]), + titleRomaji: null, + titleEnglish: null, + episodesTotal: null, + }); + + clearAnimeCoverArt(db, 1); + + assert.equal(getAnimeCoverArt(db, 1), null); + assert.equal(getCoverArt(db, 3)?.coverBlob?.length, 1); + const blobs = ( + db.prepare('SELECT COUNT(*) AS n FROM imm_cover_art_blobs').get() as { n: number } + ).n; + assert.equal(blobs, 1); + }); +}); + +for (const targetId of [1, 2, 3]) { + test(`merge rejects mixed providers before moving any source into entry ${targetId}`, () => { + withDb((db) => { + insertAnime(db, 1, 'Anime', 77); + insertAnime(db, 2, 'Drama'); + insertAnime(db, 3, 'Unlinked'); + insertEpisode(db, 1, 1, 1); + insertEpisode(db, 2, 2, 1); + db.exec( + "UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 12, tmdb_type = 'tv' WHERE anime_id = 2", + ); + for (const merge of [mergeAnimeRecords, mergeAnimeRecordsInTransaction]) { + assert.throws( + () => merge(db, targetId, [3, 1, 2]), + /AniList-linked and TMDB-linked library entries cannot be merged/, + ); + assert.equal(animeCount(db), 3); + assert.equal(videoOwner(db, 1), 1); + assert.equal(videoOwner(db, 2), 2); + } + }); + }); +} + +for (const mode of ['manual', 'auto'] as const) { + test(`TMDB ${mode} linking rolls back the merge when the survivor update fails`, () => { + withDb((db) => { + insertAnime(db, 1, 'New entry'); + insertAnime(db, 2, 'Existing entry'); + insertEpisode(db, 1, 1, 1); + insertEpisode(db, 2, 2, 2); + db.prepare( + "UPDATE imm_anime SET tmdb_id = ?, tmdb_type = 'tv', media_kind = 'live_action' WHERE anime_id = 2", + ).run(HANZAWA.tmdbId); + db.exec(`CREATE TRIGGER reject_link BEFORE UPDATE ON imm_anime + WHEN NEW.description = 'A banker fights back.' + BEGIN SELECT RAISE(ABORT, 'rejected survivor update'); END`); + assert.throws( + () => linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode }), + /rejected survivor update/, + ); + assert.equal(animeCount(db), 2); + assert.equal(videoOwner(db, 1), 1); + assert.equal(videoOwner(db, 2), 2); + assert.equal(animeRow(db, 1)?.tmdbId, null); + assert.equal(animeRow(db, 2)?.tmdbId, HANZAWA.tmdbId); + }); + }); +} + +for (const mode of ['manual', 'auto'] as const) { + test(`${mode} TMDB linking refreshes completion totals without merging records`, () => { + withDb((db) => { + insertAnime(db, 1, 'Hanzawa Naoki'); + insertEpisode(db, 1, 1, 1); + const completed = () => + ( + db + .prepare('SELECT anime_completed AS count FROM imm_lifetime_global WHERE global_id = 1') + .get() as { count: number } + ).count; + assert.equal(completed(), 0); + const result = linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 1 }, { mode }); + assert.deepEqual(result.mergedAnimeIds, []); + assert.equal(completed(), 1); + linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 2 }, { mode: 'manual' }); + assert.equal(completed(), 0); + assert.equal(animeCount(db), 1); + }); + }); +} diff --git a/src/core/services/immersion-tracker/anime-merge.ts b/src/core/services/immersion-tracker/anime-merge.ts index d85da0ef..6a2ac897 100644 --- a/src/core/services/immersion-tracker/anime-merge.ts +++ b/src/core/services/immersion-tracker/anime-merge.ts @@ -1,4 +1,4 @@ -import type { MediaKind } from '../../../shared/media-kind'; +import { shareTitleNamespace, type MediaKind } from '../../../shared/media-kind'; import type { DatabaseSync } from './sqlite'; import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime'; import { toDbTimestamp } from './query-shared'; @@ -6,8 +6,12 @@ import { nowMs } from './time'; /** Thrown when a move names an episode or destination entry that is not there. */ export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry'; -/** Thrown when a merge or move would mix an anime entry with a YouTube channel. */ -export const MEDIA_KIND_MISMATCH_MESSAGE = 'Anime and YouTube channel entries cannot be combined'; +/** Thrown when a merge would combine an AniList-linked entry with a TMDB-linked one. */ +export const INCOMPATIBLE_PROVIDER_MERGE_MESSAGE = + 'AniList-linked and TMDB-linked library entries cannot be merged together'; +/** Thrown when a merge or move would mix a YouTube channel with an anime or live-action entry. */ +export const MEDIA_KIND_MISMATCH_MESSAGE = + 'YouTube channels cannot be combined with anime or live-action entries'; export interface AnimeMergeSummary { /** Library entry that owns every moved episode once the merge finishes. */ @@ -33,6 +37,9 @@ interface AnimeMetadataRow { title_native: string | null; episodes_total: number | null; description: string | null; + media_kind: string; + tmdb_id: number | null; + tmdb_type: string | null; } function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary { @@ -55,7 +62,8 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow return (db .prepare( ` - SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description + SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description, + media_kind, tmdb_id, tmdb_type FROM imm_anime WHERE anime_id = ? `, @@ -137,6 +145,12 @@ function absorbAnimeMetadata( title_native = COALESCE(title_native, ?), episodes_total = COALESCE(episodes_total, ?), description = COALESCE(description, ?), + tmdb_id = COALESCE(tmdb_id, ?), + tmdb_type = CASE WHEN tmdb_id IS NULL THEN ? ELSE tmdb_type END, + media_kind = CASE + WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ? + ELSE media_kind + END, LAST_UPDATE_DATE = ? WHERE anime_id = ? `, @@ -147,6 +161,10 @@ function absorbAnimeMetadata( source.title_native, source.episodes_total, source.description, + source.tmdb_id, + source.tmdb_type, + source.tmdb_id, + source.media_kind, updatedAt, targetAnimeId, ); @@ -172,6 +190,18 @@ export function mergeAnimeRecordsInTransaction( return summary; } + // Validate the whole group before moving anything, including when the + // unlinked target would inherit conflicting providers from two sources. + const metadata = [targetAnimeId, ...new Set(sourceAnimeIds)].map((id) => + readAnimeMetadata(db, id), + ); + if ( + metadata.some((row) => row?.anilist_id != null) && + metadata.some((row) => row?.tmdb_id != null) + ) { + throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE); + } + const updatedAt = toDbTimestamp(nowMs()); const sourceVideosStmt = db.prepare( 'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?', @@ -206,8 +236,8 @@ export function mergeAnimeRecordsInTransaction( const sourceKind = readMediaKind(db, sourceAnimeId); if (sourceKind === null) continue; // A channel folded into an anime would only be recreated on the next - // watch, because title lookups never cross kinds; refuse instead. - if (sourceKind !== targetKind) { + // watch, because title lookups never cross namespaces; refuse instead. + if (!shareTitleNamespace(sourceKind, targetKind)) { throw new Error(MEDIA_KIND_MISMATCH_MESSAGE); } @@ -275,7 +305,8 @@ export function moveVideoToAnime( } const previousAnimeId = videoRow.animeId; - if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) { + const previousKind = previousAnimeId === null ? null : readMediaKind(db, previousAnimeId); + if (previousKind !== null && !shareTitleNamespace(previousKind, targetKind)) { throw new Error(MEDIA_KIND_MISMATCH_MESSAGE); } if (previousAnimeId === targetAnimeId) { diff --git a/src/core/services/immersion-tracker/anime-season-repair.ts b/src/core/services/immersion-tracker/anime-season-repair.ts index 797575c4..4d33d3aa 100644 --- a/src/core/services/immersion-tracker/anime-season-repair.ts +++ b/src/core/services/immersion-tracker/anime-season-repair.ts @@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null { episodes_total, description FROM imm_anime - WHERE anime_id = ? AND media_kind = 'anime' + WHERE anime_id = ? AND media_kind != 'youtube' `, ) .get(animeId) as AnimeRow | null; @@ -372,6 +372,18 @@ export function resolveAnimeAnilistConflict( targetAnimeId: number, anilistId: number, options: AnimeAnilistConflictOptions = {}, +): AnimeSeasonRepairSummary { + return runInTransaction(db, () => + resolveAnimeAnilistConflictInTransaction(db, targetAnimeId, anilistId, options), + ); +} + +/** Caller owns the write transaction. */ +export function resolveAnimeAnilistConflictInTransaction( + db: DatabaseSync, + targetAnimeId: number, + anilistId: number, + options: AnimeAnilistConflictOptions = {}, ): AnimeSeasonRepairSummary { if (!getAnimeRow(db, targetAnimeId)) { const summary = emptySummary(); @@ -392,90 +404,88 @@ export function resolveAnimeAnilistConflict( if (!conflict) { return emptySummary(); } - if (!getAnimeRow(db, conflict.animeId)) { const summary = emptySummary(); summary.anilistAssignmentBlocked = true; return summary; } - return runInTransaction(db, () => { - const targetRow = getAnimeRow(db, targetAnimeId); - if ( - options.survivor !== 'target' && - targetRow?.anilist_id != null && - targetRow.anilist_id !== anilistId - ) { - // An automatic lookup disagreeing with an existing explicit link is a - // mis-resolution, not evidence that either row should move or merge. The - // colliding id must not be assigned either: another row owns it and - // imm_anime.anilist_id is UNIQUE. - const summary = emptySummary(1); - summary.anilistAssignmentBlocked = true; - return summary; - } - const isManual = options.survivor === 'target' || options.matchConfidence === 'manual'; - if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) { - const summary = emptySummary(1); - summary.anilistAssignmentBlocked = true; - return summary; - } - const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId); - const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId); - if ( - !isManual && - targetSeasons.size === 1 && - conflictSeasons.size === 1 && - [...targetSeasons][0] !== [...conflictSeasons][0] - ) { - const summary = emptySummary(1); - summary.anilistAssignmentBlocked = true; - return summary; - } - if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) { - const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId; - const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId; - const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]); - const summary = emptySummary(1); - summary.movedVideos = merge.movedVideos; - summary.deletedAnimeRows = merge.mergedAnimeIds.length; - if (merge.mergedAnimeIds.length > 0) { - summary.repaired = 1; - // Only reported once a row really absorbed the other, so callers never - // follow this to an anime id that was never written. - summary.survivingAnimeId = survivingAnimeId; - summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId); - } - // Lifetime summaries are rebuilt by the caller off this summary, the same - // as the redistribution path below. - return summary; - } - if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) { - recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId); - const summary = emptySummary(1); - summary.mergeRecommended = true; - return summary; + const targetRow = getAnimeRow(db, targetAnimeId); + if ( + options.survivor !== 'target' && + targetRow?.anilist_id != null && + targetRow.anilist_id !== anilistId + ) { + // An automatic lookup disagreeing with an existing explicit link is a + // mis-resolution, not evidence that either row should move or merge. The + // colliding id must not be assigned either: another row owns it and + // imm_anime.anilist_id is UNIQUE. + const summary = emptySummary(1); + summary.anilistAssignmentBlocked = true; + return summary; + } + const isManual = options.survivor === 'target' || options.matchConfidence === 'manual'; + if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) { + const summary = emptySummary(1); + summary.anilistAssignmentBlocked = true; + return summary; + } + const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId); + const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId); + if ( + !isManual && + targetSeasons.size === 1 && + conflictSeasons.size === 1 && + [...targetSeasons][0] !== [...conflictSeasons][0] + ) { + const summary = emptySummary(1); + summary.anilistAssignmentBlocked = true; + return summary; + } + if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) { + const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId; + const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId; + const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]); + const summary = emptySummary(1); + summary.movedVideos = merge.movedVideos; + summary.deletedAnimeRows = merge.mergedAnimeIds.length; + if (merge.mergedAnimeIds.length > 0) { + summary.repaired = 1; + // Only reported once a row really absorbed the other, so callers never + // follow this to an anime id that was never written. + summary.survivingAnimeId = survivingAnimeId; + summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId); } + // Lifetime summaries are rebuilt by the caller off this summary, the same + // as the redistribution path below. + return summary; + } - const isExactAutomaticMatch = - options.matchConfidence === 'exact' || - (options.matchConfidence === undefined && - hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId)); - if (!isManual && !isExactAutomaticMatch) { - // Redistribution dismantles the id's current owner and hands the id to - // the target. On a weak automatic match that owner is usually the - // correctly linked card (e.g. a legitimate multi-season entry), so - // splitting it here is exactly the fuzzy false merge this gate exists to - // stop. Only exact or manual evidence may fall through. - const summary = emptySummary(1); - summary.anilistAssignmentBlocked = true; - return summary; - } + if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) { + recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId); + const summary = emptySummary(1); + summary.mergeRecommended = true; + return summary; + } - return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, { - transferAnilistToAnimeId: targetAnimeId, - overwriteTargetAnilist: true, - }); + const isExactAutomaticMatch = + options.matchConfidence === 'exact' || + (options.matchConfidence === undefined && + hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId)); + if (!isManual && !isExactAutomaticMatch) { + // Redistribution dismantles the id's current owner and hands the id to + // the target. On a weak automatic match that owner is usually the + // correctly linked card (e.g. a legitimate multi-season entry), so + // splitting it here is exactly the fuzzy false merge this gate exists to + // stop. Only exact or manual evidence may fall through. + const summary = emptySummary(1); + summary.anilistAssignmentBlocked = true; + return summary; + } + + return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, { + transferAnilistToAnimeId: targetAnimeId, + overwriteTargetAnilist: true, }); } diff --git a/src/core/services/immersion-tracker/live-action-link.ts b/src/core/services/immersion-tracker/live-action-link.ts new file mode 100644 index 00000000..78bfb7f7 --- /dev/null +++ b/src/core/services/immersion-tracker/live-action-link.ts @@ -0,0 +1,179 @@ +import type { DatabaseSync } from './sqlite'; +import type { TmdbMediaType } from '../../../shared/media-kind'; +import { mergeAnimeRecordsInTransaction } from './anime-merge'; +import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime'; +import { toDbTimestamp } from './query-shared'; +import { nowMs } from './time'; + +export interface LiveActionTitleInput { + tmdbId: number; + tmdbType: TmdbMediaType; + titleEnglish: string | null; + titleNative: string | null; + description: string | null; + episodesTotal: number | null; +} + +export interface LiveActionLinkResult { + /** Library entry that carries the TMDB link once the call finishes. */ + animeId: number; + /** Entries folded into `animeId` because they pointed at the same TMDB title. */ + mergedAnimeIds: number[]; +} + +export interface LiveActionLinkOptions { + /** + * `manual`: the user picked this title, so stored titles are overwritten and + * every other holder of the TMDB id is folded into this entry. + * `auto`: an exact filename match, so gaps are filled and the entry joins an + * existing holder rather than displacing it. + */ + mode: 'manual' | 'auto'; +} + +export interface VideoTmdbLink { + animeId: number; + tmdbId: number; + tmdbType: TmdbMediaType; +} + +function findOtherTmdbHolders( + db: DatabaseSync, + animeId: number, + input: Pick, +): number[] { + return ( + db + .prepare( + `SELECT anime_id AS animeId + FROM imm_anime + WHERE tmdb_id = ? AND tmdb_type = ? AND anime_id != ? + ORDER BY anime_id ASC`, + ) + .all(input.tmdbId, input.tmdbType, animeId) as Array<{ animeId: number }> + ).map((row) => row.animeId); +} + +/** + * Link a library entry to a TMDB title. Unlike AniList, a TMDB show spans all + * of its seasons, so entries that resolve to the same title are one show and + * are merged regardless of the season each was parsed with. + */ +export function linkAnimeToTmdbTitle( + db: DatabaseSync, + animeId: number, + input: LiveActionTitleInput, + options: LiveActionLinkOptions, +): LiveActionLinkResult { + db.exec('BEGIN IMMEDIATE'); + try { + const result = linkAnimeToTmdbTitleInTransaction(db, animeId, input, options); + db.exec('COMMIT'); + return result; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +/** Caller owns the write transaction, including any artwork replacement. */ +export function linkAnimeToTmdbTitleInTransaction( + db: DatabaseSync, + animeId: number, + input: LiveActionTitleInput, + options: LiveActionLinkOptions, +): LiveActionLinkResult { + const target = db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(animeId) as + | { anilist_id: number | null } + | undefined; + if (!target) throw new Error('Unknown library entry'); + if (target.anilist_id !== null) { + if (options.mode === 'auto') + throw new Error('Cannot automatically replace an AniList identity'); + // An explicit reassignment changes providers before compatible rows merge. + db.prepare('UPDATE imm_anime SET anilist_id = NULL WHERE anime_id = ?').run(animeId); + } + const others = findOtherTmdbHolders(db, animeId, input); + let survivor = animeId; + let mergedAnimeIds: number[] = []; + if (others.length > 0) { + if (options.mode === 'manual') { + mergedAnimeIds = mergeAnimeRecordsInTransaction(db, animeId, others).mergedAnimeIds; + } else { + // Keep the entry the user already sees; the newcomer is the transient + // "Show Season 3" row that a fresh season folder just created. + survivor = others[0]!; + mergedAnimeIds = mergeAnimeRecordsInTransaction(db, survivor, [ + animeId, + ...others.slice(1), + ]).mergedAnimeIds; + } + } + + const updatedAt = toDbTimestamp(nowMs()); + if (options.mode === 'manual') { + db.prepare( + `UPDATE imm_anime + SET media_kind = 'live_action', + tmdb_id = ?, + tmdb_type = ?, + anilist_id = NULL, + title_romaji = NULL, + title_english = ?, + title_native = ?, + episodes_total = ?, + description = ?, + LAST_UPDATE_DATE = ? + WHERE anime_id = ?`, + ).run( + input.tmdbId, + input.tmdbType, + input.titleEnglish, + input.titleNative, + input.episodesTotal, + input.description, + updatedAt, + survivor, + ); + } else { + db.prepare( + `UPDATE imm_anime + SET media_kind = 'live_action', + tmdb_id = ?, + tmdb_type = ?, + title_english = COALESCE(title_english, ?), + title_native = COALESCE(title_native, ?), + episodes_total = COALESCE(episodes_total, ?), + description = COALESCE(description, ?), + LAST_UPDATE_DATE = ? + WHERE anime_id = ?`, + ).run( + input.tmdbId, + input.tmdbType, + input.titleEnglish, + input.titleNative, + input.episodesTotal, + input.description, + updatedAt, + survivor, + ); + } + recomputeLifetimeAnimeAggregatesInTransaction(db); + return { animeId: survivor, mergedAnimeIds }; +} + +/** The TMDB link of the live-action entry a video belongs to, if any. */ +export function getVideoTmdbLink(db: DatabaseSync, videoId: number): VideoTmdbLink | null { + const row = db + .prepare( + `SELECT a.anime_id AS animeId, a.tmdb_id AS tmdbId, a.tmdb_type AS tmdbType + FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + WHERE v.video_id = ? + AND a.media_kind = 'live_action' + AND a.tmdb_id IS NOT NULL + AND a.tmdb_type IN ('tv', 'movie')`, + ) + .get(videoId) as VideoTmdbLink | undefined; + return row ? { animeId: row.animeId, tmdbId: row.tmdbId, tmdbType: row.tmdbType } : null; +} diff --git a/src/core/services/immersion-tracker/query-library.ts b/src/core/services/immersion-tracker/query-library.ts index cb8f5736..bfa4a672 100644 --- a/src/core/services/immersion-tracker/query-library.ts +++ b/src/core/services/immersion-tracker/query-library.ts @@ -35,6 +35,9 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { a.canonical_title AS canonicalTitle, a.media_kind AS mediaKind, a.anilist_id AS anilistId, + a.media_kind AS mediaKind, + a.tmdb_id AS tmdbId, + a.tmdb_type AS tmdbType, COALESCE(lm.total_sessions, 0) AS totalSessions, COALESCE(lm.total_active_ms, 0) AS totalActiveMs, COALESCE(lm.total_cards, 0) AS totalCards, @@ -66,6 +69,9 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo a.canonical_title AS canonicalTitle, a.media_kind AS mediaKind, a.anilist_id AS anilistId, + a.media_kind AS mediaKind, + a.tmdb_id AS tmdbId, + a.tmdb_type AS tmdbType, a.title_romaji AS titleRomaji, a.title_english AS titleEnglish, a.title_native AS titleNative, diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts index 09cf92e9..b91e9615 100644 --- a/src/core/services/immersion-tracker/query-maintenance.ts +++ b/src/core/services/immersion-tracker/query-maintenance.ts @@ -331,6 +331,29 @@ export async function cleanupVocabularyStats( }; } +/** + * Drop the cached art of every episode in a library entry. Used when a manual + * relink points at a title with no artwork, so the previous link's cover does + * not keep standing in for it. + */ +export function clearAnimeCoverArt(db: DatabaseSync, animeId: number): void { + const rows = db + .prepare( + `SELECT m.cover_blob_hash AS coverBlobHash + FROM imm_media_art m + JOIN imm_videos v ON v.video_id = m.video_id + WHERE v.anime_id = ?`, + ) + .all(animeId) as Array<{ coverBlobHash: string | null }>; + if (rows.length === 0) return; + db.prepare( + 'DELETE FROM imm_media_art WHERE video_id IN (SELECT video_id FROM imm_videos WHERE anime_id = ?)', + ).run(animeId); + for (const hash of new Set(rows.map((row) => row.coverBlobHash))) { + cleanupUnusedCoverArtBlobHash(db, hash); + } +} + export function upsertCoverArt( db: DatabaseSync, videoId: number, diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 87e6ac1c..533ec88d 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -1,4 +1,4 @@ -import type { MediaKind } from '../../../shared/media-kind'; +import { sameTitleNamespaceSql, type MediaKind } from '../../../shared/media-kind'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { parseMediaInfo } from '../../../jimaku/utils'; @@ -591,14 +591,19 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput anime_id: number; } | null) : null; + // Title lookups stay inside the kind's namespace: a parsed filename may land + // on a TMDB-linked live-action row, but never on a YouTube channel. const byNormalizedTitle = db - .prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?') + .prepare( + `SELECT anime_id FROM imm_anime + WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`, + ) .get(normalizedTitleKey, mediaKind) as { anime_id: number } | null; const byTitleAlias = db .prepare( `SELECT a.anime_id FROM imm_anime_title_aliases AS alias JOIN imm_anime AS a ON a.anime_id = alias.anime_id - WHERE alias.normalized_title_key = ? AND a.media_kind = ?`, + WHERE alias.normalized_title_key = ? AND ${sameTitleNamespaceSql('a.media_kind')}`, ) .get(normalizedTitleKey, mediaKind) as { anime_id: number } | null; const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias; @@ -611,7 +616,11 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput UPDATE imm_anime SET canonical_title = COALESCE(NULLIF(?, ''), canonical_title), - anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END, + anilist_id = CASE + WHEN ? = 'youtube' THEN NULL + WHEN tmdb_id IS NOT NULL THEN anilist_id + ELSE COALESCE(?, anilist_id) + END, title_romaji = COALESCE(?, title_romaji), title_english = COALESCE(?, title_english), title_native = COALESCE(?, title_native), @@ -889,13 +898,19 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void { } } -// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact -// and foreign keys disabled so dependent history and manual assignments survive. -function migrateAnimeTitleUniqueness(db: DatabaseSync): void { +// SQLite cannot drop a table-level UNIQUE constraint or a column CHECK. +// Rebuild with IDs intact and foreign keys disabled so dependent history and +// manual assignments survive. Two shapes need it: the original +// `normalized_title_key UNIQUE`, and the v0.19.6 `media_kind` column whose +// CHECK only allowed 'anime' and 'youtube'. +const LEGACY_TITLE_UNIQUE_RE = /normalized_title_key TEXT NOT NULL UNIQUE/i; +const LEGACY_MEDIA_KIND_CHECK_RE = /\s*CHECK\s*\(\s*media_kind IN \('anime',\s*'youtube'\)\s*\)/i; + +function migrateAnimeTableConstraints(db: DatabaseSync): void { const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as { sql: string; }; - if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) { + if (LEGACY_TITLE_UNIQUE_RE.test(schema.sql) || LEGACY_MEDIA_KIND_CHECK_RE.test(schema.sql)) { const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }; const sequence = db .prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'") @@ -909,10 +924,8 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void { /CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i, 'CREATE TABLE imm_anime_new', ) - .replace( - /normalized_title_key TEXT NOT NULL UNIQUE/i, - 'normalized_title_key TEXT NOT NULL', - ), + .replace(LEGACY_TITLE_UNIQUE_RE, 'normalized_title_key TEXT NOT NULL') + .replace(LEGACY_MEDIA_KIND_CHECK_RE, ''), ); db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime; DROP TABLE imm_anime; @@ -930,8 +943,11 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void { db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`); } } - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title - ON imm_anime(media_kind, normalized_title_key)`); + // v0.19.6 scoped titles per kind; anime and live-action now share one + // namespace (an entry moves between them when relinked), YouTube is separate. + db.exec(`DROP INDEX IF EXISTS idx_anime_kind_title; + CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_namespace_title + ON imm_anime((media_kind = 'youtube'), normalized_title_key)`); } // Older builds can create channel rows with the default anime kind even after @@ -995,17 +1011,20 @@ export function ensureSchema(db: DatabaseSync): void { title_native TEXT, episodes_total INTEGER, description TEXT, + media_kind TEXT NOT NULL DEFAULT 'anime', + tmdb_id INTEGER, + tmdb_type TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT ); `); - addColumnIfMissing( - db, - 'imm_anime', - 'media_kind', - "TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))", - ); + // Schema 26: media_kind separates anime, live-action (TMDB link) and YouTube + // channel entries. Kinds are validated in code, not by a CHECK constraint, + // so adding one later does not need a table rebuild. + addColumnIfMissing(db, 'imm_anime', 'media_kind', "TEXT NOT NULL DEFAULT 'anime'"); + addColumnIfMissing(db, 'imm_anime', 'tmdb_id', 'INTEGER'); + addColumnIfMissing(db, 'imm_anime', 'tmdb_type', 'TEXT'); db.exec(` CREATE TABLE IF NOT EXISTS imm_videos( video_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1549,7 +1568,7 @@ export function ensureSchema(db: DatabaseSync): void { ); } - migrateAnimeTitleUniqueness(db); + migrateAnimeTableConstraints(db); classifyYoutubeChannels(db); migrateSessionEventTimestampsToText(db); @@ -1565,6 +1584,10 @@ export function ensureSchema(db: DatabaseSync): void { CREATE INDEX IF NOT EXISTS idx_anime_anilist_id ON imm_anime(anilist_id) `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_anime_tmdb_id + ON imm_anime(tmdb_id, tmdb_type) + `); db.exec(` CREATE INDEX IF NOT EXISTS idx_videos_anime_id ON imm_videos(anime_id) diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index 4cab88c8..0c4e414a 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -1,6 +1,7 @@ -import type { MediaKind } from '../../../shared/media-kind'; +import type { MediaKind, TmdbMediaType } from '../../../shared/media-kind'; -export const SCHEMA_VERSION = 25; +// 26: live-action entries (TMDB link) and YouTube channels share the media_kind column. +export const SCHEMA_VERSION = 26; export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_FLUSH_INTERVAL_MS = 500; @@ -524,6 +525,8 @@ export interface AnimeLibraryRow { animeId: number; canonicalTitle: string; anilistId: number | null; + tmdbId: number | null; + tmdbType: TmdbMediaType | null; totalSessions: number; totalActiveMs: number; totalCards: number; @@ -538,6 +541,8 @@ export interface AnimeDetailRow { animeId: number; canonicalTitle: string; anilistId: number | null; + tmdbId: number | null; + tmdbType: TmdbMediaType | null; titleRomaji: string | null; titleEnglish: string | null; titleNative: string | null; diff --git a/src/core/services/immersion-tracker/youtube-kind.test.ts b/src/core/services/immersion-tracker/youtube-kind.test.ts index 8da915f1..3a286cb5 100644 --- a/src/core/services/immersion-tracker/youtube-kind.test.ts +++ b/src/core/services/immersion-tracker/youtube-kind.test.ts @@ -81,7 +81,7 @@ test('schema 23 channel migration preserves history and manual assignments and i const history = getAnimeLibrary(db); // Reproduce the previous schema, including its lack of a media kind column. db.exec( - 'DROP INDEX idx_anime_kind_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)', + 'DROP INDEX idx_anime_namespace_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)', ); ensureSchema(db); ensureSchema(db); @@ -240,6 +240,52 @@ test('title identity and aliases never cross media kinds', () => { } }); +test('anime title lookups land on a same-named live-action entry but never on a channel', () => { + const db = new Database(':memory:'); + try { + ensureSchema(db); + const dramaId = createAnime(db, 'Hanzawa Naoki'); + db.prepare( + "UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?", + ).run(dramaId); + // A later season folder parses to the same title with the default anime + // kind and must join the TMDB-linked entry rather than duplicate it. + assert.equal( + getOrCreateAnimeRecord(db, { + parsedTitle: 'Hanzawa Naoki', + canonicalTitle: 'Hanzawa Naoki', + anilistId: 99, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }), + dramaId, + ); + const row = db + .prepare('SELECT media_kind, anilist_id, tmdb_id FROM imm_anime WHERE anime_id = ?') + .get(dramaId) as { media_kind: string; anilist_id: number | null; tmdb_id: number }; + assert.equal(row.media_kind, 'live_action'); + assert.equal(row.anilist_id, null); + assert.equal(row.tmdb_id, 61222); + assert.notEqual( + getOrCreateAnimeRecord(db, { + mediaKind: 'youtube', + parsedTitle: 'Hanzawa Naoki', + canonicalTitle: 'Hanzawa Naoki', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }), + dramaId, + ); + } finally { + db.close(); + } +}); + test('schema 24 title constraint migration preserves referenced data', () => { const db = new Database(':memory:'); try { @@ -273,7 +319,9 @@ test('schema 24 title constraint migration preserves referenced data', () => { title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER, description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT, media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))); - INSERT INTO imm_anime SELECT * FROM old_anime; + INSERT INTO imm_anime SELECT anime_id, normalized_title_key, canonical_title, anilist_id, + title_romaji, title_english, title_native, episodes_total, description, metadata_json, + CREATED_DATE, LAST_UPDATE_DATE, media_kind FROM old_anime; DROP TABLE old_anime; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (24, 0); @@ -281,6 +329,10 @@ test('schema 24 title constraint migration preserves referenced data', () => { ensureSchema(db); ensureSchema(db); assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []); + // The v0.19.6 CHECK only allowed anime and youtube; live-action must fit now. + db.prepare( + "INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind, tmdb_id, tmdb_type) VALUES ('drama', 'Drama', 'live_action', 1, 'tv')", + ).run(); assert.equal( (db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys, 1, diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 3b39f634..5d3c05d3 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -3,6 +3,7 @@ import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import type { AnkiConnectConfig } from '../../types.js'; import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; +import type { TmdbClient } from './tmdb/tmdb-client.js'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js'; import type { StatsServerMediaGenerator } from './stats-server/mining-support.js'; @@ -129,6 +130,7 @@ export interface StatsServerConfig { input: RetimedSecondarySubtitleInput, ) => Promise | string; anilistRateLimiter?: AnilistRateLimiter; + tmdbClient?: TmdbClient; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; @@ -151,6 +153,7 @@ export function createStatsApp( input: RetimedSecondarySubtitleInput, ) => Promise | string; anilistRateLimiter?: AnilistRateLimiter; + tmdbClient?: TmdbClient; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; resolveSentenceSearchHeadwords?: (term: string) => Promise | string[]; @@ -186,6 +189,7 @@ export async function startStatsServerWithRuntime( getStatsMiningAlassPath: config.getStatsMiningAlassPath, resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText, anilistRateLimiter: config.anilistRateLimiter, + tmdbClient: config.tmdbClient, addYomitanNote: config.addYomitanNote, resolveAnkiNoteId: config.resolveAnkiNoteId, resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords, diff --git a/src/core/services/stats-server/integration-routes.ts b/src/core/services/stats-server/integration-routes.ts index ed71b2b1..d8c68219 100644 --- a/src/core/services/stats-server/integration-routes.ts +++ b/src/core/services/stats-server/integration-routes.ts @@ -1,11 +1,13 @@ -import type { Hono } from 'hono'; +import type { Context, Hono } from 'hono'; import type { AnkiConnectConfig } from '../../../types.js'; import { statsJson, type StatsAnilistSearchResult, type StatsAnkiBrowseResponse, } from '../../../types/stats-http-contract.js'; +import { isTmdbMediaType } from '../../../shared/media-kind.js'; import type { AnilistRateLimiter } from '../anilist/rate-limiter.js'; +import { TmdbApiKeyMissingError, type TmdbClient } from '../tmdb/tmdb-client.js'; import { registerStatsCoverRoutes } from '../stats-cover-routes.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import { @@ -29,6 +31,7 @@ export function registerStatsIntegrationRoutes( ankiConnectConfig?: AnkiConnectConfig; getAnkiConnectConfig?: () => AnkiConnectConfig | undefined; anilistRateLimiter?: AnilistRateLimiter; + tmdbClient?: TmdbClient; resolveAnkiNoteId?: (noteId: number) => number; }, ): void { @@ -73,6 +76,51 @@ export function registerStatsIntegrationRoutes( } }); + const tmdbUnavailable = (c: Context, err: unknown) => { + if (err instanceof TmdbApiKeyMissingError) { + return c.json(statsJson('error', { error: err.message }), 503); + } + return c.json(statsJson('error', { error: 'TMDB request failed' }), 502); + }; + + app.get('/api/stats/tmdb/search', async (c) => { + const query = (c.req.query('q') ?? '').trim(); + if (!query) return c.json(statsJson('tmdbSearch', [])); + const tmdbClient = options?.tmdbClient; + if (!tmdbClient) return c.json(statsJson('tmdbSearch', [])); + try { + return c.json(statsJson('tmdbSearch', await tmdbClient.search(query))); + } catch (err) { + return tmdbUnavailable(c, err); + } + }); + + app.patch('/api/stats/anime/:animeId/tmdb', async (c) => { + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); + const body = await c.req.json().catch(() => null); + const tmdbId = body?.tmdbId; + if ( + typeof tmdbId !== 'number' || + !Number.isInteger(tmdbId) || + tmdbId <= 0 || + !isTmdbMediaType(body?.tmdbType) + ) { + return c.body(null, 400); + } + if (!(await tracker.hasAnime(animeId))) return c.body(null, 404); + const tmdbClient = options?.tmdbClient; + if (!tmdbClient) return c.json(statsJson('error', { error: 'TMDB is not available' }), 503); + try { + const details = await tmdbClient.getDetails(body.tmdbType, tmdbId); + if (!details) return c.body(null, 404); + await tracker.reassignAnimeTmdb(animeId, details); + return c.json(statsJson('reassignAnimeTmdb', { ok: true })); + } catch (err) { + return tmdbUnavailable(c, err); + } + }); + app.get('/api/stats/known-words', (c) => { const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); if (!knownWordsSet) return c.json(statsJson('knownWords', [])); diff --git a/src/core/services/stats-server/library-routes.ts b/src/core/services/stats-server/library-routes.ts index 00b9f464..7fa20174 100644 --- a/src/core/services/stats-server/library-routes.ts +++ b/src/core/services/stats-server/library-routes.ts @@ -1,6 +1,7 @@ import type { Hono } from 'hono'; import { statsJson } from '../../../types/stats-http-contract.js'; import { + INCOMPATIBLE_PROVIDER_MERGE_MESSAGE, MEDIA_KIND_MISMATCH_MESSAGE, UNKNOWN_MOVE_TARGET_MESSAGE, } from '../immersion-tracker/anime-merge.js'; @@ -252,8 +253,14 @@ export function registerStatsLibraryRoutes( try { summary = await tracker.mergeAnime(animeId, sourceAnimeIds); } catch (error) { - if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) { - return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409); + // Mixing providers or kinds is a rejected request, not a server fault, + // so the dashboard can explain it instead of showing a bare 500. + if ( + error instanceof Error && + (error.message === INCOMPATIBLE_PROVIDER_MERGE_MESSAGE || + error.message === MEDIA_KIND_MISMATCH_MESSAGE) + ) { + return c.json(statsJson('error', { error: error.message }), 409); } throw error; } diff --git a/src/core/services/stats-sync/merge-catalog.test.ts b/src/core/services/stats-sync/merge-catalog.test.ts new file mode 100644 index 00000000..526b838c --- /dev/null +++ b/src/core/services/stats-sync/merge-catalog.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Database, type DatabaseSync } from '../immersion-tracker/sqlite'; +import { ensureSchema } from '../immersion-tracker/storage'; +import { mergeAnime } from './merge-catalog'; +import { createEmptyMergeSummary } from './shared'; + +const identities = { + unlinked: [null, null, null], + anilist: [42, null, null], + tmdb: [null, 12, 'tv'], + otherTmdb: [null, 13, 'tv'], +} as const; + +for (const [localKind, remoteKind, sameEntry] of [ + ['anilist', 'tmdb', false], + ['tmdb', 'anilist', false], + ['tmdb', 'otherTmdb', false], + ['unlinked', 'tmdb', true], + ['unlinked', 'anilist', true], + ['tmdb', 'unlinked', true], + ['tmdb', 'tmdb', true], + ['anilist', 'anilist', true], +] as const) { + test(`catalog title match: ${localKind} with ${remoteKind}`, () => { + const local = new Database(':memory:'); + const remote = new Database(':memory:'); + const adapt = (db: DatabaseSync) => ({ + query: (sql: string) => db.prepare(sql), + exec: (sql: string) => { + db.exec(sql); + }, + close: () => { + db.close(); + }, + }); + try { + for (const [db, kind] of [ + [local, localKind], + [remote, remoteKind], + ] as const) { + ensureSchema(db); + db.prepare( + `INSERT INTO imm_anime(normalized_title_key, canonical_title, anilist_id, tmdb_id, tmdb_type, CREATED_DATE, LAST_UPDATE_DATE) + VALUES ('same title', 'Same title', ?, ?, ?, 1000, 1000)`, + ).run(...identities[kind]); + } + const summary = createEmptyMergeSummary(); + const map = mergeAnime(adapt(local), adapt(remote), summary); + assert.equal(map.get(1) === 1, sameEntry); + assert.equal(summary.animeAdded, sameEntry ? 0 : 1); + const again = createEmptyMergeSummary(); + assert.equal(mergeAnime(adapt(local), adapt(remote), again).get(1), map.get(1)); + assert.equal(again.animeAdded, 0); + } finally { + local.close(); + remote.close(); + } + }); +} diff --git a/src/core/services/stats-sync/merge-catalog.ts b/src/core/services/stats-sync/merge-catalog.ts index 692e0c52..c3025441 100644 --- a/src/core/services/stats-sync/merge-catalog.ts +++ b/src/core/services/stats-sync/merge-catalog.ts @@ -1,5 +1,6 @@ import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver'; import { insertRow, tableExists, type SyncMergeSummary } from './shared'; +import { sameTitleNamespaceSql } from '../../../shared/media-kind'; const ANIME_COPY_COLUMNS = [ 'media_kind', @@ -11,6 +12,8 @@ const ANIME_COPY_COLUMNS = [ 'title_native', 'episodes_total', 'description', + 'tmdb_id', + 'tmdb_type', 'metadata_json', 'CREATED_DATE', 'LAST_UPDATE_DATE', @@ -102,8 +105,14 @@ export function mergeAnime( const byAnilist = local.query( "SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'", ); + const byTmdb = local.query( + 'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1', + ); + // Anime and live-action rows share a title namespace; YouTube channels are + // looked up on their own, so a same-named anime and channel stay separate. const byTitleKey = local.query( - 'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?', + `SELECT anime_id, anilist_id, tmdb_id, tmdb_type FROM imm_anime + WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`, ); // A pre-classification channel can be repaired, but a genuine anime sharing // its title must remain a separate entry. @@ -116,16 +125,25 @@ export function mergeAnime( const releaseChannelAnilistId = local.query( "UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?", ); + // A TMDB link only fills in when the local row is unlinked: a row already + // pinned to AniList stays anime, and vice versa, so the two link kinds never + // coexist on one entry. A channel match always becomes a channel. const fillMissing = local.query( `UPDATE imm_anime SET - media_kind = ?, anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE anilist_id END, title_romaji = COALESCE(title_romaji, ?), title_english = COALESCE(title_english, ?), title_native = COALESCE(title_native, ?), episodes_total = COALESCE(episodes_total, ?), - description = COALESCE(description, ?) + description = COALESCE(description, ?), + tmdb_id = CASE WHEN anilist_id IS NULL THEN COALESCE(tmdb_id, ?) ELSE tmdb_id END, + tmdb_type = CASE WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ? ELSE tmdb_type END, + media_kind = CASE + WHEN ? = 'youtube' THEN 'youtube' + WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ? + ELSE media_kind + END WHERE anime_id = ?`, ); @@ -139,10 +157,25 @@ export function mergeAnime( // incorrectly attached one to a channel. releaseChannelAnilistId.run(row.anilist_id); } + const titleMatch = byTitleKey.get(row.normalized_title_key, row.media_kind) as + | SqlRow + | undefined; + const compatibleTitleMatch = + titleMatch && + ((titleMatch.anilist_id === null && titleMatch.tmdb_id === null) || + (row.anilist_id === null && row.tmdb_id === null) || + (titleMatch.tmdb_id === null && + row.tmdb_id === null && + titleMatch.anilist_id === row.anilist_id) || + (titleMatch.anilist_id === null && + row.anilist_id === null && + titleMatch.tmdb_id === row.tmdb_id && + titleMatch.tmdb_type === row.tmdb_type)); const existing = ((row.media_kind === 'anime' && row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ?? - byTitleKey.get(row.normalized_title_key, row.media_kind) ?? + (row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ?? + (compatibleTitleMatch ? titleMatch : undefined) ?? (row.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as | SqlRow | undefined; @@ -150,22 +183,34 @@ export function mergeAnime( const localId = Number(existing.anime_id); map.set(remoteId, localId); fillMissing.run( - row.media_kind, row.media_kind, row.title_romaji, row.title_english, row.title_native, row.episodes_total, row.description, + row.tmdb_id, + row.tmdb_type, + row.media_kind, + row.media_kind, localId, ); continue; } - // No local row matched by anilist_id (checked first in `existing` above) - // or title key, so the remote anilist_id — if any — is free to insert as-is. - const values = ANIME_COPY_COLUMNS.map((column) => - column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column], - ); + // Conflicting providers can share a title, but the stored title key is + // unique within its namespace. + let titleKey = row.normalized_title_key; + for (let suffix = 1; byTitleKey.get(titleKey, row.media_kind); suffix += 1) { + titleKey = `${row.normalized_title_key}:sync:${suffix}`; + } + const values = ANIME_COPY_COLUMNS.map((column) => { + if (column === 'normalized_title_key') return titleKey; + // No local row matched by anilist_id (checked first in `existing` above) + // or title key, so the remote anilist_id is free to insert as-is, except + // that channels never carry one. + if (column === 'anilist_id' && row.media_kind !== 'anime') return null; + return row[column]; + }); map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values)); summary.animeAdded += 1; } diff --git a/src/core/services/tmdb/bundled-api-key.test.ts b/src/core/services/tmdb/bundled-api-key.test.ts new file mode 100644 index 00000000..04ce08dd --- /dev/null +++ b/src/core/services/tmdb/bundled-api-key.test.ts @@ -0,0 +1,22 @@ +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 { BUNDLED_INTEGRATION_KEYS_FILENAME, readBundledTmdbApiKey } from './bundled-api-key.js'; + +test('readBundledTmdbApiKey reads the staged key and tolerates a missing or malformed file', () => { + const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-key-')); + const filePath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME); + try { + assert.equal(readBundledTmdbApiKey(distDir), null); + fs.writeFileSync(filePath, '{"tmdbApiKey":" abc "}'); + assert.equal(readBundledTmdbApiKey(distDir), 'abc'); + fs.writeFileSync(filePath, '{"tmdbApiKey":""}'); + assert.equal(readBundledTmdbApiKey(distDir), null); + fs.writeFileSync(filePath, 'not json'); + assert.equal(readBundledTmdbApiKey(distDir), null); + } finally { + fs.rmSync(distDir, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/tmdb/bundled-api-key.ts b/src/core/services/tmdb/bundled-api-key.ts new file mode 100644 index 00000000..4b454183 --- /dev/null +++ b/src/core/services/tmdb/bundled-api-key.ts @@ -0,0 +1,20 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Release builds stage a project-owned TMDB key into dist/ (see + * scripts/bundled-integration-keys.mjs). Source checkouts and CI builds have no + * such file, and TMDB lookups then depend on the user's own `tmdb.apiKey`. + */ +export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json'; + +export function readBundledTmdbApiKey(distDir: string): string | null { + try { + const raw = fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'); + const parsed = JSON.parse(raw) as { tmdbApiKey?: unknown }; + const key = typeof parsed.tmdbApiKey === 'string' ? parsed.tmdbApiKey.trim() : ''; + return key.length > 0 ? key : null; + } catch { + return null; + } +} diff --git a/src/core/services/tmdb/live-action-resolver.test.ts b/src/core/services/tmdb/live-action-resolver.test.ts new file mode 100644 index 00000000..e92ee484 --- /dev/null +++ b/src/core/services/tmdb/live-action-resolver.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createLiveActionMetadataResolver, titlesMatch } from './live-action-resolver.js'; +import { + TmdbApiKeyMissingError, + type TmdbClient, + type TmdbSearchResult, + type TmdbTitleDetails, +} from './tmdb-client.js'; + +const silentLogger = { info: () => {}, warn: () => {} }; + +function searchResult(over: Partial & { tmdbId: number }): TmdbSearchResult { + return { + tmdbType: 'tv', + title: 'Title', + originalTitle: 'Title', + originalLanguage: 'ja', + overview: null, + posterUrl: null, + year: null, + isAnimation: false, + ...over, + }; +} + +function details(over: Partial & { tmdbId: number }): TmdbTitleDetails { + return { + tmdbType: 'tv', + titleEnglish: null, + titleNative: null, + description: null, + posterUrl: null, + episodesTotal: null, + year: null, + originalLanguage: 'ja', + isAnimation: false, + allTitles: [], + ...over, + }; +} + +test('titlesMatch ignores case, width, and punctuation but not extra words', () => { + assert.equal(titlesMatch('Hanzawa Naoki', ['HANZAWA NAOKI!']), true); + assert.equal(titlesMatch('半沢直樹', ['半沢直樹']), true); + assert.equal(titlesMatch('Hanzawa Naoki', ['Hanzawa Naoki Season 2']), false); + assert.equal(titlesMatch('', ['']), false); +}); + +test('resolveByTitle only accepts a Japanese non-animated result whose known titles match exactly', async () => { + const detailCalls: number[] = []; + const client: TmdbClient = { + async search() { + return [ + searchResult({ tmdbId: 1, title: 'Hanzawa Naoki', originalLanguage: 'ko' }), + searchResult({ tmdbId: 2, title: 'Hanzawa Naoki', isAnimation: true }), + searchResult({ tmdbId: 3, title: 'Hanzawa Naoki: The Movie' }), + searchResult({ tmdbId: 4, title: 'Hanzawa Naoki' }), + ]; + }, + async getDetails(_type, tmdbId) { + detailCalls.push(tmdbId); + if (tmdbId === 3) return details({ tmdbId: 3, allTitles: ['Hanzawa Naoki: The Movie'] }); + if (tmdbId === 4) return details({ tmdbId: 4, allTitles: ['Hanzawa Naoki', '半沢直樹'] }); + return null; + }, + }; + const resolver = createLiveActionMetadataResolver(client, silentLogger); + + const resolved = await resolver.resolveByTitle('hanzawa naoki'); + + assert.equal(resolved?.tmdbId, 4); + assert.deepEqual(detailCalls, [3, 4]); +}); + +test('resolveByTitle returns null when nothing matches or the key is missing', async () => { + const noMatch: TmdbClient = { + async search() { + return [searchResult({ tmdbId: 1, title: 'Something Else' })]; + }, + async getDetails() { + return details({ tmdbId: 1, allTitles: ['Something Else'] }); + }, + }; + assert.equal( + await createLiveActionMetadataResolver(noMatch, silentLogger).resolveByTitle('Hanzawa Naoki'), + null, + ); + + let infoCount = 0; + const noKey: TmdbClient = { + async search() { + throw new TmdbApiKeyMissingError(); + }, + async getDetails() { + throw new TmdbApiKeyMissingError(); + }, + }; + const resolver = createLiveActionMetadataResolver(noKey, { + info: () => { + infoCount += 1; + }, + warn: () => {}, + }); + assert.equal(await resolver.resolveByTitle('Hanzawa Naoki'), null); + assert.equal(await resolver.resolveById('tv', 1), null); + assert.equal(infoCount, 1); +}); diff --git a/src/core/services/tmdb/live-action-resolver.ts b/src/core/services/tmdb/live-action-resolver.ts new file mode 100644 index 00000000..257399b8 --- /dev/null +++ b/src/core/services/tmdb/live-action-resolver.ts @@ -0,0 +1,75 @@ +import { normalizeTitleIdentity } from '../../utils/title-normalization'; +import type { TmdbMediaType } from '../../../shared/media-kind'; +import { TmdbApiKeyMissingError, type TmdbClient, type TmdbTitleDetails } from './tmdb-client'; + +const MAX_DETAIL_LOOKUPS = 3; + +/** + * Resolves live-action titles for the automatic cover-art path. Anime is + * AniList's job, so only non-animated Japanese-language results qualify, and + * a candidate must match the parsed title exactly under one of the names TMDB + * knows for it. Fuzzy search hits are never trusted on their own: a stray + * filename would otherwise pin the wrong show to a library entry. + */ +export interface LiveActionMetadataResolver { + resolveByTitle(title: string): Promise; + resolveById(tmdbType: TmdbMediaType, tmdbId: number): Promise; +} + +interface Logger { + info(msg: string, ...args: unknown[]): void; + warn(msg: string, ...args: unknown[]): void; +} + +export function titlesMatch(candidate: string, knownTitles: Iterable): boolean { + const key = normalizeTitleIdentity(candidate); + if (!key) return false; + for (const known of knownTitles) { + if (normalizeTitleIdentity(known) === key) return true; + } + return false; +} + +export function createLiveActionMetadataResolver( + client: TmdbClient, + logger: Logger, +): LiveActionMetadataResolver { + let warnedMissingKey = false; + + const guard = async (work: () => Promise): Promise => { + try { + return await work(); + } catch (err) { + if (err instanceof TmdbApiKeyMissingError) { + if (!warnedMissingKey) { + warnedMissingKey = true; + logger.info('tmdb: no API key configured, skipping live-action metadata lookups'); + } + return null; + } + logger.warn('tmdb: lookup failed: %s', err instanceof Error ? err.message : String(err)); + return null; + } + }; + + return { + resolveByTitle(title) { + return guard(async () => { + const results = await client.search(title); + const candidates = results + .filter((result) => result.originalLanguage === 'ja' && !result.isAnimation) + .slice(0, MAX_DETAIL_LOOKUPS); + for (const candidate of candidates) { + const details = await client.getDetails(candidate.tmdbType, candidate.tmdbId); + if (details && !details.isAnimation && titlesMatch(title, details.allTitles)) { + return details; + } + } + return null; + }); + }, + resolveById(tmdbType, tmdbId) { + return guard(() => client.getDetails(tmdbType, tmdbId)); + }, + }; +} diff --git a/src/core/services/tmdb/tmdb-client.test.ts b/src/core/services/tmdb/tmdb-client.test.ts new file mode 100644 index 00000000..22c5a123 --- /dev/null +++ b/src/core/services/tmdb/tmdb-client.test.ts @@ -0,0 +1,335 @@ +import assert from 'node:assert/strict'; +import test, { type TestContext } from 'node:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { TmdbConfig } from '../../../types/integrations'; +import { + TmdbApiKeyMissingError, + createTmdbClient, + createTmdbApiKeyResolver, + resolveTmdbApiKey, +} from './tmdb-client.js'; + +function commandFixture(t: TestContext) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer tmdb command-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + let nextId = 0; + const quotePath = (value: string) => + `"${process.platform === 'win32' ? value : value.replace(/["\\$`]/g, '\\$&')}"`; + return { + dir, + command(source: string): string { + const script = path.join(dir, `credential-${nextId++}.cjs`); + fs.writeFileSync(script, source); + return `${quotePath(process.execPath)} ${quotePath(script)}`; + }, + }; +} + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function captureFetch(handler: (url: URL, init?: RequestInit) => Response) { + const calls: Array<{ url: URL; init?: RequestInit }> = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + calls.push({ url, init }); + return handler(url, init); + }) as typeof fetch; + return { calls, fetchImpl }; +} + +test('resolveTmdbApiKey prefers the literal key and trims it', async (t) => { + const { command } = commandFixture(t); + assert.equal( + await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: command('process.exit(3)') }), + 'abc', + ); + assert.equal(await resolveTmdbApiKey({ apiKey: '', apiKeyCommand: '' }), null); + assert.equal(await resolveTmdbApiKey(undefined), null); +}); + +test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async (t) => { + const { command } = commandFixture(t); + assert.equal( + await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write(" from-cmd ")') }), + 'from-cmd', + ); + assert.equal(await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }), null); +}); + +test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async (t) => { + const { command } = commandFixture(t); + assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled'); + assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine'); + assert.equal( + await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write("mine")') }, 'bundled'), + 'mine', + ); + assert.equal( + await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }, 'bundled'), + 'bundled', + ); +}); + +test('search rejects without a key and never touches the network', async () => { + const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] })); + const client = createTmdbClient({ resolveApiKey: async () => null, fetch: fetchImpl }); + await assert.rejects(client.search('半沢直樹'), TmdbApiKeyMissingError); + assert.equal(calls.length, 0); +}); + +test('search sends a v3 key as a query parameter and drops people from multi results', async () => { + const { calls, fetchImpl } = captureFetch(() => + jsonResponse({ + results: [ + { media_type: 'person', id: 1, name: 'Sakai Masato' }, + { + media_type: 'tv', + id: 61222, + name: 'Hanzawa Naoki', + original_name: '半沢直樹', + original_language: 'ja', + overview: 'A banker fights back.', + poster_path: '/hanzawa.jpg', + first_air_date: '2013-07-07', + genre_ids: [18], + }, + { + media_type: 'movie', + id: 9, + title: 'Anime Film', + original_title: 'アニメ映画', + original_language: 'ja', + release_date: '2020-01-01', + genre_ids: [16], + }, + ], + }), + ); + const client = createTmdbClient({ resolveApiKey: async () => 'v3key', fetch: fetchImpl }); + + const results = await client.search(' 半沢直樹 '); + + assert.equal(calls.length, 1); + const url = calls[0]!.url; + assert.equal(url.pathname, '/3/search/multi'); + assert.equal(url.searchParams.get('query'), '半沢直樹'); + assert.equal(url.searchParams.get('api_key'), 'v3key'); + assert.equal((calls[0]!.init?.headers as Record).Authorization, undefined); + assert.deepEqual(results, [ + { + tmdbId: 61222, + tmdbType: 'tv', + title: 'Hanzawa Naoki', + originalTitle: '半沢直樹', + originalLanguage: 'ja', + overview: 'A banker fights back.', + posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg', + year: 2013, + isAnimation: false, + }, + { + tmdbId: 9, + tmdbType: 'movie', + title: 'Anime Film', + originalTitle: 'アニメ映画', + originalLanguage: 'ja', + overview: null, + posterUrl: null, + year: 2020, + isAnimation: true, + }, + ]); +}); + +test('a v4 read token travels as a bearer header instead of api_key', async () => { + const v4Token = ['eyJ', 'test-header', '.payload', '.sig'].join(''); + const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] })); + const client = createTmdbClient({ + resolveApiKey: async () => v4Token, + fetch: fetchImpl, + }); + await client.search('x'); + assert.equal(calls[0]!.url.searchParams.has('api_key'), false); + assert.equal( + (calls[0]!.init?.headers as Record).Authorization, + `Bearer ${v4Token}`, + ); +}); + +test('getDetails folds translations and alternative titles into the normalized shape', async () => { + const { calls, fetchImpl } = captureFetch(() => + jsonResponse({ + id: 61222, + name: 'Hanzawa Naoki', + original_name: '半沢直樹', + original_language: 'ja', + overview: 'A banker fights back.', + poster_path: '/hanzawa.jpg', + first_air_date: '2013-07-07', + number_of_episodes: 10, + genres: [{ id: 18, name: 'Drama' }], + alternative_titles: { results: [{ iso_3166_1: 'JP', title: 'Hanzawa Naoki Season 1' }] }, + translations: { + translations: [ + { iso_639_1: 'en', data: { name: 'Hanzawa Naoki', overview: 'A banker fights back.' } }, + { iso_639_1: 'ja', data: { name: '半沢直樹', overview: '銀行員の物語' } }, + ], + }, + }), + ); + const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl }); + + const details = await client.getDetails('tv', 61222); + + assert.equal(calls[0]!.url.pathname, '/3/tv/61222'); + assert.equal( + calls[0]!.url.searchParams.get('append_to_response'), + 'alternative_titles,translations', + ); + assert.deepEqual(details, { + tmdbId: 61222, + tmdbType: 'tv', + titleEnglish: 'Hanzawa Naoki', + titleNative: '半沢直樹', + description: 'A banker fights back.', + posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg', + episodesTotal: 10, + year: 2013, + originalLanguage: 'ja', + isAnimation: false, + allTitles: ['Hanzawa Naoki', '半沢直樹', 'Hanzawa Naoki Season 1'], + }); +}); + +test('getDetails falls back to the Japanese overview and counts a movie as one episode', async () => { + const { fetchImpl } = captureFetch(() => + jsonResponse({ + id: 5, + title: '半沢直樹', + original_title: '半沢直樹', + original_language: 'ja', + overview: '', + release_date: '2019-03-01', + translations: { + translations: [{ iso_639_1: 'ja', data: { title: '半沢直樹', overview: 'あらすじ' } }], + }, + }), + ); + const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl }); + const details = await client.getDetails('movie', 5); + assert.equal(details?.titleEnglish, null); + assert.equal(details?.titleNative, '半沢直樹'); + assert.equal(details?.description, 'あらすじ'); + assert.equal(details?.episodesTotal, 1); +}); + +test('getDetails returns null for an unknown id', async () => { + const { fetchImpl } = captureFetch(() => jsonResponse({ status_message: 'nope' }, 404)); + const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl }); + assert.equal(await client.getDetails('tv', 1), null); +}); + +test('client reuses command output across requests and invalidates it when either setting changes', async (t) => { + const fixture = commandFixture(t); + const counter = path.join(fixture.dir, 'calls'); + const createCommand = (key: string) => + fixture.command(` + const fs = require('node:fs'); + const path = require('node:path'); + fs.appendFileSync(path.join(__dirname, 'calls'), 'x'); + process.stdout.write(${JSON.stringify(key)}); + `); + let config: TmdbConfig = { apiKeyCommand: createCommand('command-key') }; + const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] })); + const client = createTmdbClient({ + resolveApiKey: createTmdbApiKeyResolver( + () => config, + () => 'bundled', + ), + fetch: fetchImpl, + }); + await Promise.all([client.search('a'), client.search('b')]); + await client.getDetails('tv', 1); + assert.equal(fs.readFileSync(counter, 'utf8'), 'x'); + assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key')); + config = { ...config, apiKey: 'literal' }; + await client.search('c'); + assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal'); + config = { ...config, apiKey: '' }; + await client.search('d'); + assert.equal(fs.readFileSync(counter, 'utf8'), 'xx'); + config = { apiKeyCommand: createCommand('new-key') }; + await client.search('e'); + assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx'); + assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'new-key'); +}); + +for (const failure of ['error', 'empty'] as const) { + test(`${failure} command output uses a bounded cooldown before retrying`, async (t) => { + const fixture = commandFixture(t); + const counter = path.join(fixture.dir, 'calls'); + const command = fixture.command(` + const fs = require('node:fs'); + const path = require('node:path'); + const counter = path.join(__dirname, 'calls'); + fs.appendFileSync(counter, 'x'); + if (fs.readFileSync(counter, 'utf8').length === 1) process.exit(${failure === 'error' ? 1 : 0}); + process.stdout.write('recovered'); + `); + let now = 1000; + const originalNow = Date.now; + Date.now = () => now; + t.after(() => { + Date.now = originalNow; + }); + let bundledKey: string | null = 'bundled'; + const resolve = createTmdbApiKeyResolver( + () => ({ apiKeyCommand: command }), + () => bundledKey, + ); + assert.deepEqual(await Promise.all([resolve(), resolve()]), ['bundled', 'bundled']); + now += 29_999; + assert.equal(await resolve(), 'bundled'); + bundledKey = null; + assert.equal(await resolve(), null); + assert.equal(fs.readFileSync(counter, 'utf8'), 'x'); + now += 1; + assert.equal(await resolve(), 'recovered'); + assert.equal(await resolve(), 'recovered'); + assert.equal(fs.readFileSync(counter, 'utf8'), 'xx'); + }); +} + +for (const setting of ['apiKey', 'apiKeyCommand'] as const) { + test(`changing ${setting} clears a failed command cooldown`, async (t) => { + const fixture = commandFixture(t); + const counter = path.join(fixture.dir, 'calls'); + const source = ` + const fs = require('node:fs'); + const path = require('node:path'); + fs.appendFileSync(path.join(__dirname, 'calls'), 'x'); + process.exit(1); + `; + let config: TmdbConfig = { apiKeyCommand: fixture.command(source) }; + const resolve = createTmdbApiKeyResolver( + () => config, + () => 'bundled', + ); + assert.equal(await resolve(), 'bundled'); + assert.equal(await resolve(), 'bundled'); + assert.equal(fs.readFileSync(counter, 'utf8'), 'x'); + config = + setting === 'apiKey' + ? { ...config, apiKey: ' ' } + : { apiKeyCommand: fixture.command(source) }; + assert.equal(await resolve(), 'bundled'); + assert.equal(fs.readFileSync(counter, 'utf8'), 'xx'); + }); +} diff --git a/src/core/services/tmdb/tmdb-client.ts b/src/core/services/tmdb/tmdb-client.ts new file mode 100644 index 00000000..12fa4d43 --- /dev/null +++ b/src/core/services/tmdb/tmdb-client.ts @@ -0,0 +1,311 @@ +import * as childProcess from 'node:child_process'; +import type { TmdbMediaType } from '../../../shared/media-kind'; +import type { TmdbConfig } from '../../../types/integrations'; +import type { StatsTmdbSearchResult } from '../../../types/stats-http-contract'; + +export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3'; +const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500'; +const REQUEST_TIMEOUT_MS = 8_000; +const API_KEY_COMMAND_RETRY_MS = 30_000; +const ANIMATION_GENRE_ID = 16; + +export type TmdbSearchResult = StatsTmdbSearchResult; + +export interface TmdbTitleDetails { + tmdbId: number; + tmdbType: TmdbMediaType; + titleEnglish: string | null; + titleNative: string | null; + /** English synopsis, falling back to the Japanese one. */ + description: string | null; + posterUrl: string | null; + episodesTotal: number | null; + year: number | null; + originalLanguage: string; + isAnimation: boolean; + /** Every name TMDB knows for the title, used for exact-title matching. */ + allTitles: string[]; +} + +export interface TmdbClient { + search(query: string): Promise; + getDetails(tmdbType: TmdbMediaType, tmdbId: number): Promise; +} + +export class TmdbApiKeyMissingError extends Error { + constructor() { + super('TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand.'); + this.name = 'TmdbApiKeyMissingError'; + } +} + +export class TmdbRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'TmdbRequestError'; + } +} + +function execCommand(command: string): Promise { + return new Promise((resolve, reject) => { + childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => { + if (err) { + reject(err); + return; + } + resolve(stdout); + }); + }); +} + +/** + * Resolves the key in priority order: the user's literal `apiKey`, then the + * output of `apiKeyCommand`, then the key bundled into release builds. + */ +export async function resolveTmdbApiKey( + config: TmdbConfig | undefined, + bundledKey: string | null = null, +): Promise { + const literal = config?.apiKey?.trim(); + if (literal) return literal; + const command = config?.apiKeyCommand?.trim(); + if (command) { + try { + const key = (await execCommand(command)).trim(); + if (key.length > 0) return key; + } catch { + /* fall through to the bundled key */ + } + } + return bundledKey; +} + +/** Cache successful command output until either credential setting changes. */ +export function createTmdbApiKeyResolver( + getConfig: () => TmdbConfig | undefined, + getBundledKey: () => string | null = () => null, +): () => Promise { + let state: + | { + apiKey: string | undefined; + apiKeyCommand: string | undefined; + pending: Promise | null; + retryAfterMs: number; + } + | undefined; + + return async () => { + const config = getConfig(); + if ( + !state || + state.apiKey !== config?.apiKey || + state.apiKeyCommand !== config?.apiKeyCommand + ) { + state = { + apiKey: config?.apiKey, + apiKeyCommand: config?.apiKeyCommand, + pending: null, + retryAfterMs: 0, + }; + } + const current = state; + const literal = current.apiKey?.trim(); + if (literal) return literal; + if (!current.apiKeyCommand?.trim()) return getBundledKey(); + if (Date.now() < current.retryAfterMs) return getBundledKey(); + current.pending ??= resolveTmdbApiKey(current).then((key) => { + if (!key) { + current.retryAfterMs = Date.now() + API_KEY_COMMAND_RETRY_MS; + current.pending = null; + } + return key; + }); + const key = await current.pending; + return key ?? getBundledKey(); + }; +} + +interface RawSearchItem { + media_type?: string; + id?: number; + name?: string; + original_name?: string; + title?: string; + original_title?: string; + original_language?: string; + overview?: string; + poster_path?: string | null; + first_air_date?: string; + release_date?: string; + genre_ids?: number[]; +} + +interface RawTranslation { + iso_639_1?: string; + data?: { name?: string; title?: string; overview?: string }; +} + +interface RawDetails extends RawSearchItem { + number_of_episodes?: number; + genres?: Array<{ id?: number }>; + alternative_titles?: { results?: Array<{ title?: string }>; titles?: Array<{ title?: string }> }; + translations?: { translations?: RawTranslation[] }; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function yearOf(date: string | undefined): number | null { + const year = Number.parseInt(date?.slice(0, 4) ?? '', 10); + return Number.isFinite(year) && year > 0 ? year : null; +} + +function posterUrlOf(path: string | null | undefined): string | null { + return path ? `${TMDB_POSTER_BASE_URL}${path}` : null; +} + +function mediaTypeOf(value: unknown): TmdbMediaType | null { + return value === 'tv' || value === 'movie' ? value : null; +} + +function normalizeSearchItem(item: RawSearchItem): TmdbSearchResult | null { + const tmdbType = mediaTypeOf(item.media_type); + if (!tmdbType || typeof item.id !== 'number') return null; + const title = nonEmpty(item.name) ?? nonEmpty(item.title); + const originalTitle = nonEmpty(item.original_name) ?? nonEmpty(item.original_title) ?? title; + if (!title || !originalTitle) return null; + return { + tmdbId: item.id, + tmdbType, + title, + originalTitle, + originalLanguage: item.original_language ?? '', + overview: nonEmpty(item.overview), + posterUrl: posterUrlOf(item.poster_path), + year: yearOf(item.first_air_date ?? item.release_date), + isAnimation: (item.genre_ids ?? []).includes(ANIMATION_GENRE_ID), + }; +} + +function normalizeDetails(tmdbType: TmdbMediaType, raw: RawDetails): TmdbTitleDetails | null { + if (typeof raw.id !== 'number') return null; + const localizedTitle = nonEmpty(raw.name) ?? nonEmpty(raw.title); + const originalTitle = nonEmpty(raw.original_name) ?? nonEmpty(raw.original_title); + const originalLanguage = raw.original_language ?? ''; + const translations = raw.translations?.translations ?? []; + const translationFor = (language: string) => + translations.find((entry) => entry.iso_639_1 === language)?.data; + const english = translationFor('en'); + const japanese = translationFor('ja'); + const englishTitle = + nonEmpty(english?.name) ?? + nonEmpty(english?.title) ?? + (localizedTitle && localizedTitle !== originalTitle ? localizedTitle : null); + const nativeTitle = + originalLanguage === 'ja' + ? originalTitle + : (nonEmpty(japanese?.name) ?? nonEmpty(japanese?.title)); + const alternativeTitles = [ + ...(raw.alternative_titles?.results ?? []), + ...(raw.alternative_titles?.titles ?? []), + ].map((entry) => nonEmpty(entry.title)); + const translatedTitles = translations.map( + (entry) => nonEmpty(entry.data?.name) ?? nonEmpty(entry.data?.title), + ); + const allTitles = [ + ...new Set( + [ + localizedTitle, + originalTitle, + englishTitle, + nativeTitle, + ...translatedTitles, + ...alternativeTitles, + ].filter((title): title is string => Boolean(title)), + ), + ]; + return { + tmdbId: raw.id, + tmdbType, + titleEnglish: englishTitle, + titleNative: nativeTitle, + description: + nonEmpty(raw.overview) ?? nonEmpty(english?.overview) ?? nonEmpty(japanese?.overview), + posterUrl: posterUrlOf(raw.poster_path), + episodesTotal: + tmdbType === 'movie' + ? 1 + : typeof raw.number_of_episodes === 'number' && raw.number_of_episodes > 0 + ? raw.number_of_episodes + : null, + year: yearOf(raw.first_air_date ?? raw.release_date), + originalLanguage, + isAnimation: (raw.genres ?? []).some((genre) => genre.id === ANIMATION_GENRE_ID), + allTitles, + }; +} + +// TMDB issues two kinds of credential: a short v3 key that travels as a query +// parameter and a long v4 read token (a JWT) that goes in the Authorization +// header. Users paste whichever the settings page showed them. +function isV4Token(apiKey: string): boolean { + return apiKey.startsWith('eyJ'); +} + +export function createTmdbClient(deps: { + resolveApiKey: () => Promise; + fetch?: typeof fetch; + baseUrl?: string; +}): TmdbClient { + const fetchImpl = deps.fetch ?? fetch; + const baseUrl = (deps.baseUrl ?? TMDB_API_BASE_URL).replace(/\/+$/, ''); + + async function request(path: string, params: Record): Promise { + const apiKey = await deps.resolveApiKey(); + if (!apiKey) throw new TmdbApiKeyMissingError(); + const url = new URL(`${baseUrl}${path}`); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + const headers: Record = { Accept: 'application/json' }; + if (isV4Token(apiKey)) { + headers.Authorization = `Bearer ${apiKey}`; + } else { + url.searchParams.set('api_key', apiKey); + } + const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (res.status === 404) return null; + if (!res.ok) { + throw new TmdbRequestError( + `TMDB request failed: ${res.status} ${res.statusText}`, + res.status, + ); + } + return (await res.json()) as T; + } + + return { + async search(query) { + const trimmed = query.trim(); + if (!trimmed) return []; + const payload = await request<{ results?: RawSearchItem[] }>('/search/multi', { + query: trimmed, + include_adult: 'false', + language: 'en-US', + page: '1', + }); + return (payload?.results ?? []) + .map(normalizeSearchItem) + .filter((item): item is TmdbSearchResult => item !== null); + }, + async getDetails(tmdbType, tmdbId) { + const raw = await request(`/${tmdbType}/${tmdbId}`, { + language: 'en-US', + append_to_response: 'alternative_titles,translations', + }); + return raw ? normalizeDetails(tmdbType, raw) : null; + }, + }; +} diff --git a/src/main.ts b/src/main.ts index a85866d7..3ba0a4ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -427,6 +427,9 @@ import { } from './core/services/anilist/anilist-updater'; import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher'; import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; +import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver'; +import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client'; +import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key'; import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; @@ -961,6 +964,8 @@ const reportFatalError = createFatalErrorReporter({ let forceQuitTimer: ReturnType | null = null; const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); +// Release builds stage a project TMDB key next to the compiled main process. +const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname); const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); const statsServerRuntime = createStatsServerRuntime({ userDataPath: USER_DATA_PATH, @@ -987,6 +992,7 @@ const statsServerRuntime = createStatsServerRuntime({ }, getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(), getAnilistRateLimiter: () => anilistRateLimiter, + getBundledTmdbApiKey: () => bundledTmdbApiKey, resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => { appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); @@ -1685,6 +1691,17 @@ const anilistRateLimiter = createAnilistRateLimiter(); const statsCoverArtFetcher = createCoverArtFetcher( anilistRateLimiter, createLogger('main:stats-cover-art'), + { + liveAction: createLiveActionMetadataResolver( + createTmdbClient({ + resolveApiKey: createTmdbApiKeyResolver( + () => configService.getConfig().tmdb, + () => bundledTmdbApiKey, + ), + }), + createLogger('main:tmdb'), + ), + }, ); const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler()); const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); diff --git a/src/main/runtime/stats-server-runtime.ts b/src/main/runtime/stats-server-runtime.ts index ee1aaf2e..a1206a69 100644 --- a/src/main/runtime/stats-server-runtime.ts +++ b/src/main/runtime/stats-server-runtime.ts @@ -5,6 +5,7 @@ import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, } from '../../core/services'; import { startStatsServer, type StatsServer } from '../../core/services/stats-server'; +import { createTmdbClient, createTmdbApiKeyResolver } from '../../core/services/tmdb/tmdb-client'; import { createLogger } from '../../logger'; import type { ResolvedConfig } from '../../types/config'; import type { AppState } from '../state'; @@ -46,6 +47,8 @@ export interface StatsServerRuntimeDeps { getAnilistRateLimiter: () => NonNullable< Parameters[0]['anilistRateLimiter'] >; + /** Project TMDB key staged into release builds; null for source builds. */ + getBundledTmdbApiKey?: () => string | null; resolveAnkiNoteId: (noteId: number) => number; trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void; resolveSentenceSearchHeadwords: (term: string) => Promise; @@ -155,6 +158,12 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { deps.getResolvedConfig().secondarySub.secondarySubLanguages, getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path, anilistRateLimiter: deps.getAnilistRateLimiter(), + tmdbClient: createTmdbClient({ + resolveApiKey: createTmdbApiKeyResolver( + () => deps.getResolvedConfig().tmdb, + () => deps.getBundledTmdbApiKey?.() ?? null, + ), + }), resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId), resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term), addYomitanNote: async (word: string) => { diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts index 1bc2c9b0..2d884663 100644 --- a/src/prerelease-workflow.test.ts +++ b/src/prerelease-workflow.test.ts @@ -125,18 +125,21 @@ test('prerelease workflow builds and uploads all release platforms', () => { assert.ok(executableRunLines(publish).includes('release/package-size-*.json')); }); -test('release callers pass only the declared macOS signing secrets to packaging', () => { - const secrets = [ +test('release callers pass only the declared packaging secrets', () => { + const signingSecrets = [ 'CSC_LINK', 'CSC_KEY_PASSWORD', 'APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID', ]; - assert.deepEqual( - parsedPackageWorkflow.on?.workflow_call?.secrets, - Object.fromEntries(secrets.map((name) => [name, { required: true }])), - ); + // The bundled TMDB key is optional: artifacts stay valid without it and + // users fall back to their own tmdb.apiKey. + const optionalSecrets = ['SUBMINER_TMDB_API_KEY']; + assert.deepEqual(parsedPackageWorkflow.on?.workflow_call?.secrets, { + ...Object.fromEntries(signingSecrets.map((name) => [name, { required: true }])), + ...Object.fromEntries(optionalSecrets.map((name) => [name, { required: false }])), + }); for (const workflow of [ parsedPrereleaseWorkflow, readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')), @@ -144,7 +147,12 @@ test('release callers pass only the declared macOS signing secrets to packaging' assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml'); assert.deepEqual( workflow.jobs?.package?.secrets, - Object.fromEntries(secrets.map((name) => [name, '${{ secrets.' + name + ' }}'])), + Object.fromEntries( + [...signingSecrets, ...optionalSecrets].map((name) => [ + name, + '${{ secrets.' + name + ' }}', + ]), + ), ); } }); diff --git a/src/shared/media-kind.ts b/src/shared/media-kind.ts index 5899bb58..a17f53c4 100644 --- a/src/shared/media-kind.ts +++ b/src/shared/media-kind.ts @@ -1,3 +1,37 @@ -export const MEDIA_KINDS = ['anime', 'youtube'] as const; - +/** + * Library entry classification shared by the tracker, the stats HTTP layer and + * the stats SPA. Anime entries link to AniList, live-action entries link to + * TMDB, and YouTube entries are channels grouping tracked videos. + */ +export const MEDIA_KINDS = ['anime', 'live_action', 'youtube'] as const; export type MediaKind = (typeof MEDIA_KINDS)[number]; + +export const TMDB_MEDIA_TYPES = ['tv', 'movie'] as const; +export type TmdbMediaType = (typeof TMDB_MEDIA_TYPES)[number]; + +export function isMediaKind(value: unknown): value is MediaKind { + return typeof value === 'string' && (MEDIA_KINDS as readonly string[]).includes(value); +} + +export function isTmdbMediaType(value: unknown): value is TmdbMediaType { + return typeof value === 'string' && (TMDB_MEDIA_TYPES as readonly string[]).includes(value); +} + +/** + * Anime and live-action entries share one title namespace: both come from the + * filename/Jellyfin parser and an entry switches between them when it is + * relinked from AniList to TMDB or back. YouTube channels are a separate + * namespace, so a channel never combines with a series entry and a same-named + * anime and channel stay separate. + */ +export function shareTitleNamespace(a: MediaKind, b: MediaKind): boolean { + return (a === 'youtube') === (b === 'youtube'); +} + +/** + * SQL predicate matching rows in the same title namespace as the bound kind + * parameter (the SQL twin of `shareTitleNamespace`). + */ +export function sameTitleNamespaceSql(column = 'media_kind'): string { + return `(${column} = 'youtube') = (? = 'youtube')`; +} diff --git a/src/stats-daemon-runner.ts b/src/stats-daemon-runner.ts index 0e59fafa..16e40729 100644 --- a/src/stats-daemon-runner.ts +++ b/src/stats-daemon-runner.ts @@ -7,6 +7,9 @@ import { createLogger, setLogLevel } from './logger'; import { ImmersionTrackerService } from './core/services/immersion-tracker-service'; import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher'; import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; +import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver'; +import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client'; +import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key'; import { startStatsServer } from './core/services/stats-server'; import { removeBackgroundStatsServerState, @@ -124,6 +127,7 @@ const daemonUserDataPath = userDataPath; const statePath = path.join(userDataPath, 'stats-daemon.json'); const knownWordCachePath = path.join(userDataPath, 'known-words-cache.json'); const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); +const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname); const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js'); let tracker: ImmersionTrackerService | null = null; @@ -202,8 +206,16 @@ async function main(): Promise { }, }, }); + const tmdbClient = createTmdbClient({ + resolveApiKey: createTmdbApiKeyResolver( + () => configService.reloadConfig().tmdb, + () => bundledTmdbApiKey, + ), + }); tracker.setCoverArtFetcher( - createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')), + createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art'), { + liveAction: createLiveActionMetadataResolver(tmdbClient, createLogger('stats-daemon:tmdb')), + }), ); statsServer = await startStatsServer({ @@ -212,6 +224,7 @@ async function main(): Promise { tracker, knownWordCachePath, getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect, + tmdbClient, getYomitanAnkiDeckName: async () => await readStatsYomitanDeckName({ helperScriptPath: wordHelperScriptPath, diff --git a/src/types/config.ts b/src/types/config.ts index b6607be2..71f8f4f3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -12,6 +12,7 @@ import type { ImmersionTrackingRetentionMode, ImmersionTrackingRetentionPreset, TsukihimeConfig, + TmdbConfig, JellyfinConfig, JimakuConfig, JimakuLanguagePreference, @@ -160,6 +161,7 @@ export interface Config { /** @deprecated Use tsukihime. */ animetosho?: TsukihimeConfig; tsukihime?: TsukihimeConfig; + tmdb?: TmdbConfig; anilist?: AnilistConfig; yomitan?: YomitanConfig; jellyfin?: JellyfinConfig; @@ -333,6 +335,10 @@ export interface ResolvedConfig { apiBaseUrl: string; maxSearchResults: number; }; + tmdb: { + apiKey: string; + apiKeyCommand: string; + }; anilist: { enabled: boolean; accessToken: string; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index 096cfb0d..d7ef5ddc 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -287,3 +287,9 @@ export interface TsukihimeConfig { apiBaseUrl?: string; maxSearchResults?: number; } + +/** TMDB (The Movie Database) access for live-action drama and movie metadata. */ +export interface TmdbConfig { + apiKey?: string; + apiKeyCommand?: string; +} diff --git a/src/types/stats-http-contract.ts b/src/types/stats-http-contract.ts index a920761a..53dd8cbe 100644 --- a/src/types/stats-http-contract.ts +++ b/src/types/stats-http-contract.ts @@ -27,6 +27,7 @@ import type { WatchTimePerAnime, WordDetailData, } from './stats-wire'; +import type { TmdbMediaType } from '../shared/media-kind'; export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all'; export type StatsTrendGroupBy = 'day' | 'month'; @@ -78,6 +79,25 @@ export interface StatsAnilistSearchResult { title: { romaji: string | null; english: string | null; native: string | null } | null; } +export interface StatsTmdbSearchResult { + tmdbId: number; + tmdbType: TmdbMediaType; + /** English title when TMDB has one, otherwise the original title. */ + title: string; + originalTitle: string; + originalLanguage: string; + overview: string | null; + posterUrl: string | null; + year: number | null; + /** True when TMDB tags the title with the Animation genre. */ + isAnimation: boolean; +} + +export interface StatsTmdbAssignment { + tmdbId: number; + tmdbType: TmdbMediaType; +} + export interface StatsAnilistAssignment { anilistId: number; titleRomaji?: string | null; @@ -209,11 +229,13 @@ export interface StatsJsonResponseMap { moveVideoToAnime: StatsMoveVideoResponse; dismissAnimeMergeRecommendation: StatsOkResponse; anilistSearch: StatsAnilistSearchResult[]; + tmdbSearch: StatsTmdbSearchResult[]; knownWords: string[]; knownWordsSummary: StatsKnownWordsSummary; animeKnownWordsSummary: StatsKnownWordsSummary; mediaKnownWordsSummary: StatsKnownWordsSummary; reassignAnimeAnilist: StatsOkResponse; + reassignAnimeTmdb: StatsOkResponse; coverImages: StatsCoverImagesData; episodeDetail: EpisodeDetailData; ankiBrowse: StatsAnkiBrowseResponse; @@ -302,6 +324,8 @@ export interface StatsHttpClient { getMediaKnownWordsSummary: (videoId: number) => Promise; searchAnilist: (query: string) => Promise; reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise; + searchTmdb: (query: string) => Promise; + reassignAnimeTmdb: (animeId: number, info: StatsTmdbAssignment) => Promise; mineCard: (params: StatsMineCardParams) => Promise; ankiBrowse: (noteId: number) => Promise; ankiNotesInfo: (noteIds: number[]) => Promise; diff --git a/src/types/stats-wire.ts b/src/types/stats-wire.ts index c65e1d9f..4875085f 100644 --- a/src/types/stats-wire.ts +++ b/src/types/stats-wire.ts @@ -1,4 +1,5 @@ -import type { MediaKind } from '../shared/media-kind'; +import type { MediaKind, TmdbMediaType } from '../shared/media-kind'; + export interface SessionSummary { sessionId: number; canonicalTitle: string | null; @@ -245,6 +246,8 @@ export interface AnimeLibraryItem { animeId: number; canonicalTitle: string; anilistId: number | null; + tmdbId: number | null; + tmdbType: TmdbMediaType | null; totalSessions: number; totalActiveMs: number; totalCards: number; @@ -267,6 +270,8 @@ export interface AnimeDetailData { animeId: number; canonicalTitle: string; anilistId: number | null; + tmdbId: number | null; + tmdbType: TmdbMediaType | null; titleRomaji: string | null; titleEnglish: string | null; titleNative: string | null; diff --git a/stats/src/components/anime/AnimeCard.test.tsx b/stats/src/components/anime/AnimeCard.test.tsx index 7d087b31..36a1c9c1 100644 --- a/stats/src/components/anime/AnimeCard.test.tsx +++ b/stats/src/components/anime/AnimeCard.test.tsx @@ -11,6 +11,8 @@ test('AnimeCard includes linked AniList id in cover URLs to avoid stale library animeId: 42, canonicalTitle: 'Test Anime', anilistId: 21699, + tmdbId: null, + tmdbType: null, totalSessions: 1, totalActiveMs: 600_000, totalCards: 0, diff --git a/stats/src/components/anime/AnimeCard.tsx b/stats/src/components/anime/AnimeCard.tsx index fdb67be7..6243da55 100644 --- a/stats/src/components/anime/AnimeCard.tsx +++ b/stats/src/components/anime/AnimeCard.tsx @@ -29,7 +29,7 @@ export function AnimeCard({ {selectable && ( diff --git a/stats/src/components/anime/AnimeCoverImage.test.tsx b/stats/src/components/anime/AnimeCoverImage.test.tsx index bed2148c..db520547 100644 --- a/stats/src/components/anime/AnimeCoverImage.test.tsx +++ b/stats/src/components/anime/AnimeCoverImage.test.tsx @@ -20,6 +20,8 @@ test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', ( animeId: 42, canonicalTitle: 'Test Anime', anilistId: 21699, + tmdbId: null, + tmdbType: null, titleRomaji: null, titleEnglish: null, titleNative: null, diff --git a/stats/src/components/anime/AnimeDetailView.tsx b/stats/src/components/anime/AnimeDetailView.tsx index b185675b..88e7db95 100644 --- a/stats/src/components/anime/AnimeDetailView.tsx +++ b/stats/src/components/anime/AnimeDetailView.tsx @@ -7,6 +7,7 @@ import { AnimeHeader } from './AnimeHeader'; import { EpisodeList } from './EpisodeList'; import { AnimeWordList } from './AnimeWordList'; import { AnilistSelector } from './AnilistSelector'; +import { TmdbSelector } from './TmdbSelector'; import { AnimeOverviewStats } from './AnimeOverviewStats'; import { CHART_THEME } from '../../lib/chart-theme'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; @@ -20,11 +21,11 @@ interface AnimeDetailViewProps { /** Called after the whole library entry is deleted, so the caller can refresh. */ onAnimeDeleted?: () => void; /** - * Called after the AniList link changes. The library list caches the old - * anilistId (and with it the cover URL), so it has to refetch or the grid - * keeps showing the previous title's art. + * Called after the AniList or TMDB link changes. The library list caches the + * old provider ids (and with them the cover URL and media kind), so it has to + * refetch or the grid keeps showing the previous title's art. */ - onAnilistRelinked?: () => void; + onProviderRelinked?: () => void; /** Called after an episode is reassigned to another entry. */ onEpisodeMoved?: () => void; } @@ -151,11 +152,12 @@ export function AnimeDetailView({ onNavigateToWord, onOpenEpisodeDetail, onAnimeDeleted, - onAnilistRelinked, + onProviderRelinked, onEpisodeMoved, }: AnimeDetailViewProps) { const { data, loading, error, reload } = useAnimeDetail(animeId); const [showAnilistSelector, setShowAnilistSelector] = useState(false); + const [showTmdbSelector, setShowTmdbSelector] = useState(false); const [coverRetryToken, setCoverRetryToken] = useState(0); const [isDeletingAnime, setIsDeletingAnime] = useState(false); const [deleteError, setDeleteError] = useState(null); @@ -219,6 +221,7 @@ export function AnimeDetailView({ anilistEntries={anilistEntries ?? []} coverRetryToken={coverRetryToken} onChangeAnilist={() => setShowAnilistSelector(true)} + onChangeTmdb={() => setShowTmdbSelector(true)} onDeleteAnime={() => void handleDeleteAnime()} isDeletingAnime={isDeletingAnime} /> @@ -238,7 +241,7 @@ export function AnimeDetailView({ /> - {detail.mediaKind === 'anime' && showAnilistSelector && ( + {detail.mediaKind !== 'youtube' && showAnilistSelector && ( value + 1); reload(); - onAnilistRelinked?.(); + onProviderRelinked?.(); + }} + /> + )} + {showTmdbSelector && ( + setShowTmdbSelector(false)} + onLinked={() => { + setShowTmdbSelector(false); + setCoverRetryToken((value) => value + 1); + reload(); + onProviderRelinked?.(); }} /> )} diff --git a/stats/src/components/anime/AnimeDialogAccessibility.test.tsx b/stats/src/components/anime/AnimeDialogAccessibility.test.tsx index 5f202307..c3f8f813 100644 --- a/stats/src/components/anime/AnimeDialogAccessibility.test.tsx +++ b/stats/src/components/anime/AnimeDialogAccessibility.test.tsx @@ -50,6 +50,8 @@ function libraryItem(animeId: number, title: string): AnimeLibraryItem { animeId, canonicalTitle: title, anilistId: null, + tmdbId: null, + tmdbType: null, totalSessions: 1, totalActiveMs: 1000, totalCards: 0, diff --git a/stats/src/components/anime/AnimeHeader.test.tsx b/stats/src/components/anime/AnimeHeader.test.tsx index 3d19d0ca..4277166e 100644 --- a/stats/src/components/anime/AnimeHeader.test.tsx +++ b/stats/src/components/anime/AnimeHeader.test.tsx @@ -10,6 +10,8 @@ const DETAIL: AnimeDetailData['detail'] = { animeId: 3, canonicalTitle: 'Project Radio Noise Season 2', anilistId: 20661, + tmdbId: null, + tmdbType: null, titleRomaji: 'Toaru Kagaku no Railgun S', titleEnglish: 'A Certain Scientific Railgun S', titleNative: null, @@ -71,6 +73,46 @@ test('confirmAnimeDelete spells out how much data the entry deletion removes', a assert.match(seen[0] ?? '', /every session and stat/); }); +test('AnimeHeader shows TMDB actions for a live-action entry and hides AniList links', () => { + const markup = renderToStaticMarkup( + {}} + onChangeTmdb={() => {}} + />, + ); + + assert.match(markup, /https:\/\/www\.themoviedb\.org\/tv\/61222/); + assert.match(markup, /Change TMDB Title/); + assert.match(markup, /Live action/); + assert.match(markup, /A banker fights back\./); + assert.doesNotMatch(markup, /anilist\.co/); +}); + +test('AnimeHeader offers to link an unlinked anime entry to TMDB', () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + assert.match(markup, /Link to TMDB/); + assert.doesNotMatch(markup, /themoviedb\.org/); +}); + test('YouTube channel headers show videos and omit all AniList controls', () => { const markup = renderToStaticMarkup( void; + onChangeTmdb?: () => void; onDeleteAnime?: () => void; isDeletingAnime?: boolean; } @@ -34,6 +35,7 @@ export function AnimeHeader({ anilistEntries, coverRetryToken = 0, onChangeAnilist, + onChangeTmdb, onDeleteAnime, isDeletingAnime = false, }: AnimeHeaderProps) { @@ -44,7 +46,12 @@ export function AnimeHeader({ const uniqueAltTitles = [...new Set(altTitles)]; const hasMultipleEntries = anilistEntries.length > 1; - const coverCacheToken = (detail.anilistId ?? 0) * 1_000_000 + coverRetryToken; + const isLiveAction = detail.mediaKind === 'live_action'; + const tmdbUrl = + detail.tmdbId && detail.tmdbType + ? `https://www.themoviedb.org/${detail.tmdbType}/${detail.tmdbId}` + : null; + const coverCacheToken = (detail.anilistId ?? detail.tmdbId ?? 0) * 1_000_000 + coverRetryToken; return (
@@ -61,13 +68,31 @@ export function AnimeHeader({ {uniqueAltTitles.join(' · ')}
)} -
- {isYoutube ? 'YouTube channel · ' : ''} - {detail.episodeCount} {isYoutube ? 'video' : 'episode'} - {detail.episodeCount !== 1 ? 's' : ''} +
+ + {isYoutube ? 'YouTube channel · ' : ''} + {detail.episodeCount} {isYoutube ? 'video' : 'episode'} + {detail.episodeCount !== 1 ? 's' : ''} + + {isLiveAction && ( + + {detail.tmdbType === 'movie' ? 'Movie' : 'Live action'} + + )}
+ {tmdbUrl && ( + + View on TMDB {'\u2197'} + + )} {!isYoutube && + !isLiveAction && (anilistEntries.length > 0 ? ( hasMultipleEntries ? ( anilistEntries.map((entry) => ) @@ -103,6 +128,16 @@ export function AnimeHeader({ : 'Link to AniList'} )} + {!isYoutube && onChangeTmdb && ( + + )} {onDeleteAnime && ( ))}
@@ -269,7 +284,7 @@ export function AnimeTab({ {checkedEntries.length === 0 ? 'Pick the duplicate entries to combine' : mixedKindsChecked - ? 'Anime and YouTube channels cannot be combined' + ? 'YouTube channels cannot be combined with other titles' : `${checkedEntries.length} selected`}
+ + handleInput(e.target.value)} + placeholder="Search TMDB for a drama or movie..." + className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue" + /> + + +
+ {loading &&
Searching...
} + {error &&
{error}
} + {!loading && !error && results.length === 0 && query.trim() && ( +
No results
+ )} + {results.map((media) => ( + + ))} +
+ + + ); +} diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index 2d7186aa..3462d49c 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -15,6 +15,7 @@ import type { StatsMergeAnimeResponse, StatsMoveVideoRequest, StatsMoveVideoResponse, + StatsTmdbAssignment, StatsTrendGroupBy, StatsTrendRange, StatsVideoWatchedRequest, @@ -240,6 +241,15 @@ export const apiClient = { body: JSON.stringify(info), }); }, + searchTmdb: (query: string) => + fetchJson('tmdbSearch', `/api/stats/tmdb/search?q=${encodeURIComponent(query)}`), + reassignAnimeTmdb: async (animeId: number, info: StatsTmdbAssignment): Promise => { + await fetchResponse(`/api/stats/anime/${animeId}/tmdb`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(info satisfies StatsTmdbAssignment), + }); + }, mineCard: async (params: StatsMineCardParams): Promise => { const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, { method: 'POST', diff --git a/stats/src/lib/yomitan-lookup.test.tsx b/stats/src/lib/yomitan-lookup.test.tsx index f364becc..dac82c36 100644 --- a/stats/src/lib/yomitan-lookup.test.tsx +++ b/stats/src/lib/yomitan-lookup.test.tsx @@ -118,6 +118,8 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => { canonicalTitle: 'Anime', mediaKind: 'anime', anilistId: null, + tmdbId: null, + tmdbType: null, titleRomaji: null, titleEnglish: null, titleNative: null,