mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
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:
@@ -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 video.mkv # launch mpv with SubMiner
|
||||||
subminer /path/to/dir # pick a file with fzf
|
subminer /path/to/dir # pick a file with fzf
|
||||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
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.
|
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
|
|||||||
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
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
|
## Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -105,6 +122,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
| `-d, --directory` | Video search directory (default: cwd) |
|
||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-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 |
|
| `--setup` | Open first-run setup popup manually |
|
||||||
| `-v, --version` | Print installed SubMiner version |
|
| `-v, --version` | Print installed SubMiner version |
|
||||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||||
|
|||||||
Vendored
+26
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
texthookerOnly: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
|
history: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export function createDefaultArgs(
|
|||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
|
history: false,
|
||||||
logLevel: loggingConfig.level ?? 'warn',
|
logLevel: loggingConfig.level ?? 'warn',
|
||||||
logRotation: loggingConfig.rotation ?? 7,
|
logRotation: loggingConfig.rotation ?? 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
@@ -231,6 +232,7 @@ export function applyRootOptionsToArgs(
|
|||||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||||
if (options.rofi === true) parsed.useRofi = true;
|
if (options.rofi === true) parsed.useRofi = true;
|
||||||
|
if (options.history === true) parsed.history = true;
|
||||||
if (options.update === true) parsed.update = true;
|
if (options.update === true) parsed.update = true;
|
||||||
if (options.version === true) parsed.version = true;
|
if (options.version === true) parsed.version = true;
|
||||||
if (options.settings === true) parsed.settings = true;
|
if (options.settings === true) parsed.settings = true;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void {
|
|||||||
.option('--settings', 'Open settings window')
|
.option('--settings', 'Open settings window')
|
||||||
.option('-u, --update', 'Check for updates')
|
.option('-u, --update', 'Check for updates')
|
||||||
.option('-R, --rofi', 'Use rofi picker')
|
.option('-R, --rofi', 'Use rofi picker')
|
||||||
|
.option('-H, --history', 'Browse local watch history')
|
||||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ function createArgs(): Args {
|
|||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
|
history: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
|
|||||||
import { runLogsCommand } from './commands/logs-command.js';
|
import { runLogsCommand } from './commands/logs-command.js';
|
||||||
import { runStatsCommand } from './commands/stats-command.js';
|
import { runStatsCommand } from './commands/stats-command.js';
|
||||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||||
|
import { runHistoryCommand } from './commands/history-command.js';
|
||||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||||
import { runUpdateCommand } from './commands/update-command.js';
|
import { runUpdateCommand } from './commands/update-command.js';
|
||||||
|
|
||||||
@@ -142,6 +143,16 @@ async function main(): Promise<void> {
|
|||||||
return;
|
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);
|
await runPlaybackCommand(appContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -570,6 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
|
history: false,
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
|
|||||||
assert.equal(parsed.settings, true);
|
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', () => {
|
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||||
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
||||||
const longParsed = parseArgs(['--update'], 'subminer', {});
|
const longParsed = parseArgs(['--update'], 'subminer', {});
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface Args {
|
|||||||
texthookerOnly: boolean;
|
texthookerOnly: boolean;
|
||||||
texthookerOpenBrowser: boolean;
|
texthookerOpenBrowser: boolean;
|
||||||
useRofi: boolean;
|
useRofi: boolean;
|
||||||
|
history: boolean;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
logRotation: LogRotation;
|
logRotation: LogRotation;
|
||||||
passwordStore: string;
|
passwordStore: string;
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user