From 651e541ea8f282766d34d8deb49e16c658241d7b Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 5 Jul 2026 00:57:06 -0700 Subject: [PATCH] refactor(launcher): split history.ts into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract types → history-types.ts, DB layer → history-db.ts, navigation → history-navigation.ts - history.ts becomes a re-export barrel - Fix findNextEpisode: deleted-file fallback now advances to next season - Add isReadonlyWalRetryError (WAL-mode guard before read-write retry) - Strengthen bun-sqlite.d.ts with generics on Statement/query/prepare --- launcher/bun-sqlite.d.ts | 18 ++- launcher/history-db.ts | 120 +++++++++++++++++ launcher/history-navigation.ts | 121 +++++++++++++++++ launcher/history-types.ts | 21 +++ launcher/history.test.ts | 48 +++++++ launcher/history.ts | 235 +-------------------------------- 6 files changed, 324 insertions(+), 239 deletions(-) create mode 100644 launcher/history-db.ts create mode 100644 launcher/history-navigation.ts create mode 100644 launcher/history-types.ts diff --git a/launcher/bun-sqlite.d.ts b/launcher/bun-sqlite.d.ts index f29fe7b6..3e094507 100644 --- a/launcher/bun-sqlite.d.ts +++ b/launcher/bun-sqlite.d.ts @@ -7,10 +7,10 @@ declare module 'bun:sqlite' { lastInsertRowid: number | bigint; } - export interface Statement { - all(...params: unknown[]): unknown[]; - get(...params: unknown[]): unknown; - run(...params: unknown[]): RunResult; + export interface Statement { + all(...params: ParamsType): ReturnType[]; + get(...params: ParamsType): ReturnType | undefined; + run(...params: ParamsType): RunResult; } export class Database { @@ -18,9 +18,13 @@ declare module 'bun:sqlite' { filename: string, options?: { readonly?: boolean; readwrite?: boolean; create?: boolean }, ); - query(sql: string): Statement; - prepare(sql: string): Statement; + query( + sql: string, + ): Statement; + prepare( + sql: string, + ): Statement; run(sql: string, ...params: unknown[]): RunResult; - close(): void; + close(throwOnError?: boolean): void; } } diff --git a/launcher/history-db.ts b/launcher/history-db.ts new file mode 100644 index 00000000..830a878f --- /dev/null +++ b/launcher/history-db.ts @@ -0,0 +1,120 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Database } from 'bun:sqlite'; +import { resolveConfigDir } from '../src/config/path-resolution.js'; +import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; +import type { HistoryVideoRow } from './history-types.js'; +import { resolvePathMaybe } from './util.js'; + +export function resolveImmersionDbPath(): string { + const root = readLauncherMainConfigObject(); + const tracking = + root?.immersionTracking && + typeof root.immersionTracking === 'object' && + !Array.isArray(root.immersionTracking) + ? (root.immersionTracking as Record) + : null; + const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : ''; + if (configured) return resolvePathMaybe(configured); + + const configDir = resolveConfigDir({ + platform: process.platform, + appDataDir: process.env.APPDATA, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); + return path.join(configDir, 'immersion.sqlite'); +} + +interface RawHistoryRow { + video_id: number; + source_path: string | null; + parsed_title: string | null; + parsed_season: number | null; + parsed_episode: number | null; + anime_title: string | null; + last_watched_ms: number | bigint | null; +} + +export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] { + try { + return readHistoryRows(dbPath, { readonly: true }); + } catch (error) { + if (!isReadonlyWalRetryError(error, dbPath)) throw error; + return readHistoryRows(dbPath, { readwrite: true, create: false }); + } +} + +export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean { + if (!isWalModeSqliteDatabase(dbPath)) return false; + const code = + typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code ?? '') + : ''; + const message = error instanceof Error ? error.message : String(error); + const text = `${code} ${message}`.toLowerCase(); + return ( + text.includes('readonly') || + text.includes('read-only') || + text.includes('attempt to write a readonly database') + ); +} + +function isWalModeSqliteDatabase(dbPath: string): boolean { + const header = Buffer.alloc(20); + let fd: number | null = null; + try { + fd = fs.openSync(dbPath, 'r'); + if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false; + } catch { + return false; + } finally { + if (fd !== null) fs.closeSync(fd); + } + return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2; +} + +function readHistoryRows( + dbPath: string, + options: { readonly?: boolean; readwrite?: boolean; create?: boolean }, +): HistoryVideoRow[] { + const db = new Database(dbPath, options); + try { + const rows = db + .query( + ` + SELECT + v.video_id, + v.source_path, + v.parsed_title, + v.parsed_season, + v.parsed_episode, + COALESCE(a.title_romaji, a.canonical_title) AS anime_title, + MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms + FROM imm_sessions s + JOIN imm_videos v ON v.video_id = s.video_id + LEFT JOIN imm_anime a ON a.anime_id = v.anime_id + WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != '' + GROUP BY v.video_id + ORDER BY last_watched_ms DESC + `, + ) + .all(); + + return rows + .filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0) + .map((row) => ({ + videoId: row.video_id, + sourcePath: row.source_path!, + parsedTitle: row.parsed_title, + parsedSeason: row.parsed_season, + parsedEpisode: row.parsed_episode, + animeTitle: row.anime_title, + lastWatchedMs: Number(row.last_watched_ms ?? 0), + })); + } finally { + db.close(); + } +} diff --git a/launcher/history-navigation.ts b/launcher/history-navigation.ts new file mode 100644 index 00000000..4612e6cb --- /dev/null +++ b/launcher/history-navigation.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parseMediaInfo } from '../src/jimaku/utils.js'; +import { collectVideos } from './picker.js'; +import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js'; + +const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i; + +export function seasonNumberFromDirName(name: string): number | null { + const match = name.trim().match(SEASON_DIR_PATTERN); + if (!match) return null; + const parsed = Number.parseInt(match[1]!, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +export function resolveSeriesRoot(filePath: string): string { + const parent = path.dirname(filePath); + if (seasonNumberFromDirName(path.basename(parent)) !== null) { + return path.dirname(parent); + } + return parent; +} + +export function groupHistoryBySeries( + rows: HistoryVideoRow[], + existsFn: (candidate: string) => boolean = fs.existsSync, +): HistorySeriesEntry[] { + const byRoot = new Map(); + const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs); + + for (const row of sorted) { + const seriesRoot = resolveSeriesRoot(row.sourcePath); + if (byRoot.has(seriesRoot)) continue; + if (!existsFn(seriesRoot)) continue; + const displayName = + row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot); + byRoot.set(seriesRoot, { seriesRoot, displayName, lastWatched: row }); + } + + return Array.from(byRoot.values()); +} + +function compareNatural(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); +} + +export function sortVideosByEpisode(videos: string[]): string[] { + const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) })); + parsed.sort((a, b) => { + if (a.info.episode !== null && b.info.episode !== null) { + const seasonA = a.info.season ?? 0; + const seasonB = b.info.season ?? 0; + if (seasonA !== seasonB) return seasonA - seasonB; + if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode; + } + return compareNatural(a.video, b.video); + }); + return parsed.map((entry) => entry.video); +} + +function dirContainsVideo(dir: string): boolean { + return collectVideos(dir, true).length > 0; +} + +export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(seriesRoot, { withFileTypes: true }); + } catch { + return []; + } + + const dirs = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + path: path.join(seriesRoot, entry.name), + season: seasonNumberFromDirName(entry.name), + })) + .filter((entry) => dirContainsVideo(entry.path)); + + dirs.sort((a, b) => { + if (a.season !== null && b.season !== null && a.season !== b.season) { + return a.season - b.season; + } + return compareNatural(a.name, b.name); + }); + return dirs; +} + +function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null { + const seriesRoot = resolveSeriesRoot(resolvedLast); + if (seriesRoot === dir) return null; + const seasons = listSeasonDirs(seriesRoot); + const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir); + if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null; + const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false)); + return nextSeason[0] ?? null; +} + +export function findNextEpisode(lastPath: string): string | null { + const resolvedLast = path.resolve(lastPath); + const dir = path.dirname(resolvedLast); + const episodes = sortVideosByEpisode(collectVideos(dir, false)); + const idx = episodes.indexOf(resolvedLast); + + if (idx >= 0) { + if (idx + 1 < episodes.length) return episodes[idx + 1]!; + } else { + const lastInfo = parseMediaInfo(resolvedLast); + if (lastInfo.episode !== null) { + const candidate = episodes.find((episode) => { + const info = parseMediaInfo(episode); + return info.episode !== null && info.episode > lastInfo.episode!; + }); + if (candidate) return candidate; + } + } + + return findFirstEpisodeInNextSeason(resolvedLast, dir); +} diff --git a/launcher/history-types.ts b/launcher/history-types.ts new file mode 100644 index 00000000..19dbbf89 --- /dev/null +++ b/launcher/history-types.ts @@ -0,0 +1,21 @@ +export interface HistoryVideoRow { + videoId: number; + sourcePath: string; + parsedTitle: string | null; + parsedSeason: number | null; + parsedEpisode: number | null; + animeTitle: string | null; + lastWatchedMs: number; +} + +export interface HistorySeriesEntry { + seriesRoot: string; + displayName: string; + lastWatched: HistoryVideoRow; +} + +export interface SeasonDirEntry { + name: string; + path: string; + season: number | null; +} diff --git a/launcher/history.test.ts b/launcher/history.test.ts index 877b8997..1b5ed8b9 100644 --- a/launcher/history.test.ts +++ b/launcher/history.test.ts @@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite'; import { findNextEpisode, groupHistoryBySeries, + isReadonlyWalRetryError, listSeasonDirs, queryLocalWatchHistory, resolveSeriesRoot, @@ -182,6 +183,18 @@ test('findNextEpisode falls back to episode numbers when file was removed', () = } }); +test('findNextEpisode advances seasons when a deleted file was the last episode', () => { + const seriesRoot = createSeriesTree(); + try { + const season1 = path.join(seriesRoot, 'Season-1'); + const season2 = path.join(seriesRoot, 'Season-2'); + const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv'); + assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv')); + } finally { + fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true }); + } +}); + function createHistoryDb(dbPath: string, options: { wal?: boolean } = {}): void { const db = new Database(dbPath); try { @@ -269,3 +282,38 @@ test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-')); + const walDbPath = path.join(dir, 'wal.sqlite'); + const rollbackDbPath = path.join(dir, 'rollback.sqlite'); + try { + createHistoryDb(walDbPath, { wal: true }); + createHistoryDb(rollbackDbPath); + + assert.equal( + isReadonlyWalRetryError( + Object.assign(new Error('attempt to write a readonly database'), { + code: 'SQLITE_READONLY', + }), + walDbPath, + ), + true, + ); + assert.equal( + isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath), + false, + ); + assert.equal( + isReadonlyWalRetryError( + Object.assign(new Error('attempt to write a readonly database'), { + code: 'SQLITE_READONLY', + }), + rollbackDbPath, + ), + false, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/launcher/history.ts b/launcher/history.ts index f405994e..280238a4 100644 --- a/launcher/history.ts +++ b/launcher/history.ts @@ -1,232 +1,3 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { Database } from 'bun:sqlite'; -import { parseMediaInfo } from '../src/jimaku/utils.js'; -import { resolveConfigDir } from '../src/config/path-resolution.js'; -import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; -import { collectVideos } from './picker.js'; -import { resolvePathMaybe } from './util.js'; - -export interface HistoryVideoRow { - videoId: number; - sourcePath: string; - parsedTitle: string | null; - parsedSeason: number | null; - parsedEpisode: number | null; - animeTitle: string | null; - lastWatchedMs: number; -} - -export interface HistorySeriesEntry { - seriesRoot: string; - displayName: string; - lastWatched: HistoryVideoRow; -} - -export interface SeasonDirEntry { - name: string; - path: string; - season: number | null; -} - -const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i; - -export function seasonNumberFromDirName(name: string): number | null { - const match = name.trim().match(SEASON_DIR_PATTERN); - if (!match) return null; - const parsed = Number.parseInt(match[1]!, 10); - return Number.isFinite(parsed) ? parsed : null; -} - -export function resolveSeriesRoot(filePath: string): string { - const parent = path.dirname(filePath); - if (seasonNumberFromDirName(path.basename(parent)) !== null) { - return path.dirname(parent); - } - return parent; -} - -export function resolveImmersionDbPath(): string { - const root = readLauncherMainConfigObject(); - const tracking = - root?.immersionTracking && - typeof root.immersionTracking === 'object' && - !Array.isArray(root.immersionTracking) - ? (root.immersionTracking as Record) - : null; - const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : ''; - if (configured) return resolvePathMaybe(configured); - - const configDir = resolveConfigDir({ - platform: process.platform, - appDataDir: process.env.APPDATA, - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - existsSync: fs.existsSync, - }); - return path.join(configDir, 'immersion.sqlite'); -} - -interface RawHistoryRow { - video_id: number; - source_path: string | null; - parsed_title: string | null; - parsed_season: number | null; - parsed_episode: number | null; - anime_title: string | null; - last_watched_ms: number | bigint | null; -} - -export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] { - try { - return readHistoryRows(dbPath, { readonly: true }); - } catch { - // The database uses WAL mode; a read-only connection cannot recreate the - // -wal/-shm files after the app closed them, so retry with a read-write - // handle (still only running SELECTs). - return readHistoryRows(dbPath, { readwrite: true, create: false }); - } -} - -function readHistoryRows( - dbPath: string, - options: { readonly?: boolean; readwrite?: boolean; create?: boolean }, -): HistoryVideoRow[] { - const db = new Database(dbPath, options); - try { - const rows = db - .query( - ` - SELECT - v.video_id, - v.source_path, - v.parsed_title, - v.parsed_season, - v.parsed_episode, - COALESCE(a.title_romaji, a.canonical_title) AS anime_title, - MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms - FROM imm_sessions s - JOIN imm_videos v ON v.video_id = s.video_id - LEFT JOIN imm_anime a ON a.anime_id = v.anime_id - WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != '' - GROUP BY v.video_id - ORDER BY last_watched_ms DESC - `, - ) - .all() as RawHistoryRow[]; - - return rows - .filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0) - .map((row) => ({ - videoId: row.video_id, - sourcePath: row.source_path!, - parsedTitle: row.parsed_title, - parsedSeason: row.parsed_season, - parsedEpisode: row.parsed_episode, - animeTitle: row.anime_title, - lastWatchedMs: Number(row.last_watched_ms ?? 0), - })); - } finally { - db.close(); - } -} - -export function groupHistoryBySeries( - rows: HistoryVideoRow[], - existsFn: (candidate: string) => boolean = fs.existsSync, -): HistorySeriesEntry[] { - const byRoot = new Map(); - const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs); - - for (const row of sorted) { - const seriesRoot = resolveSeriesRoot(row.sourcePath); - if (byRoot.has(seriesRoot)) continue; - if (!existsFn(seriesRoot)) continue; - const displayName = - row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot); - byRoot.set(seriesRoot, { seriesRoot, displayName, lastWatched: row }); - } - - return Array.from(byRoot.values()); -} - -function compareNatural(a: string, b: string): number { - return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); -} - -export function sortVideosByEpisode(videos: string[]): string[] { - const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) })); - parsed.sort((a, b) => { - if (a.info.episode !== null && b.info.episode !== null) { - const seasonA = a.info.season ?? 0; - const seasonB = b.info.season ?? 0; - if (seasonA !== seasonB) return seasonA - seasonB; - if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode; - } - return compareNatural(a.video, b.video); - }); - return parsed.map((entry) => entry.video); -} - -function dirContainsVideo(dir: string): boolean { - return collectVideos(dir, true).length > 0; -} - -export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(seriesRoot, { withFileTypes: true }); - } catch { - return []; - } - - const dirs = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => ({ - name: entry.name, - path: path.join(seriesRoot, entry.name), - season: seasonNumberFromDirName(entry.name), - })) - .filter((entry) => dirContainsVideo(entry.path)); - - dirs.sort((a, b) => { - if (a.season !== null && b.season !== null && a.season !== b.season) { - return a.season - b.season; - } - return compareNatural(a.name, b.name); - }); - return dirs; -} - -export function findNextEpisode(lastPath: string): string | null { - const resolvedLast = path.resolve(lastPath); - const dir = path.dirname(resolvedLast); - const episodes = sortVideosByEpisode(collectVideos(dir, false)); - const idx = episodes.indexOf(resolvedLast); - - if (idx >= 0) { - if (idx + 1 < episodes.length) return episodes[idx + 1]!; - } else { - // Last-watched file no longer exists: fall back to the first episode with - // a higher parsed episode number in the same directory. - const lastInfo = parseMediaInfo(resolvedLast); - if (lastInfo.episode !== null) { - const candidate = episodes.find((episode) => { - const info = parseMediaInfo(episode); - return info.episode !== null && info.episode > lastInfo.episode!; - }); - if (candidate) return candidate; - } - return null; - } - - // End of the season: continue with the first episode of the next season. - const seriesRoot = resolveSeriesRoot(resolvedLast); - if (seriesRoot === dir) return null; - const seasons = listSeasonDirs(seriesRoot); - const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir); - if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null; - const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false)); - return nextSeason[0] ?? null; -} +export * from './history-db.js'; +export * from './history-navigation.js'; +export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';