mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
refactor(launcher): split history.ts into focused modules
- Extract types → history-types.ts, DB layer → history-db.ts, navigation → history-navigation.ts - history.ts becomes a re-export barrel - Fix findNextEpisode: deleted-file fallback now advances to next season - Add isReadonlyWalRetryError (WAL-mode guard before read-write retry) - Strengthen bun-sqlite.d.ts with generics on Statement/query/prepare
This commit is contained in:
Vendored
+11
-7
@@ -7,10 +7,10 @@ declare module 'bun:sqlite' {
|
|||||||
lastInsertRowid: number | bigint;
|
lastInsertRowid: number | bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Statement {
|
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
|
||||||
all(...params: unknown[]): unknown[];
|
all(...params: ParamsType): ReturnType[];
|
||||||
get(...params: unknown[]): unknown;
|
get(...params: ParamsType): ReturnType | undefined;
|
||||||
run(...params: unknown[]): RunResult;
|
run(...params: ParamsType): RunResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Database {
|
export class Database {
|
||||||
@@ -18,9 +18,13 @@ declare module 'bun:sqlite' {
|
|||||||
filename: string,
|
filename: string,
|
||||||
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||||
);
|
);
|
||||||
query(sql: string): Statement;
|
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||||
prepare(sql: string): Statement;
|
sql: string,
|
||||||
|
): Statement<ReturnType, ParamsType>;
|
||||||
|
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||||
|
sql: string,
|
||||||
|
): Statement<ReturnType, ParamsType>;
|
||||||
run(sql: string, ...params: unknown[]): RunResult;
|
run(sql: string, ...params: unknown[]): RunResult;
|
||||||
close(): void;
|
close(throwOnError?: boolean): void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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<string, unknown>)
|
||||||
|
: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
||||||
|
try {
|
||||||
|
return readHistoryRows(dbPath, { readonly: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
|
||||||
|
return readHistoryRows(dbPath, { 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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 readHistoryRows(
|
||||||
|
dbPath: string,
|
||||||
|
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||||
|
): HistoryVideoRow[] {
|
||||||
|
const db = new Database(dbPath, options);
|
||||||
|
try {
|
||||||
|
const rows = db
|
||||||
|
.query<RawHistoryRow>(
|
||||||
|
`
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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<string, HistorySeriesEntry>();
|
||||||
|
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
||||||
|
|
||||||
|
for (const row of sorted) {
|
||||||
|
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
||||||
|
if (byRoot.has(seriesRoot)) continue;
|
||||||
|
if (!existsFn(seriesRoot)) continue;
|
||||||
|
const displayName =
|
||||||
|
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
||||||
|
byRoot.set(seriesRoot, { seriesRoot, displayName, lastWatched: row });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface HistoryVideoRow {
|
||||||
|
videoId: number;
|
||||||
|
sourcePath: string;
|
||||||
|
parsedTitle: string | null;
|
||||||
|
parsedSeason: number | null;
|
||||||
|
parsedEpisode: number | null;
|
||||||
|
animeTitle: string | null;
|
||||||
|
lastWatchedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistorySeriesEntry {
|
||||||
|
seriesRoot: string;
|
||||||
|
displayName: string;
|
||||||
|
lastWatched: HistoryVideoRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonDirEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
season: number | null;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite';
|
|||||||
import {
|
import {
|
||||||
findNextEpisode,
|
findNextEpisode,
|
||||||
groupHistoryBySeries,
|
groupHistoryBySeries,
|
||||||
|
isReadonlyWalRetryError,
|
||||||
listSeasonDirs,
|
listSeasonDirs,
|
||||||
queryLocalWatchHistory,
|
queryLocalWatchHistory,
|
||||||
resolveSeriesRoot,
|
resolveSeriesRoot,
|
||||||
@@ -182,6 +183,18 @@ test('findNextEpisode falls back to episode numbers when file was removed', () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function createHistoryDb(dbPath: string, options: { wal?: boolean } = {}): void {
|
function createHistoryDb(dbPath: string, options: { wal?: boolean } = {}): void {
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
try {
|
try {
|
||||||
@@ -269,3 +282,38 @@ test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
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(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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+3
-232
@@ -1,232 +1,3 @@
|
|||||||
import fs from 'node:fs';
|
export * from './history-db.js';
|
||||||
import os from 'node:os';
|
export * from './history-navigation.js';
|
||||||
import path from 'node:path';
|
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||||
import { Database } from 'bun:sqlite';
|
|
||||||
import { parseMediaInfo } from '../src/jimaku/utils.js';
|
|
||||||
import { resolveConfigDir } from '../src/config/path-resolution.js';
|
|
||||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
|
||||||
import { collectVideos } from './picker.js';
|
|
||||||
import { resolvePathMaybe } from './util.js';
|
|
||||||
|
|
||||||
export interface HistoryVideoRow {
|
|
||||||
videoId: number;
|
|
||||||
sourcePath: string;
|
|
||||||
parsedTitle: string | null;
|
|
||||||
parsedSeason: number | null;
|
|
||||||
parsedEpisode: number | null;
|
|
||||||
animeTitle: string | null;
|
|
||||||
lastWatchedMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistorySeriesEntry {
|
|
||||||
seriesRoot: string;
|
|
||||||
displayName: string;
|
|
||||||
lastWatched: HistoryVideoRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeasonDirEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
season: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 resolveImmersionDbPath(): string {
|
|
||||||
const root = readLauncherMainConfigObject();
|
|
||||||
const tracking =
|
|
||||||
root?.immersionTracking &&
|
|
||||||
typeof root.immersionTracking === 'object' &&
|
|
||||||
!Array.isArray(root.immersionTracking)
|
|
||||||
? (root.immersionTracking as Record<string, unknown>)
|
|
||||||
: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
|
||||||
try {
|
|
||||||
return readHistoryRows(dbPath, { readonly: true });
|
|
||||||
} catch {
|
|
||||||
// The database uses WAL mode; a read-only connection cannot recreate the
|
|
||||||
// -wal/-shm files after the app closed them, so retry with a read-write
|
|
||||||
// handle (still only running SELECTs).
|
|
||||||
return readHistoryRows(dbPath, { readwrite: true, create: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readHistoryRows(
|
|
||||||
dbPath: string,
|
|
||||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
|
||||||
): HistoryVideoRow[] {
|
|
||||||
const db = new Database(dbPath, options);
|
|
||||||
try {
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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() as RawHistoryRow[];
|
|
||||||
|
|
||||||
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),
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupHistoryBySeries(
|
|
||||||
rows: HistoryVideoRow[],
|
|
||||||
existsFn: (candidate: string) => boolean = fs.existsSync,
|
|
||||||
): HistorySeriesEntry[] {
|
|
||||||
const byRoot = new Map<string, HistorySeriesEntry>();
|
|
||||||
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
|
||||||
|
|
||||||
for (const row of sorted) {
|
|
||||||
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
|
||||||
if (byRoot.has(seriesRoot)) continue;
|
|
||||||
if (!existsFn(seriesRoot)) continue;
|
|
||||||
const displayName =
|
|
||||||
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
|
||||||
byRoot.set(seriesRoot, { seriesRoot, displayName, lastWatched: row });
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
// Last-watched file no longer exists: fall back to the first episode with
|
|
||||||
// a higher parsed episode number in the same directory.
|
|
||||||
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 null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// End of the season: continue with the first episode of the next season.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user