mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c718bb8b34
|
|||
|
177f7e5fd6
|
|||
| 20b50ec4e1 |
@@ -213,7 +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)
|
||||
subminer -H # browse history, then previous / replay / next / select / quit
|
||||
```
|
||||
|
||||
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||
@@ -238,7 +238,6 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [TsukiHime](https://tsukihime.org) | Release-track subtitle search and downloads (Animetosho successor) |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
|
||||
@@ -36,12 +36,15 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.8.2",
|
||||
"brace-expansion": "5.0.7",
|
||||
"electron-builder-squirrel-windows": "26.8.2",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "5.2.1",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.3",
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.16",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
},
|
||||
"packages": {
|
||||
@@ -273,7 +276,7 @@
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||
|
||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
@@ -515,7 +518,7 @@
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
@@ -537,7 +540,7 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
"js-yaml": ["js-yaml@5.2.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
@@ -765,7 +768,7 @@
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
|
||||
"tar": ["tar@7.5.21", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA=="],
|
||||
|
||||
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
|
||||
@@ -1,4 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a TsukiHime integration for downloading primary and secondary subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openTsukihime`) opens an in-overlay modal with a Japanese primary tab and a secondary tab that follows `secondarySub.secondarySubLanguages`. It parses the current video filename, searches TsukiHime releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads Japanese into mpv's primary slot or configured secondary tracks into its secondary slot. TsukiHime carries the Animetosho index and mirrors its attachment storage, so older releases stay reachable. No API key is required; also reachable via `subminer --open-tsukihime`, the `__tsukihime-open` keybinding command, and configurable under a new `tsukihime` config section.
|
||||
- Added TsukiHime subtitle downloads for the current video, with Japanese primary and configured secondary-language tracks loaded directly into mpv.
|
||||
|
||||
@@ -73,9 +73,12 @@ 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
|
||||
- **Replay last watched**: replays the most recently watched episode
|
||||
- **Next episode**: plays the episode after the last watched one and 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 appears first
|
||||
- **Quit SubMiner**: closes the history session without starting an episode
|
||||
|
||||
After an episode ends or you close mpv, the launcher returns to an action menu for the same series. The menu lists Previous, Rewatch, Next, Select episode, and Quit SubMiner in that order, omitting Previous or Next when no episode exists in that direction. Choosing Previous or Next can move between season directories. After you play another episode, Previous, Rewatch, and Next use it instead of the older database entry. Pressing Escape closes the history session.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ subminer --start video.mkv # Explicit overlay start (use when mpv.autoSta
|
||||
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
|
||||
subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||
subminer -H # Browse watch history (replay/continue episodes, fzf or rofi picker)
|
||||
subminer -H # Browse history, then choose previous/replay/next after playback
|
||||
subminer app --setup # Open first-run setup popup
|
||||
subminer --version # Print the launcher's version
|
||||
subminer -v # Same as above
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../picker.js';
|
||||
import {
|
||||
findNextEpisode,
|
||||
findPreviousEpisode,
|
||||
groupHistoryBySeries,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
@@ -23,6 +24,139 @@ import {
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
|
||||
|
||||
export interface HistoryPlaybackSelection {
|
||||
entry: HistorySeriesEntry;
|
||||
videoPath: string;
|
||||
themePath?: string | null;
|
||||
entryIcon?: string | null;
|
||||
}
|
||||
|
||||
interface HistorySessionMenuAction {
|
||||
kind: HistorySessionAction;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function buildHistorySessionActions(
|
||||
justPlayedPath: string,
|
||||
previousEpisodePath: string | null,
|
||||
nextEpisodePath: string | null,
|
||||
): HistorySessionMenuAction[] {
|
||||
const actions: HistorySessionMenuAction[] = [];
|
||||
if (previousEpisodePath) {
|
||||
actions.push({
|
||||
kind: 'previous',
|
||||
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
kind: 'replay',
|
||||
label: `Rewatch episode: ${path.basename(justPlayedPath)}`,
|
||||
});
|
||||
if (nextEpisodePath) {
|
||||
actions.push({
|
||||
kind: 'next',
|
||||
label: `Play next episode: ${path.basename(nextEpisodePath)}`,
|
||||
});
|
||||
}
|
||||
actions.push(
|
||||
{ kind: 'browse', label: 'Select / browse episode' },
|
||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
interface HistoryPlaybackLoopDeps {
|
||||
play: (videoPath: string) => Promise<void>;
|
||||
pickPostPlaybackAction: (input: {
|
||||
entry: HistorySeriesEntry;
|
||||
justPlayedPath: string;
|
||||
previousEpisodePath: string | null;
|
||||
nextEpisodePath: string | null;
|
||||
}) => Promise<HistorySessionAction | null>;
|
||||
findPreviousEpisode: (videoPath: string) => string | null;
|
||||
findNextEpisode: (videoPath: string) => string | null;
|
||||
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export async function runHistoryPlaybackLoop(
|
||||
initial: HistoryPlaybackSelection,
|
||||
deps: HistoryPlaybackLoopDeps,
|
||||
): Promise<void> {
|
||||
let videoPath = initial.videoPath;
|
||||
|
||||
while (true) {
|
||||
await deps.play(videoPath);
|
||||
const previousEpisodePath = deps.findPreviousEpisode(videoPath);
|
||||
const nextEpisodePath = deps.findNextEpisode(videoPath);
|
||||
const action = await deps.pickPostPlaybackAction({
|
||||
entry: initial.entry,
|
||||
justPlayedPath: videoPath,
|
||||
previousEpisodePath,
|
||||
nextEpisodePath,
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case 'replay':
|
||||
break;
|
||||
case 'previous':
|
||||
if (!previousEpisodePath) return;
|
||||
videoPath = previousEpisodePath;
|
||||
break;
|
||||
case 'next':
|
||||
if (!nextEpisodePath) return;
|
||||
videoPath = nextEpisodePath;
|
||||
break;
|
||||
case 'browse': {
|
||||
const browsedPath = await deps.browseEpisodes(initial.entry);
|
||||
if (!browsedPath) return;
|
||||
videoPath = browsedPath;
|
||||
break;
|
||||
}
|
||||
case 'quit':
|
||||
case null:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runHistorySession(
|
||||
context: LauncherCommandContext,
|
||||
play: (videoPath: string) => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
const initial = await runHistoryCommand(context);
|
||||
if (!initial) return false;
|
||||
|
||||
await runHistoryPlaybackLoop(initial, {
|
||||
play,
|
||||
findPreviousEpisode,
|
||||
findNextEpisode,
|
||||
browseEpisodes: async (entry) => browseEpisodes(entry, context, initial.themePath ?? null),
|
||||
pickPostPlaybackAction: async ({
|
||||
entry,
|
||||
justPlayedPath,
|
||||
previousEpisodePath,
|
||||
nextEpisodePath,
|
||||
}) => {
|
||||
const actions = buildHistorySessionActions(
|
||||
justPlayedPath,
|
||||
previousEpisodePath,
|
||||
nextEpisodePath,
|
||||
);
|
||||
const actionIdx = pickIndex(
|
||||
actions.map((action) => action.label),
|
||||
entry.displayName,
|
||||
context.args.useRofi,
|
||||
initial.themePath ?? null,
|
||||
actions.map(() => initial.entryIcon ?? null),
|
||||
);
|
||||
return actionIdx < 0 ? null : actions[actionIdx]!.kind;
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkPickerDependencies(args: Args): void {
|
||||
if (args.useRofi) {
|
||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||
@@ -142,7 +276,7 @@ function browseEpisodes(
|
||||
if (seasons.length > 1) {
|
||||
const idx = pickIndex(
|
||||
seasons.map((season) => season.name),
|
||||
`${entry.displayName} — Season`,
|
||||
`${entry.displayName}: Season`,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
);
|
||||
@@ -155,7 +289,9 @@ function browseEpisodes(
|
||||
return pickEpisodeFromDir(dir, context);
|
||||
}
|
||||
|
||||
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
|
||||
export async function runHistoryCommand(
|
||||
context: LauncherCommandContext,
|
||||
): Promise<HistoryPlaybackSelection | null> {
|
||||
const { args, scriptPath } = context;
|
||||
|
||||
checkPickerDependencies(args);
|
||||
@@ -198,14 +334,15 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
|
||||
const lastExists = fs.existsSync(lastPath);
|
||||
const nextEpisode = findNextEpisode(lastPath);
|
||||
|
||||
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
|
||||
const actions: HistorySessionMenuAction[] = [];
|
||||
if (lastExists) {
|
||||
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
|
||||
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: 'next', label: `Next episode: ${path.basename(nextEpisode)}` });
|
||||
}
|
||||
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||
actions.push({ kind: 'quit', label: 'Quit SubMiner' });
|
||||
|
||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||
const actionIdx = pickIndex(
|
||||
@@ -219,10 +356,15 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
|
||||
|
||||
switch (actions[actionIdx]!.kind) {
|
||||
case 'replay':
|
||||
return lastPath;
|
||||
return { entry, videoPath: lastPath, themePath, entryIcon };
|
||||
case 'next':
|
||||
return nextEpisode;
|
||||
case 'browse':
|
||||
return browseEpisodes(entry, context, themePath);
|
||||
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
|
||||
case 'browse': {
|
||||
const videoPath = browseEpisodes(entry, context, themePath);
|
||||
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
|
||||
}
|
||||
case 'previous':
|
||||
case 'quit':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { buildHistorySessionActions, runHistoryPlaybackLoop } from './history-command.js';
|
||||
import type { HistorySeriesEntry } from '../history.js';
|
||||
|
||||
type HistoryLoop = (
|
||||
initial: { entry: HistorySeriesEntry; videoPath: string },
|
||||
deps: {
|
||||
play: (videoPath: string) => Promise<void>;
|
||||
pickPostPlaybackAction: (input: {
|
||||
entry: HistorySeriesEntry;
|
||||
justPlayedPath: string;
|
||||
previousEpisodePath: string | null;
|
||||
nextEpisodePath: string | null;
|
||||
}) => Promise<'previous' | 'replay' | 'next' | 'browse' | 'quit' | null>;
|
||||
findPreviousEpisode: (videoPath: string) => string | null;
|
||||
findNextEpisode: (videoPath: string) => string | null;
|
||||
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
|
||||
},
|
||||
) => Promise<void>;
|
||||
|
||||
const typedRunHistoryPlaybackLoop: HistoryLoop = runHistoryPlaybackLoop;
|
||||
|
||||
function makeEntry(lastWatchedPath: string): HistorySeriesEntry {
|
||||
return {
|
||||
seriesRoot: path.dirname(lastWatchedPath),
|
||||
displayName: 'Test Show',
|
||||
coverBlobHash: null,
|
||||
lastWatched: {
|
||||
videoId: 1,
|
||||
sourcePath: lastWatchedPath,
|
||||
parsedTitle: 'Test Show',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
animeTitle: 'Test Show',
|
||||
lastWatchedMs: 1,
|
||||
coverBlobHash: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('history loop plays an initial selection, browsed selection, then quits', async () => {
|
||||
assert.equal(
|
||||
typeof runHistoryPlaybackLoop,
|
||||
'function',
|
||||
'history playback loop is not implemented',
|
||||
);
|
||||
const entry = makeEntry('/shows/test-show/episode-01.mkv');
|
||||
const played: string[] = [];
|
||||
const menuEntries: HistorySeriesEntry[] = [];
|
||||
let menuCount = 0;
|
||||
|
||||
await typedRunHistoryPlaybackLoop(
|
||||
{ entry, videoPath: '/shows/test-show/episode-02.mkv' },
|
||||
{
|
||||
play: async (videoPath) => {
|
||||
played.push(videoPath);
|
||||
},
|
||||
pickPostPlaybackAction: async ({ entry: menuEntry }) => {
|
||||
menuEntries.push(menuEntry);
|
||||
return menuCount++ === 0 ? 'browse' : 'quit';
|
||||
},
|
||||
findPreviousEpisode: () => null,
|
||||
findNextEpisode: () => null,
|
||||
browseEpisodes: async (browseEntry) => {
|
||||
assert.equal(browseEntry, entry);
|
||||
return '/shows/test-show/episode-04.mkv';
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(played, ['/shows/test-show/episode-02.mkv', '/shows/test-show/episode-04.mkv']);
|
||||
assert.deepEqual(menuEntries, [entry, entry]);
|
||||
});
|
||||
|
||||
test('history replay uses the actual just-played path instead of the database row', async () => {
|
||||
assert.equal(
|
||||
typeof runHistoryPlaybackLoop,
|
||||
'function',
|
||||
'history playback loop is not implemented',
|
||||
);
|
||||
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
|
||||
const played: string[] = [];
|
||||
const menuPaths: string[] = [];
|
||||
let menuCount = 0;
|
||||
|
||||
await typedRunHistoryPlaybackLoop(
|
||||
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
|
||||
{
|
||||
play: async (videoPath) => {
|
||||
played.push(videoPath);
|
||||
},
|
||||
pickPostPlaybackAction: async ({ justPlayedPath }) => {
|
||||
menuPaths.push(justPlayedPath);
|
||||
return menuCount++ === 0 ? 'replay' : 'quit';
|
||||
},
|
||||
findPreviousEpisode: () => null,
|
||||
findNextEpisode: () => '/shows/test-show/episode-08.mkv',
|
||||
browseEpisodes: async () => null,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-07.mkv']);
|
||||
assert.deepEqual(menuPaths, [
|
||||
'/shows/test-show/episode-07.mkv',
|
||||
'/shows/test-show/episode-07.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('history next is computed from the actual just-played path', async () => {
|
||||
assert.equal(
|
||||
typeof runHistoryPlaybackLoop,
|
||||
'function',
|
||||
'history playback loop is not implemented',
|
||||
);
|
||||
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
|
||||
const played: string[] = [];
|
||||
const nextInputs: string[] = [];
|
||||
let menuCount = 0;
|
||||
|
||||
await typedRunHistoryPlaybackLoop(
|
||||
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
|
||||
{
|
||||
play: async (videoPath) => {
|
||||
played.push(videoPath);
|
||||
},
|
||||
pickPostPlaybackAction: async ({ nextEpisodePath }) => {
|
||||
if (menuCount++ === 0) {
|
||||
assert.equal(nextEpisodePath, '/shows/test-show/episode-08.mkv');
|
||||
return 'next';
|
||||
}
|
||||
assert.equal(nextEpisodePath, null);
|
||||
return 'quit';
|
||||
},
|
||||
findPreviousEpisode: () => null,
|
||||
findNextEpisode: (videoPath) => {
|
||||
nextInputs.push(videoPath);
|
||||
return videoPath.endsWith('episode-07.mkv') ? '/shows/test-show/episode-08.mkv' : null;
|
||||
},
|
||||
browseEpisodes: async () => null,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-08.mkv']);
|
||||
assert.deepEqual(nextInputs, [
|
||||
'/shows/test-show/episode-07.mkv',
|
||||
'/shows/test-show/episode-08.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('history show menu offers previous, rewatch, next, browse, and quit in order', () => {
|
||||
assert.equal(
|
||||
typeof buildHistorySessionActions,
|
||||
'function',
|
||||
'history session actions are not implemented',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildHistorySessionActions(
|
||||
'/shows/test-show/episode-07.mkv',
|
||||
'/shows/test-show/episode-06.mkv',
|
||||
'/shows/test-show/episode-08.mkv',
|
||||
),
|
||||
[
|
||||
{ kind: 'previous', label: 'Previous episode: episode-06.mkv' },
|
||||
{ kind: 'replay', label: 'Rewatch episode: episode-07.mkv' },
|
||||
{ kind: 'next', label: 'Play next episode: episode-08.mkv' },
|
||||
{ kind: 'browse', label: 'Select / browse episode' },
|
||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('history show menu omits previous and next when the just-played episode has neither', () => {
|
||||
assert.equal(
|
||||
typeof buildHistorySessionActions,
|
||||
'function',
|
||||
'history session actions are not implemented',
|
||||
);
|
||||
|
||||
assert.deepEqual(buildHistorySessionActions('/shows/test-show/finale.mkv', null, null), [
|
||||
{ kind: 'replay', label: 'Rewatch episode: finale.mkv' },
|
||||
{ kind: 'browse', label: 'Select / browse episode' },
|
||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
|
||||
assert.equal(
|
||||
typeof runHistoryPlaybackLoop,
|
||||
'function',
|
||||
'history playback loop is not implemented',
|
||||
);
|
||||
const entry = makeEntry('/shows/test-show/stale-episode-09.mkv');
|
||||
const played: string[] = [];
|
||||
const previousInputs: string[] = [];
|
||||
const previousSeenByMenu: Array<string | null> = [];
|
||||
let menuCount = 0;
|
||||
|
||||
await typedRunHistoryPlaybackLoop(
|
||||
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
|
||||
{
|
||||
play: async (videoPath) => {
|
||||
played.push(videoPath);
|
||||
},
|
||||
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
|
||||
previousSeenByMenu.push(previousEpisodePath);
|
||||
return menuCount++ === 0 ? 'previous' : 'quit';
|
||||
},
|
||||
findPreviousEpisode: (videoPath) => {
|
||||
previousInputs.push(videoPath);
|
||||
if (videoPath.endsWith('episode-07.mkv')) return '/shows/test-show/episode-06.mkv';
|
||||
if (videoPath.endsWith('episode-06.mkv')) return '/shows/test-show/episode-05.mkv';
|
||||
return null;
|
||||
},
|
||||
findNextEpisode: () => null,
|
||||
browseEpisodes: async () => null,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-06.mkv']);
|
||||
assert.deepEqual(previousInputs, [
|
||||
'/shows/test-show/episode-07.mkv',
|
||||
'/shows/test-show/episode-06.mkv',
|
||||
]);
|
||||
assert.deepEqual(previousSeenByMenu, [
|
||||
'/shows/test-show/episode-06.mkv',
|
||||
'/shows/test-show/episode-05.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('history playback loop stops advancing when previous is chosen with no prior episode', async () => {
|
||||
assert.equal(
|
||||
typeof runHistoryPlaybackLoop,
|
||||
'function',
|
||||
'history playback loop is not implemented',
|
||||
);
|
||||
const entry = makeEntry('/shows/test-show/episode-01.mkv');
|
||||
const played: string[] = [];
|
||||
|
||||
await typedRunHistoryPlaybackLoop(
|
||||
{ entry, videoPath: '/shows/test-show/episode-01.mkv' },
|
||||
{
|
||||
play: async (videoPath) => {
|
||||
played.push(videoPath);
|
||||
},
|
||||
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
|
||||
assert.equal(previousEpisodePath, null);
|
||||
return 'previous';
|
||||
},
|
||||
findPreviousEpisode: () => null,
|
||||
findNextEpisode: () => null,
|
||||
browseEpisodes: async () => null,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(played, ['/shows/test-show/episode-01.mkv']);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
import { state } from '../mpv.js';
|
||||
|
||||
function createContext(): LauncherCommandContext {
|
||||
@@ -103,6 +103,20 @@ function createContext(): LauncherCommandContext {
|
||||
};
|
||||
}
|
||||
|
||||
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
|
||||
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
|
||||
const context = createContext();
|
||||
const registeredSignals: NodeJS.Signals[] = [];
|
||||
context.processAdapter.onSignal = (signal) => {
|
||||
registeredSignals.push(signal);
|
||||
};
|
||||
|
||||
registerCleanup(context);
|
||||
registerCleanup(context);
|
||||
|
||||
assert.deepEqual(registeredSignals, ['SIGINT', 'SIGTERM']);
|
||||
});
|
||||
|
||||
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
|
||||
const calls: string[] = [];
|
||||
const context = createContext();
|
||||
|
||||
@@ -30,6 +30,7 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
const cleanupRegisteredProcessAdapters = new WeakSet<LauncherCommandContext['processAdapter']>();
|
||||
|
||||
function getLauncherConfigDir(): string {
|
||||
return getDefaultConfigDir({
|
||||
@@ -92,8 +93,10 @@ async function chooseTarget(
|
||||
return { target: selected, kind: 'file' };
|
||||
}
|
||||
|
||||
function registerCleanup(context: LauncherCommandContext): void {
|
||||
export function registerCleanup(context: LauncherCommandContext): void {
|
||||
const { args, processAdapter } = context;
|
||||
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
|
||||
|
||||
processAdapter.onSignal('SIGINT', () => {
|
||||
stopOverlay(args);
|
||||
processAdapter.exit(130);
|
||||
@@ -102,6 +105,7 @@ function registerCleanup(context: LauncherCommandContext): void {
|
||||
stopOverlay(args);
|
||||
processAdapter.exit(143);
|
||||
});
|
||||
cleanupRegisteredProcessAdapters.add(processAdapter);
|
||||
}
|
||||
|
||||
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
|
||||
|
||||
@@ -130,3 +130,46 @@ export function findNextEpisode(lastPath: string): string | null {
|
||||
|
||||
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
||||
}
|
||||
|
||||
function findLastEpisodeInPreviousSeason(resolvedCurrent: string, dir: string): string | null {
|
||||
const seriesRoot = resolveSeriesRoot(resolvedCurrent);
|
||||
if (seriesRoot === dir) return null;
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
|
||||
const currentSeason = seasonNumberFromDirName(path.basename(dir));
|
||||
const previousSeasonEntry =
|
||||
currentIdx >= 0
|
||||
? seasons[currentIdx - 1]
|
||||
: seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
currentSeason !== null && season.season !== null && season.season < currentSeason,
|
||||
)
|
||||
.at(-1);
|
||||
if (!previousSeasonEntry) return null;
|
||||
const previousSeason = sortVideosByEpisode(collectVideos(previousSeasonEntry.path, false));
|
||||
return previousSeason.at(-1) ?? null;
|
||||
}
|
||||
|
||||
export function findPreviousEpisode(currentPath: string): string | null {
|
||||
const resolvedCurrent = path.resolve(currentPath);
|
||||
const dir = path.dirname(resolvedCurrent);
|
||||
const episodes = sortVideosByEpisode(collectVideos(dir, false));
|
||||
const idx = episodes.indexOf(resolvedCurrent);
|
||||
|
||||
if (idx >= 0) {
|
||||
if (idx - 1 >= 0) return episodes[idx - 1]!;
|
||||
} else {
|
||||
const currentInfo = parseMediaInfo(resolvedCurrent);
|
||||
if (currentInfo.episode !== null) {
|
||||
const candidates = episodes.filter((episode) => {
|
||||
const info = parseMediaInfo(episode);
|
||||
return info.episode !== null && info.episode < currentInfo.episode!;
|
||||
});
|
||||
const candidate = candidates[candidates.length - 1];
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return findLastEpisodeInPreviousSeason(resolvedCurrent, dir);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
detectImageExtension,
|
||||
findNextEpisode,
|
||||
findPreviousEpisode,
|
||||
groupHistoryBySeries,
|
||||
isReadonlyWalRetryError,
|
||||
listSeasonDirs,
|
||||
@@ -198,6 +199,55 @@ test('findNextEpisode advances seasons when a deleted file was the last episode'
|
||||
}
|
||||
});
|
||||
|
||||
test('findPreviousEpisode steps back within a season and across seasons', () => {
|
||||
assert.equal(typeof findPreviousEpisode, 'function', 'findPreviousEpisode is not implemented');
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
|
||||
assert.equal(
|
||||
findPreviousEpisode(path.join(season1, 'Show - S01E03.mkv')),
|
||||
path.join(season1, 'Show - S01E02.mkv'),
|
||||
);
|
||||
assert.equal(
|
||||
findPreviousEpisode(path.join(season1, 'Show - S01E02.mkv')),
|
||||
path.join(season1, 'Show - S01E01.mkv'),
|
||||
);
|
||||
assert.equal(findPreviousEpisode(path.join(season1, 'Show - S01E01.mkv')), null);
|
||||
assert.equal(
|
||||
findPreviousEpisode(path.join(season2, 'Show - S02E01.mkv')),
|
||||
path.join(season1, 'Show - S01E03.mkv'),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findPreviousEpisode 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(findPreviousEpisode(missing), path.join(season1, 'Show - S01E01.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findPreviousEpisode falls back to prior season when a deleted file was the first episode', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
fs.rmSync(path.join(season2, 'Show - S02E01.mkv'));
|
||||
const missing = path.join(season2, 'Show - S02E01 - Deleted Cut.mkv');
|
||||
assert.equal(findPreviousEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||
|
||||
function createHistoryDb(
|
||||
|
||||
+8
-6
@@ -21,7 +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 { runHistorySession } from './commands/history-command.js';
|
||||
import { runSyncCommand } from './commands/sync-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
@@ -149,13 +149,15 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (appContext.args.history) {
|
||||
const selected = await runHistoryCommand(appContext);
|
||||
if (!selected) {
|
||||
const played = await runHistorySession(appContext, async (videoPath) => {
|
||||
appContext.args.target = videoPath;
|
||||
appContext.args.targetKind = 'file';
|
||||
await runPlaybackCommand(appContext);
|
||||
});
|
||||
if (!played) {
|
||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||
return;
|
||||
}
|
||||
appContext.args.target = selected;
|
||||
appContext.args.targetKind = 'file';
|
||||
return;
|
||||
}
|
||||
|
||||
await runPlaybackCommand(appContext);
|
||||
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.0-beta.1",
|
||||
"version": "0.19.0-beta.2",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -83,12 +83,15 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.8.2",
|
||||
"brace-expansion": "5.0.7",
|
||||
"electron-builder-squirrel-windows": "26.8.2",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "5.2.1",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.3",
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.16",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
### Added
|
||||
|
||||
- **Sync Stats & History**
|
||||
- Keep mining stats and watch history in sync across machines over SSH, from a new **Sync Stats & History** window (tray menu) or the `subminer sync <host>` command.
|
||||
- Syncing merges data safely, so nothing is duplicated even if you sync the same machines repeatedly, and hosts with auto-sync enabled sync in the background on a schedule, reporting results as overlay notifications.
|
||||
- Manual database snapshots (create, merge, reveal, delete) cover one-off transfers, and Windows machines running the built-in OpenSSH Server can be used as sync remotes too. No setup beyond SSH access is required on the remote side.
|
||||
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync across machines over SSH.
|
||||
- Syncing is safe to repeat: data merges without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
|
||||
- Manual database snapshots (create, merge, reveal, delete) and connection testing cover one-off transfers, and Windows machines running the built-in OpenSSH Server work as sync remotes too; no setup beyond SSH access is needed on the remote side.
|
||||
|
||||
- **TsukiHime Subtitle Downloads**
|
||||
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow: `Ctrl+Shift+T` opens an in-overlay search modal with separate tabs for the primary and secondary languages.
|
||||
- Matching releases are found automatically from the video filename; the chosen subtitle downloads and loads straight into mpv, no API key required.
|
||||
|
||||
- **Post-Playback History Menu**
|
||||
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Clipboard-Video Shortcut**: The "append clipboard video to queue" shortcut is now configurable (`shortcuts.appendClipboardVideoToQueue`) instead of fixed.
|
||||
@@ -23,12 +26,16 @@
|
||||
- **Word Highlighting Accuracy**: Fixed several cases of incorrect word highlighting and annotations, including inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary being skipped for next-level ("N+1") highlighting.
|
||||
- **Startup Playback Pausing Too Early**: Fixed playback resuming before subtitle processing had finished warming up, which could briefly show untranslated subtitles right after opening a video, most noticeable when resuming mid-episode.
|
||||
- **Linux AppImage Crash Notification on Quit**: Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||
- **AnkiConnect Proxy Port Conflict**: Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
||||
- **Stats & Settings Reliability**: Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections; AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user