mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
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:
@@ -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`).
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
findNextEpisode,
|
findNextEpisode,
|
||||||
groupHistoryBySeries,
|
groupHistoryBySeries,
|
||||||
listSeasonDirs,
|
listSeasonDirs,
|
||||||
|
materializeCoverArt,
|
||||||
queryLocalWatchHistory,
|
queryLocalWatchHistory,
|
||||||
resolveImmersionDbPath,
|
resolveImmersionDbPath,
|
||||||
sortVideosByEpisode,
|
sortVideosByEpisode,
|
||||||
@@ -30,15 +31,29 @@ function checkPickerDependencies(args: Args): void {
|
|||||||
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
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 rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||||
|
const hasIcons = icons.some(Boolean);
|
||||||
|
if (hasIcons) rofiArgs.push('-show-icons');
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath);
|
||||||
} else {
|
} else {
|
||||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
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, {
|
const result = spawnSync('rofi', rofiArgs, {
|
||||||
input: `${labels.join('\n')}\n`,
|
input: `${lines.join('\n')}\n`,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'ignore'],
|
stdio: ['pipe', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
@@ -84,9 +99,12 @@ function pickIndex(
|
|||||||
prompt: string,
|
prompt: string,
|
||||||
useRofi: boolean,
|
useRofi: boolean,
|
||||||
themePath: string | null,
|
themePath: string | null,
|
||||||
|
icons: Array<string | null> = [],
|
||||||
): number {
|
): number {
|
||||||
if (labels.length === 0) return -1;
|
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 {
|
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}`);
|
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(
|
const seriesIdx = pickIndex(
|
||||||
series.map(formatSeriesLabel),
|
series.map(formatSeriesLabel),
|
||||||
'Watch History',
|
'Watch History',
|
||||||
args.useRofi,
|
args.useRofi,
|
||||||
themePath,
|
themePath,
|
||||||
|
seriesIcons,
|
||||||
);
|
);
|
||||||
if (seriesIdx < 0) return null;
|
if (seriesIdx < 0) return null;
|
||||||
const entry = series[seriesIdx]!;
|
const entry = series[seriesIdx]!;
|
||||||
@@ -178,11 +207,13 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
|
|||||||
}
|
}
|
||||||
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||||
|
|
||||||
|
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||||
const actionIdx = pickIndex(
|
const actionIdx = pickIndex(
|
||||||
actions.map((action) => action.label),
|
actions.map((action) => action.label),
|
||||||
entry.displayName,
|
entry.displayName,
|
||||||
args.useRofi,
|
args.useRofi,
|
||||||
themePath,
|
themePath,
|
||||||
|
actions.map(() => entryIcon),
|
||||||
);
|
);
|
||||||
if (actionIdx < 0) return null;
|
if (actionIdx < 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -36,6 +36,7 @@ interface RawHistoryRow {
|
|||||||
parsed_episode: number | null;
|
parsed_episode: number | null;
|
||||||
anime_title: string | null;
|
anime_title: string | null;
|
||||||
last_watched_ms: number | bigint | null;
|
last_watched_ms: number | bigint | null;
|
||||||
|
cover_blob_hash: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
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;
|
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(
|
function readHistoryRows(
|
||||||
dbPath: string,
|
dbPath: string,
|
||||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||||
): HistoryVideoRow[] {
|
): HistoryVideoRow[] {
|
||||||
const db = new Database(dbPath, options);
|
const db = new Database(dbPath, options);
|
||||||
try {
|
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
|
const rows = db
|
||||||
.query<RawHistoryRow>(
|
.query<RawHistoryRow>(
|
||||||
`
|
`
|
||||||
@@ -94,10 +113,12 @@ function readHistoryRows(
|
|||||||
v.parsed_season,
|
v.parsed_season,
|
||||||
v.parsed_episode,
|
v.parsed_episode,
|
||||||
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
|
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
|
FROM imm_sessions s
|
||||||
JOIN imm_videos v ON v.video_id = s.video_id
|
JOIN imm_videos v ON v.video_id = s.video_id
|
||||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_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 != ''
|
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
|
||||||
GROUP BY v.video_id
|
GROUP BY v.video_id
|
||||||
ORDER BY last_watched_ms DESC
|
ORDER BY last_watched_ms DESC
|
||||||
@@ -115,6 +136,7 @@ function readHistoryRows(
|
|||||||
parsedEpisode: row.parsed_episode,
|
parsedEpisode: row.parsed_episode,
|
||||||
animeTitle: row.anime_title,
|
animeTitle: row.anime_title,
|
||||||
lastWatchedMs: Number(row.last_watched_ms ?? 0),
|
lastWatchedMs: Number(row.last_watched_ms ?? 0),
|
||||||
|
coverBlobHash: row.cover_blob_hash,
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -30,11 +30,22 @@ export function groupHistoryBySeries(
|
|||||||
|
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
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;
|
if (!existsFn(seriesRoot)) continue;
|
||||||
const displayName =
|
const displayName =
|
||||||
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
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());
|
return Array.from(byRoot.values());
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ export interface HistoryVideoRow {
|
|||||||
parsedEpisode: number | null;
|
parsedEpisode: number | null;
|
||||||
animeTitle: string | null;
|
animeTitle: string | null;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
|
coverBlobHash: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistorySeriesEntry {
|
export interface HistorySeriesEntry {
|
||||||
seriesRoot: string;
|
seriesRoot: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
lastWatched: HistoryVideoRow;
|
lastWatched: HistoryVideoRow;
|
||||||
|
coverBlobHash: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeasonDirEntry {
|
export interface SeasonDirEntry {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import {
|
import {
|
||||||
|
detectImageExtension,
|
||||||
findNextEpisode,
|
findNextEpisode,
|
||||||
groupHistoryBySeries,
|
groupHistoryBySeries,
|
||||||
isReadonlyWalRetryError,
|
isReadonlyWalRetryError,
|
||||||
listSeasonDirs,
|
listSeasonDirs,
|
||||||
|
materializeCoverArt,
|
||||||
queryLocalWatchHistory,
|
queryLocalWatchHistory,
|
||||||
resolveSeriesRoot,
|
resolveSeriesRoot,
|
||||||
seasonNumberFromDirName,
|
seasonNumberFromDirName,
|
||||||
@@ -25,6 +27,7 @@ function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
|
|||||||
parsedEpisode: 1,
|
parsedEpisode: 1,
|
||||||
animeTitle: null,
|
animeTitle: null,
|
||||||
lastWatchedMs: 1000,
|
lastWatchedMs: 1000,
|
||||||
|
coverBlobHash: null,
|
||||||
...overrides,
|
...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);
|
const db = new Database(dbPath);
|
||||||
try {
|
try {
|
||||||
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
|
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
|
||||||
@@ -239,6 +247,23 @@ function createHistoryDb(dbPath: string, options: { wal?: boolean } = {}): void
|
|||||||
(3, 2, '3000'),
|
(3, 2, '3000'),
|
||||||
(4, 3, '9000');
|
(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);');
|
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
@@ -262,11 +287,76 @@ test('queryLocalWatchHistory returns local files ordered by most recent session'
|
|||||||
try {
|
try {
|
||||||
createHistoryDb(dbPath);
|
createHistoryDb(dbPath);
|
||||||
assertHistoryRows(dbPath);
|
assertHistoryRows(dbPath);
|
||||||
|
const rows = queryLocalWatchHistory(dbPath);
|
||||||
|
assert.equal(rows[0]?.coverBlobHash, null);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
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', () => {
|
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
|
||||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './history-art.js';
|
||||||
export * from './history-db.js';
|
export * from './history-db.js';
|
||||||
export * from './history-navigation.js';
|
export * from './history-navigation.js';
|
||||||
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user