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 }); } });