chore: apply remaining workspace formatting and updates

This commit is contained in:
2026-03-16 01:54:35 -07:00
parent 77c35c770d
commit a9e33618e7
82 changed files with 1530 additions and 736 deletions

View File

@@ -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') {

View File

@@ -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,
},
},
],
},

View File

@@ -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

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -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 = ?')

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 [];

View File

@@ -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;
});
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)
);
}

View File

@@ -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);

View File

@@ -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';

View File

@@ -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');
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;