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<path>)
This commit is contained in:
2026-07-05 02:14:44 -07:00
parent 2f16df8d17
commit 33d9a7890d
8 changed files with 294 additions and 7 deletions
+4
View File
@@ -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`).
+34 -3
View File
@@ -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<string | null> = [],
): 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<string | null> = [],
): 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<string, string>();
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;
+126
View File
@@ -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<string, Buffer> {
const blobs = new Map<string, Buffer>();
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<string | null | undefined>,
cacheDir: string = getDefaultCoverCacheDir(),
): Map<string, string> {
const wanted = Array.from(new Set(hashes.filter((hash): hash is string => Boolean(hash))));
const resolved = new Map<string, string>();
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<string, Buffer>;
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;
}
+23 -1
View File
@@ -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<RawHistoryRow>(
`
@@ -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();
+13 -2
View File
@@ -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());
+2
View File
@@ -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 {
+91 -1
View File
@@ -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> = {}): 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');
+1
View File
@@ -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';