From a9e33618e7c8c48aea2eb4b2753ee7c44b801e04 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 16 Mar 2026 01:54:35 -0700 Subject: [PATCH] chore: apply remaining workspace formatting and updates --- launcher/commands/command-modules.test.ts | 62 +-- launcher/commands/stats-command.ts | 8 +- launcher/main.test.ts | 49 ++- scripts/update-frequency.ts | 5 +- src/anki-integration.ts | 4 +- src/anki-integration/known-word-cache.ts | 4 +- src/anki-integration/polling.test.ts | 5 +- src/cli/args.test.ts | 6 +- src/config/config.test.ts | 7 +- .../definitions/options-integrations.ts | 3 +- src/config/resolve/anki-connect.test.ts | 14 +- src/config/resolve/anki-connect.ts | 7 +- src/config/resolve/stats.ts | 14 +- src/core/services/anilist/anilist-updater.ts | 2 +- .../anilist/cover-art-fetcher.test.ts | 9 +- .../services/anilist/cover-art-fetcher.ts | 40 +- .../immersion-tracker-service.test.ts | 18 +- .../services/immersion-tracker-service.ts | 92 ++-- .../immersion-tracker/metadata.test.ts | 44 +- src/core/services/immersion-tracker/query.ts | 394 +++++++++++++----- .../immersion-tracker/storage-session.test.ts | 29 +- .../services/immersion-tracker/storage.ts | 14 +- src/core/services/index.ts | 5 +- src/core/services/ipc.test.ts | 7 +- src/core/services/ipc.ts | 29 +- src/core/services/mpv-protocol.test.ts | 5 +- src/core/services/stats-server.ts | 87 +++- src/core/services/stats-window-runtime.ts | 8 +- src/core/services/subtitle-cue-parser.test.ts | 41 +- src/core/services/subtitle-cue-parser.ts | 29 +- src/core/services/subtitle-prefetch.test.ts | 9 +- src/core/services/tokenizer.test.ts | 95 +++-- src/core/services/tokenizer.ts | 18 +- .../services/tokenizer/annotation-stage.ts | 6 +- .../tokenizer/yomitan-parser-runtime.ts | 4 +- src/main.ts | 21 +- src/main/runtime/anilist-media-guess.test.ts | 7 +- .../anilist-post-watch-main-deps.test.ts | 6 +- src/main/runtime/mpv-main-event-main-deps.ts | 4 +- src/main/runtime/stats-cli-command.test.ts | 9 +- src/main/runtime/stats-cli-command.ts | 4 +- .../runtime/subtitle-prefetch-source.test.ts | 11 +- src/preload-stats.ts | 3 +- stats/index.html | 2 +- stats/src/App.tsx | 40 +- .../src/components/anime/AnilistSelector.tsx | 12 +- stats/src/components/anime/AnimeCardsList.tsx | 4 +- .../src/components/anime/AnimeCoverImage.tsx | 4 +- .../src/components/anime/AnimeDetailView.tsx | 43 +- stats/src/components/anime/AnimeHeader.tsx | 20 +- stats/src/components/anime/AnimeTab.tsx | 16 +- stats/src/components/anime/AnimeWordList.tsx | 16 +- .../components/anime/CollapsibleSection.tsx | 16 +- stats/src/components/layout/StatCard.tsx | 23 +- stats/src/components/library/CoverImage.tsx | 4 +- stats/src/components/library/MediaHeader.tsx | 10 +- .../components/library/MediaWatchChart.tsx | 14 +- stats/src/components/overview/HeroStats.tsx | 10 +- stats/src/components/overview/OverviewTab.tsx | 7 +- .../components/overview/RecentSessions.tsx | 53 ++- .../src/components/sessions/SessionDetail.tsx | 50 ++- .../components/trends/DateRangeSelector.tsx | 2 +- .../components/trends/StackedTrendChart.tsx | 39 +- stats/src/components/trends/TrendChart.tsx | 50 ++- stats/src/components/trends/TrendsTab.tsx | 25 +- .../vocabulary/ExclusionManager.tsx | 12 +- .../vocabulary/FrequencyRankTable.tsx | 26 +- .../vocabulary/KanjiDetailPanel.tsx | 53 ++- .../VocabularyOccurrencesDrawer.tsx | 8 +- .../components/vocabulary/VocabularyTab.tsx | 35 +- .../components/vocabulary/WordDetailPanel.tsx | 225 ++++++---- stats/src/components/vocabulary/WordList.tsx | 30 +- .../src/components/vocabulary/pos-helpers.tsx | 3 +- stats/src/hooks/useAnimeLibrary.ts | 16 +- stats/src/hooks/useExcludedWords.ts | 30 +- stats/src/hooks/useStreakCalendar.ts | 16 +- stats/src/hooks/useTrends.ts | 29 +- stats/src/lib/dashboard-data.test.ts | 17 +- stats/src/lib/dashboard-data.ts | 31 +- stats/src/lib/ipc-client.ts | 30 +- stats/src/main.tsx | 2 +- stats/src/styles/globals.css | 5 +- 82 files changed, 1530 insertions(+), 736 deletions(-) diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 2d126cb..c438f2b 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -149,7 +149,13 @@ test('stats command launches attached app command with response path', async () assert.equal(handled, true); assert.deepEqual(forwarded, [ - ['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--log-level', 'debug'], + [ + '--stats', + '--stats-response-path', + '/tmp/subminer-stats-test/response.json', + '--log-level', + 'debug', + ], ]); }); @@ -187,40 +193,34 @@ test('stats command throws when stats response reports an error', async () => { const context = createContext(); context.args.stats = true; - await assert.rejects( - async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => 0, - waitForStatsResponse: async () => ({ - ok: false, - error: 'Immersion tracking is disabled in config.', - }), - removeDir: () => {}, - }); - }, - /Immersion tracking is disabled in config\./, - ); + await assert.rejects(async () => { + await runStatsCommand(context, { + createTempDir: () => '/tmp/subminer-stats-test', + joinPath: (...parts) => parts.join('/'), + runAppCommandAttached: async () => 0, + waitForStatsResponse: async () => ({ + ok: false, + error: 'Immersion tracking is disabled in config.', + }), + removeDir: () => {}, + }); + }, /Immersion tracking is disabled in config\./); }); test('stats command fails if attached app exits before startup response', async () => { const context = createContext(); context.args.stats = true; - await assert.rejects( - async () => { - await runStatsCommand(context, { - createTempDir: () => '/tmp/subminer-stats-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandAttached: async () => 2, - waitForStatsResponse: async () => { - await new Promise((resolve) => setTimeout(resolve, 25)); - return { ok: true, url: 'http://127.0.0.1:5175' }; - }, - removeDir: () => {}, - }); - }, - /Stats app exited before startup response \(status 2\)\./, - ); + await assert.rejects(async () => { + await runStatsCommand(context, { + createTempDir: () => '/tmp/subminer-stats-test', + joinPath: (...parts) => parts.join('/'), + runAppCommandAttached: async () => 2, + waitForStatsResponse: async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + return { ok: true, url: 'http://127.0.0.1:5175' }; + }, + removeDir: () => {}, + }); + }, /Stats app exited before startup response \(status 2\)\./); }); diff --git a/launcher/commands/stats-command.ts b/launcher/commands/stats-command.ts index 7bd7397..da10c5c 100644 --- a/launcher/commands/stats-command.ts +++ b/launcher/commands/stats-command.ts @@ -81,12 +81,16 @@ export async function runStatsCommand( 'stats', ); const startupResult = await Promise.race([ - deps.waitForStatsResponse(responsePath).then((response) => ({ kind: 'response' as const, response })), + deps + .waitForStatsResponse(responsePath) + .then((response) => ({ kind: 'response' as const, response })), attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })), ]); if (startupResult.kind === 'exit') { if (startupResult.status !== 0) { - throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`); + throw new Error( + `Stats app exited before startup response (status ${startupResult.status}).`, + ); } const response = await deps.waitForStatsResponse(responsePath); if (!response.ok) { diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 7beda39..9e2dce1 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -335,15 +335,18 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co }); }); -test('stats command launches attached app flow and waits for response file', { timeout: 15000 }, () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh +test( + 'stats command launches attached app flow and waits for response file', + { timeout: 15000 }, + () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const appPath = path.join(root, 'fake-subminer.sh'); + const capturePath = path.join(root, 'captured-args.txt'); + fs.writeFileSync( + appPath, + `#!/bin/sh set -eu response_path="" prev="" @@ -369,20 +372,24 @@ mkdir -p "$(dirname "$response_path")" printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path" exit 0 `, - ); - fs.chmodSync(appPath, 0o755); + ); + fs.chmodSync(appPath, 0o755); - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_STATS_CAPTURE: capturePath, - }; - const result = runLauncher(['stats', '--log-level', 'debug'], env); + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_STATS_CAPTURE: capturePath, + }; + const result = runLauncher(['stats', '--log-level', 'debug'], env); - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - assert.match(fs.readFileSync(capturePath, 'utf8'), /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/); - }); -}); + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match( + fs.readFileSync(capturePath, 'utf8'), + /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/, + ); + }); + }, +); test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => { withTempDir((root) => { diff --git a/scripts/update-frequency.ts b/scripts/update-frequency.ts index 25cc7c7..bae0512 100644 --- a/scripts/update-frequency.ts +++ b/scripts/update-frequency.ts @@ -15,10 +15,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import Database from 'libsql'; -const DB_PATH = join( - process.env.HOME ?? '~', - '.config/SubMiner/immersion.sqlite', -); +const DB_PATH = join(process.env.HOME ?? '~', '.config/SubMiner/immersion.sqlite'); function parsePositiveNumber(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null; diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 136de04..1a37f8f 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -1122,7 +1122,9 @@ export class AnkiIntegration { this.mediaGenerator.cleanup(); } - setRecordCardsMinedCallback(callback: ((count: number, noteIds?: number[]) => void) | null): void { + setRecordCardsMinedCallback( + callback: ((count: number, noteIds?: number[]) => void) | null, + ): void { this.recordCardsMinedCallback = callback; } } diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index 8fedc15..a124f6e 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -218,7 +218,9 @@ export class KnownWordCacheManager { private getKnownWordDecks(): string[] { const configuredDecks = this.deps.getConfig().knownWords?.decks; if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) { - return Object.keys(configuredDecks).map((d) => d.trim()).filter((d) => d.length > 0); + return Object.keys(configuredDecks) + .map((d) => d.trim()) + .filter((d) => d.length > 0); } const deck = this.deps.getConfig().deck?.trim(); diff --git a/src/anki-integration/polling.test.ts b/src/anki-integration/polling.test.ts index d62d1ba..93a330e 100644 --- a/src/anki-integration/polling.test.ts +++ b/src/anki-integration/polling.test.ts @@ -6,7 +6,10 @@ import { PollingRunner } from './polling'; test('polling runner records newly added cards after initialization', async () => { const recordedCards: number[] = []; let tracked = new Set(); - const responses = [[10, 11], [10, 11, 12, 13]]; + const responses = [ + [10, 11], + [10, 11, 12, 13], + ]; const runner = new PollingRunner({ getDeck: () => 'Mining', getPollingRate: () => 250, diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 305260c..a536621 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -143,7 +143,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(dictionaryTarget.dictionary, true); assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv'); - const stats = parseArgs(['--stats', '--stats-response-path', '/tmp/subminer-stats-response.json']); + const stats = parseArgs([ + '--stats', + '--stats-response-path', + '/tmp/subminer-stats-response.json', + ]); assert.equal(stats.stats, true); assert.equal(stats.statsResponsePath, '/tmp/subminer-stats-response.json'); assert.equal(hasExplicitCommand(stats), true); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 564445f..a25c1f4 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1528,10 +1528,7 @@ test('validates ankiConnect knownWords and n+1 color values', () => { const warnings = service.getWarnings(); assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne); - assert.equal( - config.ankiConnect.knownWords.color, - DEFAULT_CONFIG.ankiConnect.knownWords.color, - ); + assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color')); }); @@ -1586,7 +1583,7 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90); assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); assert.deepEqual(config.ankiConnect.knownWords.decks, { - 'Mining': ['Expression', 'Word', 'Reading', 'Word Reading'], + Mining: ['Expression', 'Word', 'Reading', 'Word Reading'], 'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'], }); assert.equal(config.ankiConnect.knownWords.color, '#a6da95'); diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index c219f5c..085c6b3 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -105,7 +105,8 @@ export function buildIntegrationConfigOptionRegistry( path: 'ankiConnect.knownWords.decks', kind: 'object', defaultValue: defaultConfig.ankiConnect.knownWords.decks, - description: 'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.', + description: + 'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.', }, { path: 'ankiConnect.nPlusOne.nPlusOne', diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index 44cf256..764d847 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -33,7 +33,10 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled, ); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled')); - assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'), false); + assert.equal( + warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'), + false, + ); }); test('normalizes ankiConnect tags by trimming and deduping', () => { @@ -52,16 +55,19 @@ test('normalizes ankiConnect tags by trimming and deduping', () => { test('accepts knownWords.decks object format with field arrays', () => { const { context, warnings } = makeContext({ - knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], 'Mining': ['Expression'] } }, + knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], Mining: ['Expression'] } }, }); applyAnkiConnectResolution(context); assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, { 'Core Deck': ['Word', 'Reading'], - 'Mining': ['Expression'], + Mining: ['Expression'], }); - assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'), false); + assert.equal( + warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'), + false, + ); }); test('converts legacy knownWords.decks array to object with default fields', () => { diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 8d9ce24..db562d3 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -624,7 +624,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { ); } - const knownWordsConfig = isObject(ac.knownWords) ? (ac.knownWords as Record) : {}; + const knownWordsConfig = isObject(ac.knownWords) + ? (ac.knownWords as Record) + : {}; const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled); @@ -723,8 +725,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) && legacyBehaviorNPlusOneRefreshMinutes > 0; if (hasValidLegacyRefreshMinutes) { - context.resolved.ankiConnect.knownWords.refreshMinutes = - legacyBehaviorNPlusOneRefreshMinutes; + context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes; context.warn( 'ankiConnect.behavior.nPlusOneRefreshMinutes', behavior.nPlusOneRefreshMinutes, diff --git a/src/config/resolve/stats.ts b/src/config/resolve/stats.ts index f7e8dec..697ae98 100644 --- a/src/config/resolve/stats.ts +++ b/src/config/resolve/stats.ts @@ -24,13 +24,23 @@ export function applyStatsConfig(context: ResolveContext): void { if (autoStartServer !== undefined) { resolved.stats.autoStartServer = autoStartServer; } else if (src.stats.autoStartServer !== undefined) { - warn('stats.autoStartServer', src.stats.autoStartServer, resolved.stats.autoStartServer, 'Expected boolean.'); + warn( + 'stats.autoStartServer', + src.stats.autoStartServer, + resolved.stats.autoStartServer, + 'Expected boolean.', + ); } const autoOpenBrowser = asBoolean(src.stats.autoOpenBrowser); if (autoOpenBrowser !== undefined) { resolved.stats.autoOpenBrowser = autoOpenBrowser; } else if (src.stats.autoOpenBrowser !== undefined) { - warn('stats.autoOpenBrowser', src.stats.autoOpenBrowser, resolved.stats.autoOpenBrowser, 'Expected boolean.'); + warn( + 'stats.autoOpenBrowser', + src.stats.autoOpenBrowser, + resolved.stats.autoOpenBrowser, + 'Expected boolean.', + ); } } diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 601f041..9d704e0 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -76,7 +76,7 @@ export function runGuessit(target: string): Promise { export interface GuessAnilistMediaInfoDeps { runGuessit: (target: string) => Promise; -}; +} function firstString(value: unknown): string | null { if (typeof value === 'string') { diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index 5ab134d..cd6dab0 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -111,7 +111,8 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn const db = new Database(dbPath); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', { - canonicalTitle: '[Jellyfin] Little Witch Academia S02E05 - 025 - Pact of the Dragon (2020) [1080p].mkv', + canonicalTitle: + '[Jellyfin] Little Witch Academia S02E05 - 025 - Pact of the Dragon (2020) [1080p].mkv', sourcePath: '/tmp/cover-fetcher-season-test.mkv', sourceUrl: null, sourceType: SOURCE_TYPE_LOCAL, @@ -138,7 +139,11 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn id: 19, episodes: 24, coverImage: { large: 'https://images.test/cover.jpg', medium: null }, - title: { romaji: 'Little Witch Academia', english: 'Little Witch Academia', native: null }, + title: { + romaji: 'Little Witch Academia', + english: 'Little Witch Academia', + native: null, + }, }, ], }, diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index 599dec7..c823ca4 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -1,7 +1,11 @@ import type { AnilistRateLimiter } from './rate-limiter'; import type { DatabaseSync } from '../immersion-tracker/sqlite'; import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query'; -import { guessAnilistMediaInfo, runGuessit, type GuessAnilistMediaInfoDeps } from './anilist-updater'; +import { + guessAnilistMediaInfo, + runGuessit, + type GuessAnilistMediaInfoDeps, +} from './anilist-updater'; const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; const NO_MATCH_RETRY_MS = 5 * 60 * 1000; @@ -91,7 +95,10 @@ export function stripFilenameTags(raw: string): string { } function removeSeasonHint(title: string): string { - return title.replace(/\bseason\s*\d+\b/gi, '').replace(/\s{2,}/g, ' ').trim(); + return title + .replace(/\bseason\s*\d+\b/gi, '') + .replace(/\s{2,}/g, ' ') + .trim(); } function normalizeTitle(text: string): string { @@ -134,23 +141,20 @@ function pickBestSearchResult( .map((value) => value.trim()) .filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); - const filtered = episode === null - ? media - : media.filter((item) => { - const total = item.episodes; - return total === null || total >= episode; - }); + const filtered = + episode === null + ? media + : media.filter((item) => { + const total = item.episodes; + return total === null || total >= episode; + }); const candidates = filtered.length > 0 ? filtered : media; if (candidates.length === 0) { return null; } const scored = candidates.map((item) => { - const candidateTitles = [ - item.title?.romaji, - item.title?.english, - item.title?.native, - ] + const candidateTitles = [item.title?.romaji, item.title?.english, item.title?.native] .filter((value): value is string => typeof value === 'string') .map((value) => normalizeTitle(value)); @@ -186,7 +190,11 @@ function pickBestSearchResult( }); const selected = scored[0]!; - const selectedTitle = selected.item.title?.english ?? selected.item.title?.romaji ?? selected.item.title?.native ?? title; + const selectedTitle = + selected.item.title?.english ?? + selected.item.title?.romaji ?? + selected.item.title?.native ?? + title; return { id: selected.item.id, title: selectedTitle }; } @@ -311,9 +319,7 @@ export function createCoverArtFetcher( const parsedInfo = await resolveMediaInfo(canonicalTitle); const searchBase = parsedInfo?.title ?? cleaned; - const searchCandidates = parsedInfo - ? buildSearchCandidates(parsedInfo) - : [cleaned]; + const searchCandidates = parsedInfo ? buildSearchCandidates(parsedInfo) : [cleaned]; const effectiveCandidates = searchCandidates.includes(cleaned) ? searchCandidates diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index f0b564f..8f93f96 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -513,14 +513,16 @@ test('handleMediaChange reuses the same provisional anime row across matching fi ORDER BY v.source_path `, ) - .all('/tmp/Little Witch Academia S02E05.mkv', '/tmp/Little Witch Academia S02E06.mkv') as - Array<{ - source_path: string | null; - anime_id: number | null; - parsed_episode: number | null; - anime_title: string | null; - anilist_id: number | null; - }>; + .all( + '/tmp/Little Witch Academia S02E05.mkv', + '/tmp/Little Witch Academia S02E06.mkv', + ) as Array<{ + source_path: string | null; + anime_id: number | null; + parsed_episode: number | null; + anime_title: string | null; + anilist_id: number | null; + }>; assert.equal(rows.length, 2); assert.ok(rows[0]?.anime_id); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 95b32d9..bfc57f1 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -337,11 +337,7 @@ export class ImmersionTrackerService { return getWordOccurrences(this.db, headword, word, reading, limit, offset); } - async getKanjiOccurrences( - kanji: string, - limit = 100, - offset = 0, - ): Promise { + async getKanjiOccurrences(kanji: string, limit = 100, offset = 0): Promise { return getKanjiOccurrences(this.db, kanji, limit, offset); } @@ -413,16 +409,21 @@ export class ImmersionTrackerService { deleteVideoQuery(this.db, videoId); } - async reassignAnimeAnilist(animeId: number, info: { - anilistId: number; - titleRomaji?: string | null; - titleEnglish?: string | null; - titleNative?: string | null; - episodesTotal?: number | null; - description?: string | null; - coverUrl?: string | null; - }): Promise { - this.db.prepare(` + async reassignAnimeAnilist( + animeId: number, + info: { + anilistId: number; + titleRomaji?: string | null; + titleEnglish?: string | null; + titleNative?: string | null; + episodesTotal?: number | null; + description?: string | null; + coverUrl?: string | null; + }, + ): Promise { + this.db + .prepare( + ` UPDATE imm_anime SET anilist_id = ?, title_romaji = COALESCE(?, title_romaji), @@ -432,39 +433,55 @@ export class ImmersionTrackerService { description = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ? - `).run( - info.anilistId, - info.titleRomaji ?? null, - info.titleEnglish ?? null, - info.titleNative ?? null, - info.episodesTotal ?? null, - info.description ?? null, - Date.now(), - animeId, - ); + `, + ) + .run( + info.anilistId, + info.titleRomaji ?? null, + info.titleEnglish ?? null, + info.titleNative ?? null, + info.episodesTotal ?? null, + info.description ?? null, + Date.now(), + animeId, + ); // 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 = ?') + 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 */ } + } catch { + /* ignore */ + } for (const v of videos) { - this.db.prepare(` + this.db + .prepare( + ` INSERT INTO imm_media_art (video_id, anilist_id, cover_url, cover_blob, title_romaji, title_english, episodes_total, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(video_id) DO UPDATE SET anilist_id = excluded.anilist_id, cover_url = excluded.cover_url, cover_blob = COALESCE(excluded.cover_blob, cover_blob), title_romaji = excluded.title_romaji, title_english = excluded.title_english, episodes_total = excluded.episodes_total, fetched_at_ms = excluded.fetched_at_ms, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE - `).run( - v.video_id, info.anilistId, info.coverUrl, coverBlob, - info.titleRomaji ?? null, info.titleEnglish ?? null, info.episodesTotal ?? null, - Date.now(), Date.now(), Date.now(), - ); + `, + ) + .run( + v.video_id, + info.anilistId, + info.coverUrl, + coverBlob, + info.titleRomaji ?? null, + info.titleEnglish ?? null, + info.episodesTotal ?? null, + Date.now(), + Date.now(), + Date.now(), + ); } } } @@ -650,11 +667,7 @@ export class ImmersionTrackerService { if (!headword || !word) { continue; } - const wordKey = [ - headword, - word, - reading, - ].join('\u0000'); + const wordKey = [headword, word, reading].join('\u0000'); const storedPartOfSpeech = deriveStoredPartOfSpeech({ partOfSpeech: token.partOfSpeech, pos1: token.pos1 ?? '', @@ -729,7 +742,8 @@ export class ImmersionTrackerService { const durationMs = Math.round(durationSec * 1000); const current = getVideoDurationMs(this.db, this.sessionState.videoId); if (current === 0 || Math.abs(current - durationMs) > 1000) { - this.db.prepare('UPDATE imm_videos SET duration_ms = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?') + this.db + .prepare('UPDATE imm_videos SET duration_ms = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?') .run(durationMs, Date.now(), this.sessionState.videoId); } } diff --git a/src/core/services/immersion-tracker/metadata.test.ts b/src/core/services/immersion-tracker/metadata.test.ts index f6a23c8..6089326 100644 --- a/src/core/services/immersion-tracker/metadata.test.ts +++ b/src/core/services/immersion-tracker/metadata.test.ts @@ -149,16 +149,20 @@ test('getLocalVideoMetadata derives title and falls back to null hash on read er test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => { const seenTargets: string[] = []; - const parsed = await guessAnimeVideoMetadata('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5', { - runGuessit: async (target) => { - seenTargets.push(target); - return JSON.stringify({ - title: 'Little Witch Academia', - season: 2, - episode: 5, - }); + const parsed = await guessAnimeVideoMetadata( + '/tmp/Little Witch Academia S02E05.mkv', + 'Episode 5', + { + runGuessit: async (target) => { + seenTargets.push(target); + return JSON.stringify({ + title: 'Little Witch Academia', + season: 2, + episode: 5, + }); + }, }, - }); + ); assert.deepEqual(seenTargets, ['Little Witch Academia S02E05.mkv']); assert.deepEqual(parsed, { @@ -176,11 +180,15 @@ test('guessAnimeVideoMetadata uses guessit basename output first when available' }); test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => { - const parsed = await guessAnimeVideoMetadata('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5', { - runGuessit: async () => { - throw new Error('guessit unavailable'); + const parsed = await guessAnimeVideoMetadata( + '/tmp/Little Witch Academia S02E05.mkv', + 'Episode 5', + { + runGuessit: async () => { + throw new Error('guessit unavailable'); + }, }, - }); + ); assert.deepEqual(parsed, { parsedBasename: 'Little Witch Academia S02E05.mkv', @@ -199,13 +207,9 @@ test('guessAnimeVideoMetadata falls back to parser when guessit throws', async ( }); test('guessAnimeVideoMetadata falls back when guessit output is incomplete', async () => { - const parsed = await guessAnimeVideoMetadata( - '/tmp/[SubsPlease] Frieren - 03 (1080p).mkv', - null, - { - runGuessit: async () => JSON.stringify({ episode: 3 }), - }, - ); + const parsed = await guessAnimeVideoMetadata('/tmp/[SubsPlease] Frieren - 03 (1080p).mkv', null, { + runGuessit: async () => JSON.stringify({ episode: 3 }), + }); assert.deepEqual(parsed, { parsedBasename: '[SubsPlease] Frieren - 03 (1080p).mkv', diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts index 7af2b0d..37ec956 100644 --- a/src/core/services/immersion-tracker/query.ts +++ b/src/core/services/immersion-tracker/query.ts @@ -133,27 +133,54 @@ export function getQueryHints(db: DatabaseSync): { const activeSessions = Number((active.get() as { total?: number } | null)?.total ?? 0); const now = new Date(); - const todayLocal = Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000); - const episodesToday = (db.prepare(` + const todayLocal = Math.floor( + new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000, + ); + const episodesToday = + ( + db + .prepare( + ` SELECT COUNT(DISTINCT s.video_id) AS count FROM imm_sessions s WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? - `).get(todayLocal) as { count: number })?.count ?? 0; + `, + ) + .get(todayLocal) as { count: number } + )?.count ?? 0; const thirtyDaysAgoMs = Date.now() - 30 * 86400000; - const activeAnimeCount = (db.prepare(` + const activeAnimeCount = + ( + db + .prepare( + ` SELECT COUNT(DISTINCT v.anime_id) AS count FROM imm_sessions s JOIN imm_videos v ON v.video_id = s.video_id WHERE v.anime_id IS NOT NULL AND s.started_at_ms >= ? - `).get(thirtyDaysAgoMs) as { count: number })?.count ?? 0; + `, + ) + .get(thirtyDaysAgoMs) as { count: number } + )?.count ?? 0; - const totalEpisodesWatched = (db.prepare(` + const totalEpisodesWatched = + ( + db + .prepare( + ` SELECT COUNT(*) AS count FROM imm_videos WHERE watched = 1 - `).get() as { count: number })?.count ?? 0; + `, + ) + .get() as { count: number } + )?.count ?? 0; - const totalAnimeCompleted = (db.prepare(` + const totalAnimeCompleted = + ( + db + .prepare( + ` SELECT COUNT(*) AS count FROM ( SELECT a.anime_id FROM imm_anime a @@ -163,9 +190,19 @@ export function getQueryHints(db: DatabaseSync): { GROUP BY a.anime_id HAVING COUNT(DISTINCT CASE WHEN v.watched = 1 THEN v.video_id END) >= MAX(m.episodes_total) ) - `).get() as { count: number })?.count ?? 0; + `, + ) + .get() as { count: number } + )?.count ?? 0; - return { totalSessions, activeSessions, episodesToday, activeAnimeCount, totalEpisodesWatched, totalAnimeCompleted }; + return { + totalSessions, + activeSessions, + episodesToday, + activeAnimeCount, + totalEpisodesWatched, + totalAnimeCompleted, + }; } export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] { @@ -420,7 +457,9 @@ export async function cleanupVocabularyStats( ON CONFLICT(line_id, word_id) DO UPDATE SET occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count`, ); - const deleteOccurrencesStmt = db.prepare('DELETE FROM imm_word_line_occurrences WHERE word_id = ?'); + const deleteOccurrencesStmt = db.prepare( + 'DELETE FROM imm_word_line_occurrences WHERE word_id = ?', + ); let kept = 0; let deleted = 0; let repaired = 0; @@ -434,18 +473,16 @@ export async function cleanupVocabularyStats( row.word, resolvedPos.reading, row.id, - ) as - | { - id: number; - part_of_speech: string | null; - pos1: string | null; - pos2: string | null; - pos3: string | null; - first_seen: number | null; - last_seen: number | null; - frequency: number | null; - } - | null; + ) as { + id: number; + part_of_speech: string | null; + pos1: string | null; + pos2: string | null; + pos3: string | null; + first_seen: number | null; + last_seen: number | null; + frequency: number | null; + } | null; if (duplicate) { moveOccurrencesStmt.run(duplicate.id, row.id); deleteOccurrencesStmt.run(row.id); @@ -493,7 +530,10 @@ export async function cleanupVocabularyStats( !normalizePosField(effectiveRow.pos1) && !normalizePosField(effectiveRow.pos2) && !normalizePosField(effectiveRow.pos3); - if (missingPos || shouldExcludeTokenFromVocabularyPersistence(toStoredWordToken(effectiveRow))) { + if ( + missingPos || + shouldExcludeTokenFromVocabularyPersistence(toStoredWordToken(effectiveRow)) + ) { deleteStmt.run(row.id); deleted += 1; continue; @@ -605,7 +645,9 @@ export function getSessionEvents( } export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT a.anime_id AS animeId, a.canonical_title AS canonicalTitle, @@ -631,11 +673,15 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { ) sm ON sm.session_id = s.session_id GROUP BY a.anime_id ORDER BY totalActiveMs DESC, lastWatchedMs DESC, canonicalTitle ASC - `).all() as unknown as AnimeLibraryRow[]; + `, + ) + .all() as unknown as AnimeLibraryRow[]; } export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null { - return db.prepare(` + return db + .prepare( + ` SELECT a.anime_id AS animeId, a.canonical_title AS canonicalTitle, @@ -670,11 +716,15 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo ) sm ON sm.session_id = s.session_id WHERE a.anime_id = ? GROUP BY a.anime_id - `).get(animeId) as unknown as AnimeDetailRow | null; + `, + ) + .get(animeId) as unknown as AnimeDetailRow | null; } export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): AnimeAnilistEntryRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT DISTINCT m.anilist_id AS anilistId, m.title_romaji AS titleRomaji, @@ -685,11 +735,15 @@ export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): Anime WHERE v.anime_id = ? AND m.anilist_id IS NOT NULL ORDER BY v.parsed_season ASC - `).all(animeId) as unknown as AnimeAnilistEntryRow[]; + `, + ) + .all(animeId) as unknown as AnimeAnilistEntryRow[]; } export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT v.anime_id AS animeId, v.video_id AS videoId, @@ -723,11 +777,15 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod CASE WHEN v.parsed_episode IS NULL THEN 1 ELSE 0 END, v.parsed_episode ASC, v.video_id ASC - `).all(animeId) as unknown as AnimeEpisodeRow[]; + `, + ) + .all(animeId) as unknown as AnimeEpisodeRow[]; } export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT v.video_id AS videoId, v.canonical_title AS canonicalTitle, @@ -751,11 +809,15 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] { LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id GROUP BY v.video_id ORDER BY lastWatchedMs DESC - `).all() as unknown as MediaLibraryRow[]; + `, + ) + .all() as unknown as MediaLibraryRow[]; } export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null { - return db.prepare(` + return db + .prepare( + ` SELECT v.video_id AS videoId, v.canonical_title AS canonicalTitle, @@ -782,11 +844,19 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo ) sm ON sm.session_id = s.session_id WHERE v.video_id = ? GROUP BY v.video_id - `).get(videoId) as unknown as MediaDetailRow | null; + `, + ) + .get(videoId) as unknown as MediaDetailRow | null; } -export function getMediaSessions(db: DatabaseSync, videoId: number, limit = 100): SessionSummaryQueryRow[] { - return db.prepare(` +export function getMediaSessions( + db: DatabaseSync, + videoId: number, + limit = 100, +): SessionSummaryQueryRow[] { + return db + .prepare( + ` SELECT s.session_id AS sessionId, s.video_id AS videoId, @@ -808,11 +878,19 @@ export function getMediaSessions(db: DatabaseSync, videoId: number, limit = 100) GROUP BY s.session_id ORDER BY s.started_at_ms DESC LIMIT ? - `).all(videoId, limit) as unknown as SessionSummaryQueryRow[]; + `, + ) + .all(videoId, limit) as unknown as SessionSummaryQueryRow[]; } -export function getMediaDailyRollups(db: DatabaseSync, videoId: number, limit = 90): ImmersionSessionRollupRow[] { - return db.prepare(` +export function getMediaDailyRollups( + db: DatabaseSync, + videoId: number, + limit = 90, +): ImmersionSessionRollupRow[] { + return db + .prepare( + ` SELECT rollup_day AS rollupDayOrMonth, video_id AS videoId, @@ -829,11 +907,15 @@ export function getMediaDailyRollups(db: DatabaseSync, videoId: number, limit = WHERE video_id = ? ORDER BY rollup_day DESC LIMIT ? - `).all(videoId, limit) as unknown as ImmersionSessionRollupRow[]; + `, + ) + .all(videoId, limit) as unknown as ImmersionSessionRollupRow[]; } export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null { - return db.prepare(` + return db + .prepare( + ` SELECT a.video_id AS videoId, a.anilist_id AS anilistId, @@ -848,11 +930,15 @@ export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow WHERE v.anime_id = ? AND a.cover_blob IS NOT NULL LIMIT 1 - `).get(animeId) as unknown as MediaArtRow | null; + `, + ) + .get(animeId) as unknown as MediaArtRow | null; } export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null { - return db.prepare(` + return db + .prepare( + ` SELECT video_id AS videoId, anilist_id AS anilistId, @@ -864,7 +950,9 @@ export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | nu fetched_at_ms AS fetchedAtMs FROM imm_media_art WHERE video_id = ? - `).get(videoId) as unknown as MediaArtRow | null; + `, + ) + .get(videoId) as unknown as MediaArtRow | null; } export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] { @@ -872,17 +960,23 @@ export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRo const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const todayLocalDay = Math.floor(localMidnight / 86_400_000); const cutoffDay = todayLocalDay - days; - return db.prepare(` + return db + .prepare( + ` SELECT rollup_day AS epochDay, SUM(total_active_min) AS totalActiveMin FROM imm_daily_rollups WHERE rollup_day >= ? GROUP BY rollup_day ORDER BY rollup_day ASC - `).all(cutoffDay) as StreakCalendarRow[]; + `, + ) + .all(cutoffDay) as StreakCalendarRow[]; } export function getAnimeWords(db: DatabaseSync, animeId: number, limit = 50): AnimeWordRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech, SUM(o.occurrence_count) AS frequency FROM imm_word_line_occurrences o @@ -892,11 +986,19 @@ export function getAnimeWords(db: DatabaseSync, animeId: number, limit = 50): An GROUP BY w.id ORDER BY frequency DESC LIMIT ? - `).all(animeId, limit) as unknown as AnimeWordRow[]; + `, + ) + .all(animeId, limit) as unknown as AnimeWordRow[]; } -export function getAnimeDailyRollups(db: DatabaseSync, animeId: number, limit = 90): ImmersionSessionRollupRow[] { - return db.prepare(` +export function getAnimeDailyRollups( + db: DatabaseSync, + animeId: number, + limit = 90, +): ImmersionSessionRollupRow[] { + return db + .prepare( + ` SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId, r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin, r.total_lines_seen AS totalLinesSeen, r.total_words_seen AS totalWordsSeen, @@ -908,22 +1010,30 @@ export function getAnimeDailyRollups(db: DatabaseSync, animeId: number, limit = WHERE v.anime_id = ? ORDER BY r.rollup_day DESC LIMIT ? - `).all(animeId, limit) as unknown as ImmersionSessionRollupRow[]; + `, + ) + .all(animeId, limit) as unknown as ImmersionSessionRollupRow[]; } export function getEpisodesPerDay(db: DatabaseSync, limit = 90): EpisodesPerDayRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay, COUNT(DISTINCT s.video_id) AS episodeCount FROM imm_sessions s GROUP BY epochDay ORDER BY epochDay DESC LIMIT ? - `).all(limit) as EpisodesPerDayRow[]; + `, + ) + .all(limit) as EpisodesPerDayRow[]; } export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT first_day AS epochDay, COUNT(*) AS newAnimeCount FROM ( SELECT CAST(julianday(MIN(s.started_at_ms) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS first_day @@ -935,13 +1045,20 @@ export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayR GROUP BY first_day ORDER BY first_day DESC LIMIT ? - `).all(limit) as NewAnimePerDayRow[]; + `, + ) + .all(limit) as NewAnimePerDayRow[]; } export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePerAnimeRow[] { const nowD = new Date(); - const cutoffDay = Math.floor(new Date(nowD.getFullYear(), nowD.getMonth(), nowD.getDate()).getTime() / 86_400_000) - limit; - return db.prepare(` + const cutoffDay = + Math.floor( + new Date(nowD.getFullYear(), nowD.getMonth(), nowD.getDate()).getTime() / 86_400_000, + ) - limit; + return db + .prepare( + ` SELECT r.rollup_day AS epochDay, a.anime_id AS animeId, a.canonical_title AS animeTitle, SUM(r.total_active_min) AS totalActiveMin @@ -951,20 +1068,31 @@ export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePer WHERE r.rollup_day >= ? GROUP BY r.rollup_day, a.anime_id ORDER BY r.rollup_day ASC - `).all(cutoffDay) as WatchTimePerAnimeRow[]; + `, + ) + .all(cutoffDay) as WatchTimePerAnimeRow[]; } export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null { - return db.prepare(` + return db + .prepare( + ` SELECT id AS wordId, headword, word, reading, part_of_speech AS partOfSpeech, pos1, pos2, pos3, frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM imm_words WHERE id = ? - `).get(wordId) as WordDetailRow | null; + `, + ) + .get(wordId) as WordDetailRow | null; } -export function getWordAnimeAppearances(db: DatabaseSync, wordId: number): WordAnimeAppearanceRow[] { - return db.prepare(` +export function getWordAnimeAppearances( + db: DatabaseSync, + wordId: number, +): WordAnimeAppearanceRow[] { + return db + .prepare( + ` SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle, SUM(o.occurrence_count) AS occurrenceCount FROM imm_word_line_occurrences o @@ -973,37 +1101,55 @@ export function getWordAnimeAppearances(db: DatabaseSync, wordId: number): WordA WHERE o.word_id = ? AND sl.anime_id IS NOT NULL GROUP BY a.anime_id ORDER BY occurrenceCount DESC - `).all(wordId) as WordAnimeAppearanceRow[]; + `, + ) + .all(wordId) as WordAnimeAppearanceRow[]; } export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] { - const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as { headword: string; reading: string } | null; + const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as { + headword: string; + reading: string; + } | null; if (!word) return []; - return db.prepare(` + return db + .prepare( + ` SELECT id AS wordId, headword, word, reading, frequency FROM imm_words WHERE id != ? AND (reading = ? OR headword LIKE ? OR headword LIKE ?) ORDER BY frequency DESC LIMIT ? - `).all( - wordId, - word.reading, - `%${word.headword.charAt(0)}%`, - `%${word.headword.charAt(word.headword.length - 1)}%`, - limit, - ) as SimilarWordRow[]; + `, + ) + .all( + wordId, + word.reading, + `%${word.headword.charAt(0)}%`, + `%${word.headword.charAt(word.headword.length - 1)}%`, + limit, + ) as SimilarWordRow[]; } export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null { - return db.prepare(` + return db + .prepare( + ` SELECT id AS kanjiId, kanji, frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM imm_kanji WHERE id = ? - `).get(kanjiId) as KanjiDetailRow | null; + `, + ) + .get(kanjiId) as KanjiDetailRow | null; } -export function getKanjiAnimeAppearances(db: DatabaseSync, kanjiId: number): KanjiAnimeAppearanceRow[] { - return db.prepare(` +export function getKanjiAnimeAppearances( + db: DatabaseSync, + kanjiId: number, +): KanjiAnimeAppearanceRow[] { + return db + .prepare( + ` SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle, SUM(o.occurrence_count) AS occurrenceCount FROM imm_kanji_line_occurrences o @@ -1012,23 +1158,33 @@ export function getKanjiAnimeAppearances(db: DatabaseSync, kanjiId: number): Kan WHERE o.kanji_id = ? AND sl.anime_id IS NOT NULL GROUP BY a.anime_id ORDER BY occurrenceCount DESC - `).all(kanjiId) as KanjiAnimeAppearanceRow[]; + `, + ) + .all(kanjiId) as KanjiAnimeAppearanceRow[]; } export function getKanjiWords(db: DatabaseSync, kanjiId: number, limit = 20): KanjiWordRow[] { - const kanjiRow = db.prepare('SELECT kanji FROM imm_kanji WHERE id = ?').get(kanjiId) as { kanji: string } | null; + const kanjiRow = db.prepare('SELECT kanji FROM imm_kanji WHERE id = ?').get(kanjiId) as { + kanji: string; + } | null; if (!kanjiRow) return []; - return db.prepare(` + return db + .prepare( + ` SELECT id AS wordId, headword, word, reading, frequency FROM imm_words WHERE headword LIKE ? ORDER BY frequency DESC LIMIT ? - `).all(`%${kanjiRow.kanji}%`, limit) as KanjiWordRow[]; + `, + ) + .all(`%${kanjiRow.kanji}%`, limit) as KanjiWordRow[]; } export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50): AnimeWordRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech, SUM(o.occurrence_count) AS frequency FROM imm_word_line_occurrences o @@ -1038,11 +1194,15 @@ export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50): GROUP BY w.id ORDER BY frequency DESC LIMIT ? - `).all(videoId, limit) as unknown as AnimeWordRow[]; + `, + ) + .all(videoId, limit) as unknown as AnimeWordRow[]; } export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] { - return db.prepare(` + return db + .prepare( + ` SELECT s.session_id AS sessionId, s.video_id AS videoId, v.canonical_title AS canonicalTitle, @@ -1061,11 +1221,15 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu WHERE s.video_id = ? GROUP BY s.session_id ORDER BY s.started_at_ms DESC - `).all(videoId) as SessionSummaryQueryRow[]; + `, + ) + .all(videoId) as SessionSummaryQueryRow[]; } export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT e.event_id AS eventId, e.session_id AS sessionId, e.ts_ms AS tsMs, e.cards_delta AS cardsDelta, e.payload_json AS payloadJson @@ -1073,9 +1237,17 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode JOIN imm_sessions s ON s.session_id = e.session_id WHERE s.video_id = ? AND e.event_type = 4 ORDER BY e.ts_ms DESC - `).all(videoId) as Array<{ eventId: number; sessionId: number; tsMs: number; cardsDelta: number; payloadJson: string | null }>; + `, + ) + .all(videoId) as Array<{ + eventId: number; + sessionId: number; + tsMs: number; + cardsDelta: number; + payloadJson: string | null; + }>; - return rows.map(row => { + return rows.map((row) => { let noteIds: number[] = []; if (row.payloadJson) { try { @@ -1083,7 +1255,13 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode if (Array.isArray(parsed.noteIds)) noteIds = parsed.noteIds; } catch {} } - return { eventId: row.eventId, sessionId: row.sessionId, tsMs: row.tsMs, cardsDelta: row.cardsDelta, noteIds }; + return { + eventId: row.eventId, + sessionId: row.sessionId, + tsMs: row.tsMs, + cardsDelta: row.cardsDelta, + noteIds, + }; }); } @@ -1100,7 +1278,8 @@ export function upsertCoverArt( }, ): void { const nowMs = Date.now(); - db.prepare(` + db.prepare( + ` INSERT INTO imm_media_art ( video_id, anilist_id, cover_url, cover_blob, title_romaji, title_english, episodes_total, @@ -1115,10 +1294,18 @@ export function upsertCoverArt( episodes_total = excluded.episodes_total, fetched_at_ms = excluded.fetched_at_ms, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE - `).run( - videoId, art.anilistId, art.coverUrl, art.coverBlob, - art.titleRomaji, art.titleEnglish, art.episodesTotal, - nowMs, nowMs, nowMs, + `, + ).run( + videoId, + art.anilistId, + art.coverUrl, + art.coverBlob, + art.titleRomaji, + art.titleEnglish, + art.episodesTotal, + nowMs, + nowMs, + nowMs, ); } @@ -1138,7 +1325,8 @@ export function updateAnimeAnilistInfo( } | null; if (!row?.anime_id) return; - db.prepare(` + db.prepare( + ` UPDATE imm_anime SET anilist_id = COALESCE(?, anilist_id), @@ -1148,7 +1336,8 @@ export function updateAnimeAnilistInfo( episodes_total = COALESCE(?, episodes_total), LAST_UPDATE_DATE = ? WHERE anime_id = ? - `).run( + `, + ).run( info.anilistId, info.titleRomaji, info.titleEnglish, @@ -1160,8 +1349,11 @@ export function updateAnimeAnilistInfo( } export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void { - db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?') - .run(watched ? 1 : 0, Date.now(), videoId); + db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run( + watched ? 1 : 0, + Date.now(), + videoId, + ); } export function getVideoDurationMs(db: DatabaseSync, videoId: number): number { @@ -1186,7 +1378,9 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void { } export function deleteVideo(db: DatabaseSync, videoId: number): void { - const sessions = db.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?').all(videoId) as Array<{ session_id: number }>; + const sessions = db + .prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?') + .all(videoId) as Array<{ session_id: number }>; for (const s of sessions) { deleteSession(db, s.session_id); } diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index ea4bec2..82e10d2 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -425,8 +425,9 @@ test('ensureSchema adds subtitle-line occurrence tables to schema version 6 data const tableNames = new Set( ( - db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`).all() as - Array<{ name: string }> + db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`) + .all() as Array<{ name: string }> ).map((row) => row.name), ); @@ -731,8 +732,28 @@ test('word upsert replaces legacy other part_of_speech when better POS metadata ensureSchema(db); const stmts = createTrackerPreparedStatements(db); - stmts.wordUpsertStmt.run('知っている', '知っている', 'しっている', 'other', '動詞', '自立', '', 10, 10); - stmts.wordUpsertStmt.run('知っている', '知っている', 'しっている', 'verb', '動詞', '自立', '', 11, 12); + stmts.wordUpsertStmt.run( + '知っている', + '知っている', + 'しっている', + 'other', + '動詞', + '自立', + '', + 10, + 10, + ); + stmts.wordUpsertStmt.run( + '知っている', + '知っている', + 'しっている', + 'verb', + '動詞', + '自立', + '', + 11, + 12, + ); const row = db .prepare('SELECT frequency, part_of_speech, pos1, pos2 FROM imm_words WHERE headword = ?') diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index de7e75f..de36560 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -78,11 +78,7 @@ export function normalizeAnimeIdentityKey(title: string): string { } function looksLikeEpisodeOnlyTitle(title: string): boolean { - const normalized = title - .normalize('NFKC') - .toLowerCase() - .replace(/\s+/g, ' ') - .trim(); + const normalized = title.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim(); return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized); } @@ -757,7 +753,9 @@ export function ensureSchema(db: DatabaseSync): void { if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) { db.exec('DELETE FROM imm_daily_rollups'); db.exec('DELETE FROM imm_monthly_rollups'); - db.exec(`UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`); + db.exec( + `UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`, + ); } db.exec(` @@ -954,7 +952,9 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta return; } if (write.kind === 'subtitleLine') { - const animeRow = stmts.videoAnimeIdSelectStmt.get(write.videoId) as { anime_id: number | null } | null; + const animeRow = stmts.videoAnimeIdSelectStmt.get(write.videoId) as { + anime_id: number | null; + } | null; const lineResult = stmts.subtitleLineInsertStmt.run( write.sessionId, null, diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 3bb0733..c46adb1 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -29,7 +29,10 @@ export { } from './startup'; export { openYomitanSettingsWindow } from './yomitan-settings'; export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer'; -export { addYomitanNoteViaSearch, clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime'; +export { + addYomitanNoteViaSearch, + clearYomitanParserCachesForWindow, +} from './tokenizer/yomitan-parser-runtime'; export { deleteYomitanDictionaryByTitle, getYomitanDictionaryInfo, diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 1eeddbe..a60903d 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -313,7 +313,12 @@ test('registerIpcHandlers validates and clamps stats request limits', async () = calls.push(['monthly', limit]); return []; }, - getQueryHints: async () => ({ totalSessions: 0, activeSessions: 0, episodesToday: 0, activeAnimeCount: 0 }), + getQueryHints: async () => ({ + totalSessions: 0, + activeSessions: 0, + episodesToday: 0, + activeAnimeCount: 0, + }), getSessionTimeline: async (sessionId: number, limit = 0) => { calls.push(['timeline', limit, sessionId]); return []; diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index b8237aa..e51f31e 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -73,7 +73,12 @@ export interface IpcServiceDeps { getSessionSummaries: (limit?: number) => Promise; getDailyRollups: (limit?: number) => Promise; getMonthlyRollups: (limit?: number) => Promise; - getQueryHints: () => Promise<{ totalSessions: number; activeSessions: number; episodesToday: number; activeAnimeCount: number }>; + getQueryHints: () => Promise<{ + totalSessions: number; + activeSessions: number; + episodesToday: number; + activeAnimeCount: number; + }>; getSessionTimeline: (sessionId: number, limit?: number) => Promise; getSessionEvents: (sessionId: number, limit?: number) => Promise; getVocabularyStats: (limit?: number) => Promise; @@ -512,13 +517,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.immersionTracker?.getMediaLibrary() ?? []; }); - ipc.handle( - IPC_CHANNELS.request.statsGetMediaDetail, - async (_event, videoId: unknown) => { - if (typeof videoId !== 'number') return null; - return deps.immersionTracker?.getMediaDetail(videoId) ?? null; - }, - ); + ipc.handle(IPC_CHANNELS.request.statsGetMediaDetail, async (_event, videoId: unknown) => { + if (typeof videoId !== 'number') return null; + return deps.immersionTracker?.getMediaDetail(videoId) ?? null; + }); ipc.handle( IPC_CHANNELS.request.statsGetMediaSessions, @@ -538,11 +540,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.handle( - IPC_CHANNELS.request.statsGetMediaCover, - async (_event, videoId: unknown) => { - if (typeof videoId !== 'number') return null; - return deps.immersionTracker?.getCoverArt(videoId) ?? null; - }, - ); + ipc.handle(IPC_CHANNELS.request.statsGetMediaCover, async (_event, videoId: unknown) => { + if (typeof videoId !== 'number') return null; + return deps.immersionTracker?.getCoverArt(videoId) ?? null; + }); } diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index e2cd13e..4a321ec 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -128,10 +128,7 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => { emitSubtitleTrackListChange: (payload) => state.events.push(payload), }); - await dispatchMpvProtocolMessage( - { event: 'property-change', name: 'sid', data: '3' }, - deps, - ); + await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps); await dispatchMpvProtocolMessage( { event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] }, deps, diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 5b950e1..f7a75e3 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -51,7 +51,10 @@ function resolveStatsStaticPath(staticDir: string, requestPath: string): string const decodedPath = decodeURIComponent(normalizedPath); const absoluteStaticDir = resolve(staticDir); const absolutePath = resolve(absoluteStaticDir, decodedPath); - if (absolutePath !== absoluteStaticDir && !absolutePath.startsWith(`${absoluteStaticDir}${sep}`)) { + if ( + absolutePath !== absoluteStaticDir && + !absolutePath.startsWith(`${absoluteStaticDir}${sep}`) + ) { return null; } if (!existsSync(absolutePath)) { @@ -71,8 +74,7 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp } const extension = extname(absolutePath).toLowerCase(); - const contentType = - STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream'; + const contentType = STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream'; const body = readFileSync(absolutePath); return new Response(body, { headers: { @@ -86,7 +88,13 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp export function createStatsApp( tracker: ImmersionTrackerService, - options?: { staticDir?: string; knownWordCachePath?: string; mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; addYomitanNote?: (word: string) => Promise }, + options?: { + staticDir?: string; + knownWordCachePath?: string; + mpvSocketPath?: string; + ankiConnectConfig?: AnkiConnectConfig; + addYomitanNote?: (word: string) => Promise; + }, ) { const app = new Hono(); @@ -304,7 +312,7 @@ export function createStatsApp( variables: { search: query }, }), }); - const json = await res.json() as { data?: { Page?: { media?: unknown[] } } }; + const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } }; return c.json(json.data?.Page?.media ?? []); } catch { return c.json([]); @@ -315,9 +323,14 @@ export function createStatsApp( const cachePath = options?.knownWordCachePath; if (!cachePath || !existsSync(cachePath)) return c.json([]); try { - const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: number; words?: string[] }; + const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { + version?: number; + words?: string[]; + }; if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words); - } catch { /* ignore */ } + } catch { + /* ignore */ + } return c.json([]); }); @@ -377,7 +390,11 @@ export function createStatsApp( method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), - body: JSON.stringify({ action: 'guiBrowse', version: 6, params: { query: `nid:${noteId}` } }), + body: JSON.stringify({ + action: 'guiBrowse', + version: 6, + params: { query: `nid:${noteId}` }, + }), }); const result = await response.json(); return c.json(result); @@ -401,7 +418,9 @@ export function createStatsApp( signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }), }); - const result = await response.json() as { result?: Array<{ noteId: number; fields: Record }> }; + const result = (await response.json()) as { + result?: Array<{ noteId: number; fields: Record }>; + }; return c.json(result.result ?? []); } catch { return c.json([], 502); @@ -445,7 +464,10 @@ export function createStatsApp( const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec; const highlightedSentence = word - ? sentence.replace(new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `${word}`) + ? sentence.replace( + new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + `${word}`, + ) : sentence; const generateAudio = ankiConfig.media?.generateAudio !== false; @@ -460,12 +482,18 @@ export function createStatsApp( if (!generateImage) { imagePromise = Promise.resolve(null); } else if (imageType === 'avif') { - imagePromise = mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { - fps: ankiConfig.media?.animatedFps ?? 10, - maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, - maxHeight: ankiConfig.media?.animatedMaxHeight, - crf: ankiConfig.media?.animatedCrf ?? 35, - }); + imagePromise = mediaGen.generateAnimatedImage( + sourcePath, + startSec, + clampedEndSec, + audioPadding, + { + fps: ankiConfig.media?.animatedFps ?? 10, + maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, + maxHeight: ankiConfig.media?.animatedMaxHeight, + crf: ankiConfig.media?.animatedCrf ?? 35, + }, + ); } else { const midpointSec = (startSec + clampedEndSec) / 2; imagePromise = mediaGen.generateScreenshot(sourcePath, midpointSec, { @@ -491,14 +519,21 @@ export function createStatsApp( ]); if (yomitanResult.status === 'rejected' || !yomitanResult.value) { - return c.json({ error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}` }, 502); + return c.json( + { + error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`, + }, + 502, + ); } noteId = yomitanResult.value; const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; - if (audioResult.status === 'rejected') errors.push(`audio: ${(audioResult.reason as Error).message}`); - if (imageResult.status === 'rejected') errors.push(`image: ${(imageResult.reason as Error).message}`); + if (audioResult.status === 'rejected') + errors.push(`audio: ${(audioResult.reason as Error).message}`); + if (imageResult.status === 'rejected') + errors.push(`image: ${(imageResult.reason as Error).message}`); const mediaFields: Record = {}; const timestamp = Date.now(); @@ -566,8 +601,10 @@ export function createStatsApp( const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; - if (audioResult.status === 'rejected') errors.push(`audio: ${(audioResult.reason as Error).message}`); - if (imageResult.status === 'rejected') errors.push(`image: ${(imageResult.reason as Error).message}`); + if (audioResult.status === 'rejected') + errors.push(`audio: ${(audioResult.reason as Error).message}`); + if (imageResult.status === 'rejected') + errors.push(`image: ${(imageResult.reason as Error).message}`); const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; @@ -684,7 +721,13 @@ export function createStatsApp( } export function startStatsServer(config: StatsServerConfig): { close: () => void } { - const app = createStatsApp(config.tracker, { staticDir: config.staticDir, knownWordCachePath: config.knownWordCachePath, mpvSocketPath: config.mpvSocketPath, ankiConnectConfig: config.ankiConnectConfig, addYomitanNote: config.addYomitanNote }); + const app = createStatsApp(config.tracker, { + staticDir: config.staticDir, + knownWordCachePath: config.knownWordCachePath, + mpvSocketPath: config.mpvSocketPath, + ankiConnectConfig: config.ankiConnectConfig, + addYomitanNote: config.addYomitanNote, + }); const server = serve({ fetch: app.fetch, diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index cf1f969..7b8a3fe 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -16,13 +16,9 @@ function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean ); } -export function shouldHideStatsWindowForInput( - input: Electron.Input, - toggleKey: string, -): boolean { +export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey: string): boolean { return ( - (input.type === 'keyDown' && input.key === 'Escape') || - isBareToggleKeyInput(input, toggleKey) + (input.type === 'keyDown' && input.key === 'Escape') || isBareToggleKeyInput(input, toggleKey) ); } diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index be7fae9..6a656b7 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -27,13 +27,7 @@ test('parseSrtCues parses basic SRT content', () => { }); test('parseSrtCues handles multi-line subtitle text', () => { - const content = [ - '1', - '00:01:00,000 --> 00:01:05,000', - 'これは', - 'テストです', - '', - ].join('\n'); + const content = ['1', '00:01:00,000 --> 00:01:05,000', 'これは', 'テストです', ''].join('\n'); const cues = parseSrtCues(content); @@ -42,12 +36,7 @@ test('parseSrtCues handles multi-line subtitle text', () => { }); test('parseSrtCues handles hours in timestamps', () => { - const content = [ - '1', - '01:30:00,000 --> 01:30:05,000', - 'テスト', - '', - ].join('\n'); + const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n'); const cues = parseSrtCues(content); @@ -56,12 +45,7 @@ test('parseSrtCues handles hours in timestamps', () => { }); test('parseSrtCues handles VTT-style dot separator', () => { - const content = [ - '1', - '00:00:01.000 --> 00:00:04.000', - 'VTTスタイル', - '', - ].join('\n'); + const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTスタイル', ''].join('\n'); const cues = parseSrtCues(content); @@ -151,10 +135,7 @@ test('parseAssCues handles \\N line breaks', () => { }); test('parseAssCues returns empty for content without Events section', () => { - const content = [ - '[Script Info]', - 'Title: Test', - ].join('\n'); + const content = ['[Script Info]', 'Title: Test'].join('\n'); assert.deepEqual(parseAssCues(content), []); }); @@ -202,12 +183,7 @@ test('parseAssCues respects dynamic field ordering from the Format row', () => { }); test('parseSubtitleCues auto-detects SRT format', () => { - const content = [ - '1', - '00:00:01,000 --> 00:00:04,000', - 'SRTテスト', - '', - ].join('\n'); + const content = ['1', '00:00:01,000 --> 00:00:04,000', 'SRTテスト', ''].join('\n'); const cues = parseSubtitleCues(content, 'test.srt'); assert.equal(cues.length, 1); @@ -227,12 +203,7 @@ test('parseSubtitleCues auto-detects ASS format', () => { }); test('parseSubtitleCues auto-detects VTT format', () => { - const content = [ - '1', - '00:00:01.000 --> 00:00:04.000', - 'VTTテスト', - '', - ].join('\n'); + const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTテスト', ''].join('\n'); const cues = parseSubtitleCues(content, 'test.vtt'); assert.equal(cues.length, 1); diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 8faa9cc..6314cb6 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -34,8 +34,18 @@ export function parseSrtCues(content: string): SubtitleCue[] { continue; } - const startTime = parseTimestamp(timingMatch[1], timingMatch[2]!, timingMatch[3]!, timingMatch[4]!); - const endTime = parseTimestamp(timingMatch[5], timingMatch[6]!, timingMatch[7]!, timingMatch[8]!); + const startTime = parseTimestamp( + timingMatch[1], + timingMatch[2]!, + timingMatch[3]!, + timingMatch[4]!, + ); + const endTime = parseTimestamp( + timingMatch[5], + timingMatch[6]!, + timingMatch[7]!, + timingMatch[8]!, + ); i += 1; const textLines: string[] = []; @@ -144,13 +154,14 @@ export function parseAssCues(content: string): SubtitleCue[] { } function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null { - const [normalizedSource = source] = (() => { - try { - return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source; - } catch { - return source; - } - })().split(/[?#]/, 1)[0] ?? ''; + const [normalizedSource = source] = + (() => { + try { + return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source; + } catch { + return source; + } + })().split(/[?#]/, 1)[0] ?? ''; const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? ''; if (ext === 'srt') return 'srt'; if (ext === 'vtt') return 'vtt'; diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index c27191f..45c2ab3 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -1,9 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { - computePriorityWindow, - createSubtitlePrefetchService, -} from './subtitle-prefetch'; +import { computePriorityWindow, createSubtitlePrefetchService } from './subtitle-prefetch'; import type { SubtitleCue } from './subtitle-cue-parser'; import type { SubtitleData } from '../../types'; @@ -169,7 +166,9 @@ test('prefetch service onSeek re-prioritizes from new position', async () => { service.stop(); // After seek to 80.0, cues starting after 80.0 (line-17, line-18, line-19) should appear in cached - const hasPostSeekCue = cachedTexts.some((t) => t === 'line-17' || t === 'line-18' || t === 'line-19'); + const hasPostSeekCue = cachedTexts.some( + (t) => t === 'line-17' || t === 'line-18' || t === 'line-19', + ); assert.ok(hasPostSeekCue, 'Should have cached cues after seek position'); }); diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 98e652d..b8def55 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -3227,52 +3227,55 @@ test('tokenizeSubtitle excludes merged function/content token from frequency hig test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => { const result = await tokenizeSubtitle( '張り切ってんじゃ', - makeDepsFromYomitanTokens([{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }], { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null), - tokenizeWithMecab: async () => [ - { - headword: '張り切る', - surface: '張り切っ', - reading: 'ハリキッ', - startPos: 0, - endPos: 4, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'て', - surface: 'て', - reading: 'テ', - startPos: 4, - endPos: 5, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '接続助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'んじゃ', - surface: 'んじゃ', - reading: 'ンジャ', - startPos: 5, - endPos: 8, - partOfSpeech: PartOfSpeech.other, - pos1: '接続詞', - pos2: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - getMinSentenceWordsForNPlusOne: () => 1, - }), + makeDepsFromYomitanTokens( + [{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null), + tokenizeWithMecab: async () => [ + { + headword: '張り切る', + surface: '張り切っ', + reading: 'ハリキッ', + startPos: 0, + endPos: 4, + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞', + pos2: '自立', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'て', + surface: 'て', + reading: 'テ', + startPos: 4, + endPos: 5, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + pos2: '接続助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'んじゃ', + surface: 'んじゃ', + reading: 'ンジャ', + startPos: 5, + endPos: 8, + partOfSpeech: PartOfSpeech.other, + pos1: '接続詞', + pos2: '*', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + getMinSentenceWordsForNPlusOne: () => 1, + }, + ), ); assert.equal(result.tokens?.length, 1); diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 88993e5..2e476c3 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -188,7 +188,9 @@ async function filterSubtitleAnnotationTokens(tokens: MergedToken[]): Promise !annotationStage.shouldExcludeTokenFromSubtitleAnnotations(token)); + return tokens.filter( + (token) => !annotationStage.shouldExcludeTokenFromSubtitleAnnotations(token), + ); } export function createTokenizerDepsRuntime( @@ -449,7 +451,11 @@ function buildYomitanFrequencyIndex( reading, frequency: rank, }; - appendYomitanFrequencyEntry(byPair, makeYomitanFrequencyPairKey(term, reading), normalizedEntry); + appendYomitanFrequencyEntry( + byPair, + makeYomitanFrequencyPairKey(term, reading), + normalizedEntry, + ); appendYomitanFrequencyEntry(byTerm, term, normalizedEntry); } @@ -486,11 +492,15 @@ function getYomitanFrequencyRank( } const reading = - typeof token.reading === 'string' && token.reading.trim().length > 0 ? token.reading.trim() : null; + typeof token.reading === 'string' && token.reading.trim().length > 0 + ? token.reading.trim() + : null; const pairEntries = frequencyIndex.byPair.get(makeYomitanFrequencyPairKey(normalizedCandidateText, reading)) ?? []; const candidateEntries = - pairEntries.length > 0 ? pairEntries : (frequencyIndex.byTerm.get(normalizedCandidateText) ?? []); + pairEntries.length > 0 + ? pairEntries + : (frequencyIndex.byTerm.get(normalizedCandidateText) ?? []); if (candidateEntries.length === 0) { return null; } diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index e3df7de..ceb0883 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -54,7 +54,6 @@ function resolveKnownWordText( return matchMode === 'surface' ? surface : headword; } - function normalizePos1Tag(pos1: string | undefined): string { return typeof pos1 === 'string' ? pos1.trim() : ''; } @@ -243,7 +242,6 @@ export function shouldExcludeTokenFromVocabularyPersistence( ); } - function getCachedJlptLevel( lookupText: string, getJlptLevel: (text: string) => JlptLevel | null, @@ -634,9 +632,7 @@ export function annotateTokens( ? filterTokenFrequencyRank(token, pos1Exclusions, pos2Exclusions) : undefined; - const jlptLevel = jlptEnabled - ? computeTokenJlptLevel(token, deps.getJlptLevel) - : undefined; + const jlptLevel = jlptEnabled ? computeTokenJlptLevel(token, deps.getJlptLevel) : undefined; return { ...token, diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index 7e7f8ba..f4a2cbd 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -188,7 +188,9 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null { const rawFrequency = parsePositiveFrequencyValue(value.frequency); const displayValueRaw = value.displayValue; const parsedDisplayFrequency = - displayValueRaw !== null && displayValueRaw !== undefined ? parseDisplayFrequencyValue(displayValueRaw) : null; + displayValueRaw !== null && displayValueRaw !== undefined + ? parseDisplayFrequencyValue(displayValueRaw) + : null; const frequency = parsedDisplayFrequency ?? rawFrequency; if (!term || !dictionary || frequency === null) { return null; diff --git a/src/main.ts b/src/main.ts index 041eeac..1fd5d90 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1135,7 +1135,10 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise { subtitlePrefetchInitController.cancelPendingInit(); return; } - await subtitlePrefetchInitController.initSubtitlePrefetch(externalFilename, lastObservedTimePos); + await subtitlePrefetchInitController.initSubtitlePrefetch( + externalFilename, + lastObservedTimePos, + ); } catch { // Track list query failed; skip subtitle prefetch refresh. } @@ -2512,11 +2515,17 @@ const ensureStatsServerStarted = (): string => { getYomitanExt: () => appState.yomitanExt, getYomitanSession: () => appState.yomitanSession, getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (w: BrowserWindow | null) => { appState.yomitanParserWindow = w; }, + setYomitanParserWindow: (w: BrowserWindow | null) => { + appState.yomitanParserWindow = w; + }, getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (p: Promise | null) => { appState.yomitanParserReadyPromise = p; }, + setYomitanParserReadyPromise: (p: Promise | null) => { + appState.yomitanParserReadyPromise = p; + }, getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (p: Promise | null) => { appState.yomitanParserInitPromise = p; }, + setYomitanParserInitPromise: (p: Promise | null) => { + appState.yomitanParserInitPromise = p; + }, }; const yomitanLogger = createLogger('main:yomitan-stats'); statsServer = startStatsServer({ @@ -2528,7 +2537,9 @@ const ensureStatsServerStarted = (): string => { ankiConnectConfig: getResolvedConfig().ankiConnect, addYomitanNote: async (word: string) => { const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; - await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { forceOverride: true }); + await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { + forceOverride: true, + }); return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); }, }); diff --git a/src/main/runtime/anilist-media-guess.test.ts b/src/main/runtime/anilist-media-guess.test.ts index 9b47674..f76d7c8 100644 --- a/src/main/runtime/anilist-media-guess.test.ts +++ b/src/main/runtime/anilist-media-guess.test.ts @@ -60,6 +60,11 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => { assert.deepEqual(first, { title: 'Show', season: null, episode: 1, source: 'guessit' }); assert.deepEqual(second, { title: 'Show', season: null, episode: 1, source: 'guessit' }); assert.equal(calls, 1); - assert.deepEqual(state.mediaGuess, { title: 'Show', season: null, episode: 1, source: 'guessit' }); + assert.deepEqual(state.mediaGuess, { + title: 'Show', + season: null, + episode: 1, + source: 'guessit', + }); assert.equal(state.mediaGuessPromise, null); }); diff --git a/src/main/runtime/anilist-post-watch-main-deps.test.ts b/src/main/runtime/anilist-post-watch-main-deps.test.ts index f8d9f2f..7bb48e8 100644 --- a/src/main/runtime/anilist-post-watch-main-deps.test.ts +++ b/src/main/runtime/anilist-post-watch-main-deps.test.ts @@ -85,7 +85,11 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy deps.resetTrackedMedia('media'); assert.equal(deps.getWatchedSeconds(), 100); assert.equal(await deps.maybeProbeAnilistDuration('media'), 120); - assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', season: null, episode: 1 }); + assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { + title: 'x', + season: null, + episode: 1, + }); assert.equal(deps.hasAttemptedUpdateKey('k'), false); assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' }); assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 87536c9..57b56c7 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -145,7 +145,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { }, reportJellyfinRemoteProgress: (forceImmediate: boolean) => deps.reportJellyfinRemoteProgress(forceImmediate), - onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined, + onTimePosUpdate: deps.onTimePosUpdate + ? (time: number) => deps.onTimePosUpdate!(time) + : undefined, recordPauseState: (paused: boolean) => { deps.appState.playbackPaused = paused; deps.ensureImmersionTrackerInitialized(); diff --git a/src/main/runtime/stats-cli-command.test.ts b/src/main/runtime/stats-cli-command.test.ts index 81036e2..1fc53af 100644 --- a/src/main/runtime/stats-cli-command.test.ts +++ b/src/main/runtime/stats-cli-command.test.ts @@ -2,9 +2,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createRunStatsCliCommandHandler } from './stats-cli-command'; -function makeHandler(overrides: Partial[0]> = {}) { +function makeHandler( + overrides: Partial[0]> = {}, +) { const calls: string[] = []; - const responses: Array<{ responsePath: string; payload: { ok: boolean; url?: string; error?: string } }> = []; + const responses: Array<{ + responsePath: string; + payload: { ok: boolean; url?: string; error?: string }; + }> = []; const handler = createRunStatsCliCommandHandler({ getResolvedConfig: () => ({ diff --git a/src/main/runtime/stats-cli-command.ts b/src/main/runtime/stats-cli-command.ts index 95eb921..51612ea 100644 --- a/src/main/runtime/stats-cli-command.ts +++ b/src/main/runtime/stats-cli-command.ts @@ -31,7 +31,9 @@ export function createRunStatsCliCommandHandler(deps: { getResolvedConfig: () => StatsCliConfig; ensureImmersionTrackerStarted: () => void; ensureVocabularyCleanupTokenizerReady?: () => Promise | void; - getImmersionTracker: () => { cleanupVocabularyStats?: () => Promise } | null; + getImmersionTracker: () => { + cleanupVocabularyStats?: () => Promise; + } | null; ensureStatsServerStarted: () => string; openExternal: (url: string) => Promise; writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void; diff --git a/src/main/runtime/subtitle-prefetch-source.test.ts b/src/main/runtime/subtitle-prefetch-source.test.ts index 9704c47..e031437 100644 --- a/src/main/runtime/subtitle-prefetch-source.test.ts +++ b/src/main/runtime/subtitle-prefetch-source.test.ts @@ -27,13 +27,16 @@ test('getActiveExternalSubtitleSource returns null when the selected track is no }); test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => { - const fileUrl = process.platform === 'win32' - ? 'file:///C:/Users/test/Sub%20Folder/subs.ass' - : 'file:///tmp/Sub%20Folder/subs.ass'; + const fileUrl = + process.platform === 'win32' + ? 'file:///C:/Users/test/Sub%20Folder/subs.ass' + : 'file:///tmp/Sub%20Folder/subs.ass'; const resolved = resolveSubtitleSourcePath(fileUrl); - assert.ok(resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass')); + assert.ok( + resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass'), + ); }); test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => { diff --git a/src/preload-stats.ts b/src/preload-stats.ts index 414daee..136890a 100644 --- a/src/preload-stats.ts +++ b/src/preload-stats.ts @@ -2,8 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS } from './shared/ipc/contracts'; const statsAPI = { - getOverview: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview), + getOverview: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview), getDailyRollups: (limit?: number): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit), diff --git a/stats/index.html b/stats/index.html index f1dba12..e0a2be7 100644 --- a/stats/index.html +++ b/stats/index.html @@ -1,4 +1,4 @@ - + diff --git a/stats/src/App.tsx b/stats/src/App.tsx index 9070ff1..e33fca3 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -44,12 +44,24 @@ export function App() {
{activeTab === 'overview' ? ( -
+
) : null} {activeTab === 'anime' ? ( -
+
setSelectedAnimeId(null)} @@ -58,12 +70,24 @@ export function App() {
) : null} {activeTab === 'trends' ? ( -