From 33d9a7890d6d05eaf6d4e2a021bc9d6785955ee6 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 5 Jul 2026 02:14:44 -0700 Subject: [PATCH] feat(launcher): show cover art icons in rofi history picker - Add history-art.ts: materializeCoverArt extracts cover blobs from stats DB to ~/.cache/subminer/covers - Query cover_blob_hash in history-db with fallback via shared anime_id - Backfill cover hash from older rows of the same series in groupHistoryBySeries - Pass icons to rofi via null-separator protocol (\0icon\x1f) --- changes/history-rofi-cover-art.md | 4 + launcher/commands/history-command.ts | 37 +++++++- launcher/history-art.ts | 126 +++++++++++++++++++++++++++ launcher/history-db.ts | 24 ++++- launcher/history-navigation.ts | 15 +++- launcher/history-types.ts | 2 + launcher/history.test.ts | 92 ++++++++++++++++++- launcher/history.ts | 1 + 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 changes/history-rofi-cover-art.md create mode 100644 launcher/history-art.ts 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/launcher/commands/history-command.ts b/launcher/commands/history-command.ts index 33ed0f29..c1a66157 100644 --- a/launcher/commands/history-command.ts +++ b/launcher/commands/history-command.ts @@ -14,6 +14,7 @@ import { findNextEpisode, groupHistoryBySeries, listSeasonDirs, + materializeCoverArt, queryLocalWatchHistory, resolveImmersionDbPath, sortVideosByEpisode, @@ -30,15 +31,29 @@ function checkPickerDependencies(args: Args): void { if (!commandExists('fzf')) fail('Missing dependency: fzf'); } -function showRofiIndexMenu(labels: string[], prompt: string, themePath: string | null): number { +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: `${labels.join('\n')}\n`, + input: `${lines.join('\n')}\n`, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], }); @@ -84,9 +99,12 @@ function pickIndex( prompt: string, useRofi: boolean, themePath: string | null, + icons: Array = [], ): number { if (labels.length === 0) return -1; - return useRofi ? showRofiIndexMenu(labels, prompt, themePath) : showFzfIndexMenu(labels, prompt); + return useRofi + ? showRofiIndexMenu(labels, prompt, themePath, icons) + : showFzfIndexMenu(labels, prompt); } function formatEpisodeLabel(entry: HistorySeriesEntry): string { @@ -156,11 +174,22 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis 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]!; @@ -178,11 +207,13 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis } 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; diff --git a/launcher/history-art.ts b/launcher/history-art.ts new file mode 100644 index 00000000..24ac78b1 --- /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 { isReadonlyWalRetryError } from './history-db.js'; + +const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const; + +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(); + } +} + +/** + * 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((hash): hash is string => Boolean(hash)))); + 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 { + try { + blobs = queryCoverBlobs(dbPath, missing, { readonly: true }); + } catch (error) { + if (!isReadonlyWalRetryError(error, dbPath)) throw error; + blobs = queryCoverBlobs(dbPath, missing, { readwrite: true, create: false }); + } + } 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 index 2459da6c..0910d7c7 100644 --- a/launcher/history-db.ts +++ b/launcher/history-db.ts @@ -36,6 +36,7 @@ interface RawHistoryRow { 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[] { @@ -78,12 +79,30 @@ function isWalModeSqliteDatabase(dbPath: string): boolean { 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( ` @@ -94,10 +113,12 @@ function readHistoryRows( 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 + 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 @@ -115,6 +136,7 @@ function readHistoryRows( 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 index 4612e6cb..04333b45 100644 --- a/launcher/history-navigation.ts +++ b/launcher/history-navigation.ts @@ -30,11 +30,22 @@ export function groupHistoryBySeries( for (const row of sorted) { const seriesRoot = resolveSeriesRoot(row.sourcePath); - if (byRoot.has(seriesRoot)) continue; + 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 }); + byRoot.set(seriesRoot, { + seriesRoot, + displayName, + lastWatched: row, + coverBlobHash: row.coverBlobHash, + }); } return Array.from(byRoot.values()); diff --git a/launcher/history-types.ts b/launcher/history-types.ts index 19dbbf89..f350e82e 100644 --- a/launcher/history-types.ts +++ b/launcher/history-types.ts @@ -6,12 +6,14 @@ export interface HistoryVideoRow { 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 { diff --git a/launcher/history.test.ts b/launcher/history.test.ts index 5d332652..fcf03104 100644 --- a/launcher/history.test.ts +++ b/launcher/history.test.ts @@ -5,10 +5,12 @@ 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, @@ -25,6 +27,7 @@ function makeRow(overrides: Partial = {}): HistoryVideoRow { parsedEpisode: 1, animeTitle: null, lastWatchedMs: 1000, + coverBlobHash: null, ...overrides, }; } @@ -195,7 +198,12 @@ test('findNextEpisode advances seasons when a deleted file was the last episode' } }); -function createHistoryDb(dbPath: string, options: { wal?: boolean } = {}): void { +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;'); @@ -239,6 +247,23 @@ function createHistoryDb(dbPath: string, options: { wal?: boolean } = {}): void (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(); @@ -262,11 +287,76 @@ test('queryLocalWatchHistory returns local files ordered by most recent session' 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('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'); diff --git a/launcher/history.ts b/launcher/history.ts index 280238a4..56ff95d6 100644 --- a/launcher/history.ts +++ b/launcher/history.ts @@ -1,3 +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';