mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
chore: apply remaining workspace formatting and updates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<number>();
|
||||
const responses = [[10, 11], [10, 11, 12, 13]];
|
||||
const responses = [
|
||||
[10, 11],
|
||||
[10, 11, 12, 13],
|
||||
];
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -624,7 +624,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const knownWordsConfig = isObject(ac.knownWords) ? (ac.knownWords as Record<string, unknown>) : {};
|
||||
const knownWordsConfig = isObject(ac.knownWords)
|
||||
? (ac.knownWords as Record<string, unknown>)
|
||||
: {};
|
||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function runGuessit(target: string): Promise<string> {
|
||||
|
||||
export interface GuessAnilistMediaInfoDeps {
|
||||
runGuessit: (target: string) => Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
function firstString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<KanjiOccurrenceRow[]> {
|
||||
async getKanjiOccurrences(kanji: string, limit = 100, offset = 0): Promise<KanjiOccurrenceRow[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = ?')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -73,7 +73,12 @@ export interface IpcServiceDeps {
|
||||
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
||||
getDailyRollups: (limit?: number) => Promise<unknown>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<unknown>;
|
||||
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<unknown>;
|
||||
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getVocabularyStats: (limit?: number) => Promise<unknown>;
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number | null> },
|
||||
options?: {
|
||||
staticDir?: string;
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
},
|
||||
) {
|
||||
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<string, { value: string }> }> };
|
||||
const result = (await response.json()) as {
|
||||
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||
};
|
||||
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'), `<b>${word}</b>`)
|
||||
? sentence.replace(
|
||||
new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||
`<b>${word}</b>`,
|
||||
)
|
||||
: 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<string, string> = {};
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -188,7 +188,9 @@ async function filterSubtitleAnnotationTokens(tokens: MergedToken[]): Promise<Me
|
||||
}
|
||||
|
||||
const annotationStage = await annotationStageModulePromise;
|
||||
return tokens.filter((token) => !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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
src/main.ts
21
src/main.ts
@@ -1135,7 +1135,10 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
||||
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<void> | null) => { appState.yomitanParserReadyPromise = p; },
|
||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||
appState.yomitanParserReadyPromise = p;
|
||||
},
|
||||
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => { appState.yomitanParserInitPromise = p; },
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | 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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {}) {
|
||||
function makeHandler(
|
||||
overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[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: () => ({
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
getResolvedConfig: () => StatsCliConfig;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
|
||||
getImmersionTracker: () => { cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary> } | null;
|
||||
getImmersionTracker: () => {
|
||||
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
|
||||
} | null;
|
||||
ensureStatsServerStarted: () => string;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -2,8 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
const statsAPI = {
|
||||
getOverview: (): Promise<unknown> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
|
||||
getOverview: (): Promise<unknown> => ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
|
||||
|
||||
getDailyRollups: (limit?: number): Promise<unknown> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit),
|
||||
|
||||
Reference in New Issue
Block a user