feat(launcher): add -H/--history command to browse local watch history

- New `subminer -H` flag opens an fzf/rofi menu of watched series from the immersion tracker DB
- Actions: replay last episode, play next episode (cross-season), or browse episodes
- Adds `history.ts` with DB query, series grouping, season dir listing, and next-episode logic
- Adds `bun-sqlite.d.ts` ambient types for tsc compat
- Includes full test coverage in `history.test.ts`
This commit is contained in:
2026-07-05 00:29:52 -07:00
parent 73af1451b7
commit 29e2a266e2
15 changed files with 777 additions and 1 deletions
+1
View File
@@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
subminer video.mkv # launch mpv with SubMiner
subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer -H # browse local watch history (replay / next episode / browse)
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
+18
View File
@@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
```
## Watch History
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
```bash
subminer -H # fzf history browser
subminer -R -H # rofi history browser
```
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
- **Replay last watched** — replays the most recently watched episode
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
## Common Commands
```bash
@@ -105,6 +122,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| `--setup` | Open first-run setup popup manually |
| `-v, --version` | Print installed SubMiner version |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
+26
View File
@@ -0,0 +1,26 @@
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
// (see the build banner in package.json), but the repo typechecks with plain
// tsc which has no bun type definitions.
declare module 'bun:sqlite' {
export interface RunResult {
changes: number;
lastInsertRowid: number | bigint;
}
export interface Statement {
all(...params: unknown[]): unknown[];
get(...params: unknown[]): unknown;
run(...params: unknown[]): RunResult;
}
export class Database {
constructor(
filename: string,
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
);
query(sql: string): Statement;
prepare(sql: string): Statement;
run(sql: string, ...params: unknown[]): RunResult;
close(): void;
}
}
+197
View File
@@ -0,0 +1,197 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fail, log } from '../log.js';
import { commandExists } from '../util.js';
import {
collectVideos,
findRofiTheme,
formatPickerLaunchError,
showFzfMenu,
showRofiMenu,
} from '../picker.js';
import {
findNextEpisode,
groupHistoryBySeries,
listSeasonDirs,
queryLocalWatchHistory,
resolveImmersionDbPath,
sortVideosByEpisode,
type HistorySeriesEntry,
} from '../history.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
return;
}
if (!commandExists('fzf')) fail('Missing dependency: fzf');
}
function showRofiIndexMenu(labels: string[], prompt: string, themePath: string | null): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
const result = spawnSync('rofi', rofiArgs, {
input: `${labels.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
const out = (result.stdout || '').trim();
if (!out) return -1;
const idx = Number.parseInt(out, 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function showFzfIndexMenu(labels: string[], prompt: string): number {
const lines = labels.map((label, index) => `${index}\t${label}`);
const result = spawnSync(
'fzf',
[
'--ansi',
'--reverse',
'--ignore-case',
`--prompt=${prompt}: `,
'--delimiter=\t',
'--with-nth=2..',
],
{
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
},
);
if (result.error) {
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
const picked = (result.stdout || '').trim();
const tab = picked.indexOf('\t');
if (tab === -1) return -1;
const idx = Number.parseInt(picked.slice(0, tab), 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function pickIndex(
labels: string[],
prompt: string,
useRofi: boolean,
themePath: string | null,
): number {
if (labels.length === 0) return -1;
return useRofi ? showRofiIndexMenu(labels, prompt, themePath) : showFzfIndexMenu(labels, prompt);
}
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
const { parsedSeason, parsedEpisode } = entry.lastWatched;
if (parsedEpisode === null) return '';
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
}
function formatSeriesLabel(entry: HistorySeriesEntry): string {
const episodeLabel = formatEpisodeLabel(entry);
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
}
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
const { args, scriptPath } = context;
const videos = sortVideosByEpisode(collectVideos(dir, false));
if (videos.length === 0) {
fail(`No video files found in: ${dir}`);
}
const selected = args.useRofi
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
: showFzfMenu(videos);
return selected || null;
}
function browseEpisodes(
entry: HistorySeriesEntry,
context: LauncherCommandContext,
themePath: string | null,
): string | null {
const { args } = context;
const seasons = listSeasonDirs(entry.seriesRoot);
let dir = entry.seriesRoot;
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} — Season`,
args.useRofi,
themePath,
);
if (idx < 0) return null;
dir = seasons[idx]!.path;
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
dir = seasons[0]!.path;
}
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
const dbPath = resolveImmersionDbPath();
if (!fs.existsSync(dbPath)) {
fail(`Watch history database not found: ${dbPath}`);
}
const rows = queryLocalWatchHistory(dbPath);
const series = groupHistoryBySeries(rows);
if (series.length === 0) {
fail('No local watch history found (or watched directories are not accessible).');
}
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
const seriesIdx = pickIndex(
series.map(formatSeriesLabel),
'Watch History',
args.useRofi,
themePath,
);
if (seriesIdx < 0) return null;
const entry = series[seriesIdx]!;
const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
}
if (nextEpisode) {
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
args.useRofi,
themePath,
);
if (actionIdx < 0) return null;
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
}
}
@@ -35,6 +35,7 @@ function createContext(): LauncherCommandContext {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+2
View File
@@ -198,6 +198,7 @@ export function createDefaultArgs(
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
@@ -231,6 +232,7 @@ export function applyRootOptionsToArgs(
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true;
if (options.history === true) parsed.history = true;
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.settings === true) parsed.settings = true;
+1
View File
@@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void {
.option('--settings', 'Open settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
}
+271
View File
@@ -0,0 +1,271 @@
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 {
findNextEpisode,
groupHistoryBySeries,
listSeasonDirs,
queryLocalWatchHistory,
resolveSeriesRoot,
seasonNumberFromDirName,
sortVideosByEpisode,
type HistoryVideoRow,
} from './history.js';
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
return {
videoId: 1,
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
parsedTitle: 'Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: null,
lastWatchedMs: 1000,
...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 });
}
});
function createHistoryDb(dbPath: string, options: { wal?: 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.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);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
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 });
}
});
+232
View File
@@ -0,0 +1,232 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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;
}
+1
View File
@@ -29,6 +29,7 @@ function createArgs(): Args {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+11
View File
@@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runHistoryCommand } from './commands/history-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
@@ -142,6 +143,16 @@ async function main(): Promise<void> {
return;
}
if (appContext.args.history) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
appContext.args.target = selected;
appContext.args.targetKind = 'file';
}
await runPlaybackCommand(appContext);
}
+1
View File
@@ -570,6 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
logLevel: 'error',
logRotation: 7,
passwordStore: '',
+13
View File
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
assert.equal(parsed.settings, true);
});
test('parseArgs maps root watch history flags', () => {
const shortParsed = parseArgs(['-H'], 'subminer', {});
const longParsed = parseArgs(['--history'], 'subminer', {});
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
const defaultParsed = parseArgs([], 'subminer', {});
assert.equal(shortParsed.history, true);
assert.equal(longParsed.history, true);
assert.equal(rofiParsed.history, true);
assert.equal(rofiParsed.useRofi, true);
assert.equal(defaultParsed.history, false);
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {});
+1
View File
@@ -112,6 +112,7 @@ export interface Args {
texthookerOnly: boolean;
texthookerOpenBrowser: boolean;
useRofi: boolean;
history: boolean;
logLevel: LogLevel;
logRotation: LogRotation;
passwordStore: string;
+1 -1
View File
File diff suppressed because one or more lines are too long