diff --git a/README.md b/README.md index 78928ef4..505784f8 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on subminer video.mkv # launch mpv with SubMiner subminer /path/to/dir # pick a file with fzf subminer -R /path/to/dir # pick a file with rofi (Linux only) +subminer -H # browse local watch history (replay / next episode / browse) ``` On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it. diff --git a/changes/history-rofi-cover-art.md b/changes/history-rofi-cover-art.md new file mode 100644 index 00000000..3ec5c12c --- /dev/null +++ b/changes/history-rofi-cover-art.md @@ -0,0 +1,4 @@ +type: added +area: launcher + +- Show cover art icons in the rofi watch-history picker, reusing AniList covers already stored in the stats database (extracted to `~/.cache/subminer/covers`). diff --git a/changes/launcher-history-command.md b/changes/launcher-history-command.md new file mode 100644 index 00000000..86926f42 --- /dev/null +++ b/changes/launcher-history-command.md @@ -0,0 +1,4 @@ +type: added +area: launcher + +- Added `subminer -H` / `--history` to browse local watch history, replay the last watched episode, continue to the next episode, or browse episodes with fzf/rofi. diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index d86bd11b..b7803385 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable: SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R ``` +## Watch History + +`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`. + +```bash +subminer -H # fzf history browser +subminer -R -H # rofi history browser +``` + +The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu: + +- **Replay last watched** — replays the most recently watched episode +- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends) +- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first + +Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `/immersion.sqlite`), which SubMiner populates during playback. + ## Common Commands ```bash @@ -105,6 +122,7 @@ Use `subminer -h` for command-specific help. | `-d, --directory` | Video search directory (default: cwd) | | `-r, --recursive` | Search directories recursively | | `-R, --rofi` | Use rofi instead of fzf | +| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) | | `--setup` | Open first-run setup popup manually | | `-v, --version` | Print installed SubMiner version | | `-u, --update` | Check for SubMiner updates and update the app/launcher when possible | diff --git a/launcher/bun-sqlite.d.ts b/launcher/bun-sqlite.d.ts new file mode 100644 index 00000000..3e094507 --- /dev/null +++ b/launcher/bun-sqlite.d.ts @@ -0,0 +1,30 @@ +// Minimal ambient typing for bun:sqlite. The launcher always runs under bun +// (see the build banner in package.json), but the repo typechecks with plain +// tsc which has no bun type definitions. +declare module 'bun:sqlite' { + export interface RunResult { + changes: number; + lastInsertRowid: number | bigint; + } + + export interface Statement { + all(...params: ParamsType): ReturnType[]; + get(...params: ParamsType): ReturnType | undefined; + run(...params: ParamsType): RunResult; + } + + export class Database { + constructor( + filename: string, + options?: { readonly?: boolean; readwrite?: boolean; create?: boolean }, + ); + query( + sql: string, + ): Statement; + prepare( + sql: string, + ): Statement; + run(sql: string, ...params: unknown[]): RunResult; + close(throwOnError?: boolean): void; + } +} diff --git a/launcher/commands/history-command.ts b/launcher/commands/history-command.ts new file mode 100644 index 00000000..c1a66157 --- /dev/null +++ b/launcher/commands/history-command.ts @@ -0,0 +1,228 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fail, log } from '../log.js'; +import { commandExists } from '../util.js'; +import { + collectVideos, + findRofiTheme, + formatPickerLaunchError, + showFzfMenu, + showRofiMenu, +} from '../picker.js'; +import { + findNextEpisode, + groupHistoryBySeries, + listSeasonDirs, + materializeCoverArt, + queryLocalWatchHistory, + resolveImmersionDbPath, + sortVideosByEpisode, + type HistorySeriesEntry, +} from '../history.js'; +import type { Args } from '../types.js'; +import type { LauncherCommandContext } from './context.js'; + +function checkPickerDependencies(args: Args): void { + if (args.useRofi) { + if (!commandExists('rofi')) fail('Missing dependency: rofi'); + return; + } + if (!commandExists('fzf')) fail('Missing dependency: fzf'); +} + +function showRofiIndexMenu( + labels: string[], + prompt: string, + themePath: string | null, + icons: Array = [], +): number { + const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt]; + const hasIcons = icons.some(Boolean); + if (hasIcons) rofiArgs.push('-show-icons'); + if (themePath) { + rofiArgs.push('-theme', themePath); + } else { + rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}'); + } + if (hasIcons) { + rofiArgs.push('-theme-str', 'configuration { show-icons: true; }'); + rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }'); + } + const lines = labels.map((label, index) => + icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label, + ); + const result = spawnSync('rofi', rofiArgs, { + input: `${lines.join('\n')}\n`, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + if (result.error) { + fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException)); + } + const out = (result.stdout || '').trim(); + if (!out) return -1; + const idx = Number.parseInt(out, 10); + return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1; +} + +function showFzfIndexMenu(labels: string[], prompt: string): number { + const lines = labels.map((label, index) => `${index}\t${label}`); + const result = spawnSync( + 'fzf', + [ + '--ansi', + '--reverse', + '--ignore-case', + `--prompt=${prompt}: `, + '--delimiter=\t', + '--with-nth=2..', + ], + { + input: `${lines.join('\n')}\n`, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'inherit'], + }, + ); + if (result.error) { + fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException)); + } + const picked = (result.stdout || '').trim(); + const tab = picked.indexOf('\t'); + if (tab === -1) return -1; + const idx = Number.parseInt(picked.slice(0, tab), 10); + return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1; +} + +function pickIndex( + labels: string[], + prompt: string, + useRofi: boolean, + themePath: string | null, + icons: Array = [], +): number { + if (labels.length === 0) return -1; + return useRofi + ? showRofiIndexMenu(labels, prompt, themePath, icons) + : showFzfIndexMenu(labels, prompt); +} + +function formatEpisodeLabel(entry: HistorySeriesEntry): string { + const { parsedSeason, parsedEpisode } = entry.lastWatched; + if (parsedEpisode === null) return ''; + return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`; +} + +function formatSeriesLabel(entry: HistorySeriesEntry): string { + const episodeLabel = formatEpisodeLabel(entry); + return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName; +} + +function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null { + const { args, scriptPath } = context; + const videos = sortVideosByEpisode(collectVideos(dir, false)); + if (videos.length === 0) { + fail(`No video files found in: ${dir}`); + } + const selected = args.useRofi + ? showRofiMenu(videos, dir, false, scriptPath, args.logLevel) + : showFzfMenu(videos); + return selected || null; +} + +function browseEpisodes( + entry: HistorySeriesEntry, + context: LauncherCommandContext, + themePath: string | null, +): string | null { + const { args } = context; + const seasons = listSeasonDirs(entry.seriesRoot); + let dir = entry.seriesRoot; + + if (seasons.length > 1) { + const idx = pickIndex( + seasons.map((season) => season.name), + `${entry.displayName} — Season`, + args.useRofi, + themePath, + ); + if (idx < 0) return null; + dir = seasons[idx]!.path; + } else if (seasons.length === 1 && collectVideos(dir, false).length === 0) { + dir = seasons[0]!.path; + } + + return pickEpisodeFromDir(dir, context); +} + +export async function runHistoryCommand(context: LauncherCommandContext): Promise { + const { args, scriptPath } = context; + + checkPickerDependencies(args); + const themePath = args.useRofi ? findRofiTheme(scriptPath) : null; + + const dbPath = resolveImmersionDbPath(); + if (!fs.existsSync(dbPath)) { + fail(`Watch history database not found: ${dbPath}`); + } + + const rows = queryLocalWatchHistory(dbPath); + const series = groupHistoryBySeries(rows); + if (series.length === 0) { + fail('No local watch history found (or watched directories are not accessible).'); + } + + log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`); + + const coverPaths = args.useRofi + ? materializeCoverArt( + dbPath, + series.map((seriesEntry) => seriesEntry.coverBlobHash), + ) + : new Map(); + const seriesIcons = series.map((seriesEntry) => + seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null, + ); + + const seriesIdx = pickIndex( + series.map(formatSeriesLabel), + 'Watch History', + args.useRofi, + themePath, + seriesIcons, + ); + if (seriesIdx < 0) return null; + const entry = series[seriesIdx]!; + + const lastPath = path.resolve(entry.lastWatched.sourcePath); + const lastExists = fs.existsSync(lastPath); + const nextEpisode = findNextEpisode(lastPath); + + const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = []; + if (lastExists) { + actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` }); + } + if (nextEpisode) { + actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` }); + } + actions.push({ kind: 'browse', label: 'Browse episodes' }); + + const entryIcon = seriesIcons[seriesIdx] ?? null; + const actionIdx = pickIndex( + actions.map((action) => action.label), + entry.displayName, + args.useRofi, + themePath, + actions.map(() => entryIcon), + ); + if (actionIdx < 0) return null; + + switch (actions[actionIdx]!.kind) { + case 'replay': + return lastPath; + case 'next': + return nextEpisode; + case 'browse': + return browseEpisodes(entry, context, themePath); + } +} diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index e4dc834e..bf90173a 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -35,6 +35,7 @@ function createContext(): LauncherCommandContext { texthookerOnly: false, texthookerOpenBrowser: false, useRofi: false, + history: false, logLevel: 'info', logRotation: 7, passwordStore: '', diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 73b83acd..57fbfce4 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -198,6 +198,7 @@ export function createDefaultArgs( texthookerOnly: false, texthookerOpenBrowser: false, useRofi: false, + history: false, logLevel: loggingConfig.level ?? 'warn', logRotation: loggingConfig.rotation ?? 7, passwordStore: '', @@ -231,6 +232,7 @@ export function applyRootOptionsToArgs( if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel); if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore; if (options.rofi === true) parsed.useRofi = true; + if (options.history === true) parsed.history = true; if (options.update === true) parsed.update = true; if (options.version === true) parsed.version = true; if (options.settings === true) parsed.settings = true; diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index bdef0567..aed1fa15 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void { .option('--settings', 'Open settings window') .option('-u, --update', 'Check for updates') .option('-R, --rofi', 'Use rofi picker') + .option('-H, --history', 'Browse local watch history') .option('-S, --start-overlay', 'Auto-start overlay') .option('-T, --no-texthooker', 'Disable texthooker-ui server'); } diff --git a/launcher/history-art.ts b/launcher/history-art.ts new file mode 100644 index 00000000..c3f7ed9b --- /dev/null +++ b/launcher/history-art.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Database } from 'bun:sqlite'; +import { withReadonlyWalRetry } from './history-db.js'; + +const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const; +const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i; + +export function getDefaultCoverCacheDir(): string { + return path.join(os.homedir(), '.cache', 'subminer', 'covers'); +} + +export function detectImageExtension(blob: Buffer): string { + if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) { + return '.png'; + } + if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) { + return '.jpg'; + } + if ( + blob.length >= 12 && + blob.subarray(0, 4).toString('ascii') === 'RIFF' && + blob.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return '.webp'; + } + if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') { + return '.gif'; + } + return '.jpg'; +} + +function findCachedCover(cacheDir: string, hash: string): string | null { + for (const ext of COVER_EXTENSIONS) { + const candidate = path.join(cacheDir, `${hash}${ext}`); + try { + if (fs.statSync(candidate).size > 0) return candidate; + } catch { + // not cached with this extension + } + } + return null; +} + +function queryCoverBlobs( + dbPath: string, + hashes: string[], + options: { readonly?: boolean; readwrite?: boolean; create?: boolean }, +): Map { + const blobs = new Map(); + const db = new Database(dbPath, options); + try { + const hasBlobTable = db + .query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`) + .get(); + if (!hasBlobTable) return blobs; + + const stmt = db.query<{ cover_blob: Uint8Array | null }>( + 'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?', + ); + for (const hash of hashes) { + const row = stmt.get(hash); + if (row?.cover_blob && row.cover_blob.length > 0) { + blobs.set(hash, Buffer.from(row.cover_blob)); + } + } + return blobs; + } finally { + db.close(); + } +} + +function isSafeCoverHash(hash: string | null | undefined): hash is string { + return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash); +} + +/** + * Ensures cover art blobs referenced by hash exist as image files in the cache + * directory, extracting missing ones from the stats database. Returns a map of + * blob hash to on-disk image path for every cover that could be materialized. + */ +export function materializeCoverArt( + dbPath: string, + hashes: Array, + cacheDir: string = getDefaultCoverCacheDir(), +): Map { + const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash))); + const resolved = new Map(); + if (wanted.length === 0) return resolved; + + const missing: string[] = []; + for (const hash of wanted) { + const cached = findCachedCover(cacheDir, hash); + if (cached) { + resolved.set(hash, cached); + } else { + missing.push(hash); + } + } + if (missing.length === 0) return resolved; + + let blobs: Map; + try { + blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options)); + } catch { + return resolved; + } + if (blobs.size === 0) return resolved; + + try { + fs.mkdirSync(cacheDir, { recursive: true }); + } catch { + return resolved; + } + for (const [hash, blob] of blobs) { + const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`); + try { + fs.writeFileSync(target, blob); + resolved.set(hash, target); + } catch { + // cache write failure just means no icon for this entry + } + } + return resolved; +} diff --git a/launcher/history-db.ts b/launcher/history-db.ts new file mode 100644 index 00000000..68e5911b --- /dev/null +++ b/launcher/history-db.ts @@ -0,0 +1,151 @@ +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; + cover_blob_hash: string | null; +} + +export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] { + return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options)); +} + +export function withReadonlyWalRetry( + dbPath: string, + query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T, +): T { + try { + return query({ readonly: true }); + } catch (error) { + if (!isReadonlyWalRetryError(error, dbPath)) throw error; + return query({ 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') || + text.includes('sqlite_cantopen') || + text.includes('unable to open database file') + ); +} + +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 tableExists(db: Database, tableName: string): boolean { + return Boolean( + db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName), + ); +} + +function readHistoryRows( + dbPath: string, + options: { readonly?: boolean; readwrite?: boolean; create?: boolean }, +): HistoryVideoRow[] { + const db = new Database(dbPath, options); + try { + const hasMediaArt = tableExists(db, 'imm_media_art'); + const coverSelect = hasMediaArt + ? `COALESCE( + ma.cover_blob_hash, + (SELECT ma2.cover_blob_hash + FROM imm_media_art ma2 + JOIN imm_videos v2 ON v2.video_id = ma2.video_id + WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL + LIMIT 1) + ) AS cover_blob_hash` + : 'NULL AS cover_blob_hash'; + const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : ''; + 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, + ${coverSelect} + 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 + ${coverJoin} + 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), + coverBlobHash: row.cover_blob_hash, + })); + } finally { + db.close(); + } +} diff --git a/launcher/history-navigation.ts b/launcher/history-navigation.ts new file mode 100644 index 00000000..04333b45 --- /dev/null +++ b/launcher/history-navigation.ts @@ -0,0 +1,132 @@ +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); + const existing = byRoot.get(seriesRoot); + if (existing) { + if (existing.coverBlobHash === null && row.coverBlobHash !== null) { + existing.coverBlobHash = row.coverBlobHash; + } + continue; + } + if (!existsFn(seriesRoot)) continue; + const displayName = + row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot); + byRoot.set(seriesRoot, { + seriesRoot, + displayName, + lastWatched: row, + coverBlobHash: row.coverBlobHash, + }); + } + + 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..f350e82e --- /dev/null +++ b/launcher/history-types.ts @@ -0,0 +1,23 @@ +export interface HistoryVideoRow { + videoId: number; + sourcePath: string; + parsedTitle: string | null; + parsedSeason: number | null; + parsedEpisode: number | null; + animeTitle: string | null; + lastWatchedMs: number; + coverBlobHash: string | null; +} + +export interface HistorySeriesEntry { + seriesRoot: string; + displayName: string; + lastWatched: HistoryVideoRow; + coverBlobHash: string | null; +} + +export interface SeasonDirEntry { + name: string; + path: string; + season: number | null; +} diff --git a/launcher/history.test.ts b/launcher/history.test.ts new file mode 100644 index 00000000..8df575d9 --- /dev/null +++ b/launcher/history.test.ts @@ -0,0 +1,441 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Database } from 'bun:sqlite'; +import { + detectImageExtension, + findNextEpisode, + groupHistoryBySeries, + isReadonlyWalRetryError, + listSeasonDirs, + materializeCoverArt, + queryLocalWatchHistory, + resolveSeriesRoot, + seasonNumberFromDirName, + sortVideosByEpisode, + type HistoryVideoRow, +} from './history.js'; + +function makeRow(overrides: Partial = {}): HistoryVideoRow { + return { + videoId: 1, + sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv', + parsedTitle: 'Show', + parsedSeason: 1, + parsedEpisode: 1, + animeTitle: null, + lastWatchedMs: 1000, + coverBlobHash: null, + ...overrides, + }; +} + +test('seasonNumberFromDirName detects common season directory names', () => { + assert.equal(seasonNumberFromDirName('Season-1'), 1); + assert.equal(seasonNumberFromDirName('Season 2'), 2); + assert.equal(seasonNumberFromDirName('S03'), 3); + assert.equal(seasonNumberFromDirName('season_04'), 4); + assert.equal(seasonNumberFromDirName('Specials'), null); + assert.equal(seasonNumberFromDirName('Show Name'), null); +}); + +test('resolveSeriesRoot skips season directories', () => { + assert.equal( + resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'), + '/media/anime/Show', + ); + assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show'); +}); + +test('groupHistoryBySeries keeps most recent entry per series root', () => { + const rows = [ + makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }), + makeRow({ + videoId: 2, + sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv', + parsedEpisode: 2, + lastWatchedMs: 3000, + }), + makeRow({ + videoId: 3, + sourcePath: '/media/anime/Other/Other - 05.mkv', + parsedTitle: 'Other', + parsedSeason: null, + parsedEpisode: 5, + lastWatchedMs: 2000, + }), + ]; + + const series = groupHistoryBySeries(rows, () => true); + + assert.equal(series.length, 2); + assert.equal(series[0]?.displayName, 'Show'); + assert.equal(series[0]?.seriesRoot, '/media/anime/Show'); + assert.equal(series[0]?.lastWatched.parsedEpisode, 2); + assert.equal(series[1]?.displayName, 'Other'); +}); + +test('groupHistoryBySeries filters series roots that no longer exist', () => { + const rows = [ + makeRow({ videoId: 1 }), + makeRow({ + videoId: 2, + sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv', + parsedTitle: 'Missing', + lastWatchedMs: 5000, + }), + ]; + + const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/')); + + assert.equal(series.length, 1); + assert.equal(series[0]?.displayName, 'Show'); +}); + +test('groupHistoryBySeries falls back to directory name for display', () => { + const rows = [ + makeRow({ + sourcePath: '/media/anime/Some Show Dir/video.mkv', + parsedTitle: null, + animeTitle: null, + }), + ]; + + const series = groupHistoryBySeries(rows, () => true); + + assert.equal(series[0]?.displayName, 'Some Show Dir'); +}); + +test('sortVideosByEpisode orders by parsed episode with natural fallback', () => { + const videos = [ + '/media/Show/Show - S01E10 - Ten.mkv', + '/media/Show/Show - S01E02 - Two.mkv', + '/media/Show/Show - S01E01 - One.mkv', + ]; + + assert.deepEqual(sortVideosByEpisode(videos), [ + '/media/Show/Show - S01E01 - One.mkv', + '/media/Show/Show - S01E02 - Two.mkv', + '/media/Show/Show - S01E10 - Ten.mkv', + ]); +}); + +function createSeriesTree(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-')); + const seriesRoot = path.join(root, 'Show'); + const season1 = path.join(seriesRoot, 'Season-1'); + const season2 = path.join(seriesRoot, 'Season-2'); + fs.mkdirSync(season1, { recursive: true }); + fs.mkdirSync(season2, { recursive: true }); + fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true }); + for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) { + fs.writeFileSync(path.join(season1, name), ''); + } + fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), ''); + fs.writeFileSync(path.join(season1, 'notes.txt'), ''); + return seriesRoot; +} + +test('listSeasonDirs returns only video-bearing directories in season order', () => { + const seriesRoot = createSeriesTree(); + try { + const seasons = listSeasonDirs(seriesRoot); + assert.deepEqual( + seasons.map((entry) => entry.name), + ['Season-1', 'Season-2'], + ); + assert.deepEqual( + seasons.map((entry) => entry.season), + [1, 2], + ); + } finally { + fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true }); + } +}); + +test('findNextEpisode advances within a season and across seasons', () => { + const seriesRoot = createSeriesTree(); + try { + const season1 = path.join(seriesRoot, 'Season-1'); + const season2 = path.join(seriesRoot, 'Season-2'); + + assert.equal( + findNextEpisode(path.join(season1, 'Show - S01E02.mkv')), + path.join(season1, 'Show - S01E03.mkv'), + ); + assert.equal( + findNextEpisode(path.join(season1, 'Show - S01E03.mkv')), + path.join(season2, 'Show - S02E01.mkv'), + ); + assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null); + } finally { + fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true }); + } +}); + +test('findNextEpisode falls back to episode numbers when file was removed', () => { + const seriesRoot = createSeriesTree(); + try { + const season1 = path.join(seriesRoot, 'Season-1'); + const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv'); + assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv')); + } finally { + fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true }); + } +}); + +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 }); + } +}); + +const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); + +function createHistoryDb( + dbPath: string, + options: { wal?: boolean; coverArt?: boolean } = {}, +): void { + const db = new Database(dbPath); + try { + if (options.wal) db.run('PRAGMA journal_mode = WAL;'); + db.run(` + CREATE TABLE imm_anime( + anime_id INTEGER PRIMARY KEY, + canonical_title TEXT, + title_romaji TEXT + ); + `); + db.run(` + CREATE TABLE imm_videos( + video_id INTEGER PRIMARY KEY, + anime_id INTEGER, + source_type INTEGER, + source_path TEXT, + parsed_title TEXT, + parsed_season INTEGER, + parsed_episode INTEGER + ); + `); + db.run(` + CREATE TABLE imm_sessions( + session_id INTEGER PRIMARY KEY, + video_id INTEGER, + started_at_ms TEXT + ); + `); + db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`); + db.run(` + INSERT INTO imm_videos VALUES + (1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1), + (2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2), + (3, NULL, 2, NULL, 'Remote Show', NULL, NULL), + (4, NULL, 1, '', 'Empty Path', NULL, NULL); + `); + db.run(` + INSERT INTO imm_sessions VALUES + (1, 1, '1000'), + (2, 1, '5000'), + (3, 2, '3000'), + (4, 3, '9000'); + `); + if (options.coverArt) { + db.run(` + CREATE TABLE imm_media_art( + video_id INTEGER PRIMARY KEY, + cover_blob_hash TEXT + ); + `); + db.run(` + CREATE TABLE imm_cover_art_blobs( + blob_hash TEXT PRIMARY KEY, + cover_blob BLOB NOT NULL + ); + `); + // Art only on video 1; video 2 resolves it through the shared anime_id. + db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`); + db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC); + } + if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);'); + } finally { + db.close(); + } +} + +function assertHistoryRows(dbPath: string): void { + const rows = queryLocalWatchHistory(dbPath); + + assert.equal(rows.length, 2); + assert.equal(rows[0]?.videoId, 1); + assert.equal(rows[0]?.lastWatchedMs, 5000); + assert.equal(rows[0]?.animeTitle, 'Show Romaji'); + assert.equal(rows[1]?.videoId, 2); + assert.equal(rows[1]?.lastWatchedMs, 3000); +} + +test('queryLocalWatchHistory returns local files ordered by most recent session', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-')); + const dbPath = path.join(dir, 'immersion.sqlite'); + try { + createHistoryDb(dbPath); + assertHistoryRows(dbPath); + const rows = queryLocalWatchHistory(dbPath); + assert.equal(rows[0]?.coverBlobHash, null); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-')); + const dbPath = path.join(dir, 'immersion.sqlite'); + try { + createHistoryDb(dbPath, { coverArt: true }); + const rows = queryLocalWatchHistory(dbPath); + assert.equal(rows[0]?.videoId, 1); + assert.equal(rows[0]?.coverBlobHash, 'hash-1'); + assert.equal(rows[1]?.videoId, 2); + assert.equal(rows[1]?.coverBlobHash, 'hash-1'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-')); + const dbPath = path.join(dir, 'immersion.sqlite'); + const cacheDir = path.join(dir, 'covers'); + try { + createHistoryDb(dbPath, { coverArt: true }); + + const covers = materializeCoverArt( + dbPath, + ['hash-1', 'hash-1', null, 'hash-missing'], + cacheDir, + ); + const coverPath = covers.get('hash-1'); + assert.ok(coverPath); + assert.equal(path.extname(coverPath!), '.png'); + assert.ok(fs.statSync(coverPath!).size > 0); + assert.equal(covers.has('hash-missing'), false); + + // Cached file is reused even when the database has disappeared. + fs.rmSync(dbPath); + const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir); + assert.equal(cachedCovers.get('hash-1'), coverPath); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('materializeCoverArt rejects cover hashes that escape the cache dir', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-')); + const dbPath = path.join(dir, 'immersion.sqlite'); + const cacheDir = path.join(dir, 'covers'); + const unsafeHash = '../escape'; + try { + createHistoryDb(dbPath, { coverArt: true }); + const db = new Database(dbPath); + try { + db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC); + } finally { + db.close(); + } + + const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir); + + assert.equal(covers.has(unsafeHash), false); + assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('detectImageExtension identifies common cover formats', () => { + assert.equal(detectImageExtension(PNG_MAGIC), '.png'); + assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg'); + assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp'); + assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif'); + assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg'); +}); + +test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => { + const rows = [ + makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }), + makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }), + ]; + + const series = groupHistoryBySeries(rows, () => true); + + assert.equal(series.length, 1); + assert.equal(series[0]?.lastWatched.videoId, 2); + assert.equal(series[0]?.coverBlobHash, 'hash-1'); +}); + +test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-')); + const dbPath = path.join(dir, 'immersion.sqlite'); + try { + createHistoryDb(dbPath, { wal: true }); + // Reproduce the state after the app shuts down cleanly: WAL journal mode + // with no -wal/-shm sidecar files on disk. A read-only connection then + // fails at query time because it cannot recreate them. + fs.rmSync(`${dbPath}-wal`, { force: true }); + fs.rmSync(`${dbPath}-shm`, { force: true }); + assertHistoryRows(dbPath); + } finally { + 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( + Object.assign(new Error('unable to open database file'), { + code: 'SQLITE_CANTOPEN', + }), + 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 new file mode 100644 index 00000000..56ff95d6 --- /dev/null +++ b/launcher/history.ts @@ -0,0 +1,4 @@ +export * from './history-art.js'; +export * from './history-db.js'; +export * from './history-navigation.js'; +export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js'; diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts index b9c2b6e9..ad857268 100644 --- a/launcher/jellyfin.test.ts +++ b/launcher/jellyfin.test.ts @@ -29,6 +29,7 @@ function createArgs(): Args { texthookerOnly: false, texthookerOpenBrowser: false, useRofi: false, + history: false, logLevel: 'info', logRotation: 7, passwordStore: '', diff --git a/launcher/main.ts b/launcher/main.ts index 62a6060c..2ecaaaeb 100644 --- a/launcher/main.ts +++ b/launcher/main.ts @@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js'; import { runLogsCommand } from './commands/logs-command.js'; import { runStatsCommand } from './commands/stats-command.js'; import { runJellyfinCommand } from './commands/jellyfin-command.js'; +import { runHistoryCommand } from './commands/history-command.js'; import { runPlaybackCommand } from './commands/playback-command.js'; import { runUpdateCommand } from './commands/update-command.js'; @@ -142,6 +143,16 @@ async function main(): Promise { return; } + if (appContext.args.history) { + const selected = await runHistoryCommand(appContext); + if (!selected) { + log('info', args.logLevel, 'No watch history selection made, exiting'); + return; + } + appContext.args.target = selected; + appContext.args.targetKind = 'file'; + } + await runPlaybackCommand(appContext); } diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index a235825d..4ce5a4a9 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -570,6 +570,7 @@ function makeArgs(overrides: Partial = {}): Args { texthookerOnly: false, texthookerOpenBrowser: false, useRofi: false, + history: false, logLevel: 'error', logRotation: 7, passwordStore: '', diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index ba8ce222..01d4aa24 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => { assert.equal(parsed.settings, true); }); +test('parseArgs maps root watch history flags', () => { + const shortParsed = parseArgs(['-H'], 'subminer', {}); + const longParsed = parseArgs(['--history'], 'subminer', {}); + const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {}); + const defaultParsed = parseArgs([], 'subminer', {}); + + assert.equal(shortParsed.history, true); + assert.equal(longParsed.history, true); + assert.equal(rofiParsed.history, true); + assert.equal(rofiParsed.useRofi, true); + assert.equal(defaultParsed.history, false); +}); + test('parseArgs maps root update flags without conflicting with jellyfin username', () => { const shortParsed = parseArgs(['-u'], 'subminer', {}); const longParsed = parseArgs(['--update'], 'subminer', {}); diff --git a/launcher/types.ts b/launcher/types.ts index 3071d88c..cc4bf9c1 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -112,6 +112,7 @@ export interface Args { texthookerOnly: boolean; texthookerOpenBrowser: boolean; useRofi: boolean; + history: boolean; logLevel: LogLevel; logRotation: LogRotation; passwordStore: string; diff --git a/package.json b/package.json index 9a5e821b..0d14e67b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", - "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", + "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/history.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/youtube-media-cache-playback.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/core/utils/notification.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/windows-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/windows-mpv-plugin-detection.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/shared/setup-state.test.js dist/shared/mpv-x11-backend.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/yomitan-extension-loader.test.js dist/core/services/yomitan-settings.test.js dist/core/services/settings-window-z-order.test.js dist/core/services/hyprland-window-placement.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/__tests__/stats-server.test.js dist/main/runtime/stats-server-routing.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/overlay-runtime.test.js dist/main/runtime/macos-mpv-focus.test.js dist/main/runtime/macos-modal-focus-handoff.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/youtube-media-cache-playback.test.js dist/main/runtime/autoplay-ready-gate.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/visible-overlay-autoplay-readiness.test.js dist/main/runtime/character-dictionary-manager-gate.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/core/utils/shortcut-config.test.js dist/core/utils/electron-backend.test.js dist/core/utils/notification.test.js dist/main/runtime/startup-mode-flags.test.js dist/main/runtime/linux-overlay-pointer-interaction.test.js dist/main/runtime/windows-overlay-pointer-interaction.test.js dist/main/runtime/linux-overlay-zorder-keepalive.test.js dist/main/runtime/config-settings-window.test.js dist/main/runtime/settings-window-z-order.test.js dist/main/runtime/setup-window-factory.test.js dist/main/runtime/first-run-setup-plugin.test.js dist/main/runtime/windows-mpv-plugin-detection.test.js dist/main/runtime/first-run-setup-service.test.js dist/main/runtime/first-run-setup-window.test.js dist/main/runtime/command-line-launcher.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/composers/cli-startup-composer.test.js dist/main/runtime/log-export.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/tray-runtime-handlers.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/update/appimage-updater.test.js dist/main/runtime/update/fetch-adapter.test.js dist/main/runtime/update/release-metadata-policy.test.js dist/main/runtime/update/update-dialogs.test.js dist/main/runtime/update/support-assets.test.js dist/renderer/error-recovery.test.js dist/renderer/overlay-content-measurement.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/main/character-dictionary-runtime/term-building.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js dist/core/services/overlay-visibility.test.js dist/core/services/overlay-window-config.test.js dist/core/services/overlay-window.test.js dist/main/main-wiring.test.js dist/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/overlay-modal-input-state.test.js dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/overlay-window-layout-main-deps.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/overlay-window-runtime-handlers.test.js dist/main/runtime/yomitan-extension-overlay-reload.test.js dist/renderer/modals/subtitle-sidebar.test.js dist/renderer/overlay-mouse-ignore.test.js dist/main/runtime/linux-visible-overlay-window-mode.test.js dist/main/runtime/linux-x11-cursor-point.test.js dist/renderer/renderer-init-order.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",