mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
Compare commits
6 Commits
deae61b211
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
08c6807cb1
|
|||
|
18c6410f24
|
|||
|
e223cf9b71
|
|||
|
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": "4.3.0",
|
||||
"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@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
|
||||
"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,5 @@
|
||||
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.
|
||||
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
|
||||
@@ -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,13 @@ 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
|
||||
- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts
|
||||
- **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
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
collectVideos,
|
||||
findRofiTheme,
|
||||
formatPickerLaunchError,
|
||||
formatRofiPrompt,
|
||||
showFzfMenu,
|
||||
showRofiMenu,
|
||||
} from '../picker.js';
|
||||
import {
|
||||
findNextEpisode,
|
||||
findPreviousEpisode,
|
||||
groupHistoryBySeries,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
@@ -23,6 +25,167 @@ 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;
|
||||
}
|
||||
|
||||
export function buildHistoryEntryActions(
|
||||
lastWatchedPath: string | null,
|
||||
previousEpisodePath: string | null,
|
||||
nextEpisodePath: string | null,
|
||||
): HistorySessionMenuAction[] {
|
||||
const actions: HistorySessionMenuAction[] = [];
|
||||
if (previousEpisodePath) {
|
||||
actions.push({
|
||||
kind: 'previous',
|
||||
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
|
||||
});
|
||||
}
|
||||
if (lastWatchedPath) {
|
||||
actions.push({
|
||||
kind: 'replay',
|
||||
label: `Replay last watched: ${path.basename(lastWatchedPath)}`,
|
||||
});
|
||||
}
|
||||
if (nextEpisodePath) {
|
||||
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` });
|
||||
}
|
||||
actions.push(
|
||||
{ kind: 'browse', label: 'Browse episodes' },
|
||||
{ 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');
|
||||
@@ -37,7 +200,16 @@ function showRofiIndexMenu(
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||
const rofiArgs = [
|
||||
'-dmenu',
|
||||
'-i',
|
||||
'-matching',
|
||||
'fuzzy',
|
||||
'-format',
|
||||
'i',
|
||||
'-p',
|
||||
formatRofiPrompt(prompt),
|
||||
];
|
||||
const hasIcons = icons.some(Boolean);
|
||||
if (hasIcons) rofiArgs.push('-show-icons');
|
||||
if (themePath) {
|
||||
@@ -142,7 +314,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 +327,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);
|
||||
@@ -196,16 +370,14 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
|
||||
|
||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||
const lastExists = fs.existsSync(lastPath);
|
||||
const previousEpisode = findPreviousEpisode(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 actions = buildHistoryEntryActions(
|
||||
lastExists ? lastPath : null,
|
||||
previousEpisode,
|
||||
nextEpisode,
|
||||
);
|
||||
|
||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||
const actionIdx = pickIndex(
|
||||
@@ -219,10 +391,16 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
|
||||
|
||||
switch (actions[actionIdx]!.kind) {
|
||||
case 'replay':
|
||||
return lastPath;
|
||||
return { entry, videoPath: lastPath, themePath, entryIcon };
|
||||
case 'previous':
|
||||
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
|
||||
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 'quit':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildHistoryEntryActions,
|
||||
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 entry menu offers previous, replay, and next before playback starts', () => {
|
||||
assert.equal(
|
||||
typeof buildHistoryEntryActions,
|
||||
'function',
|
||||
'history entry actions not implemented',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildHistoryEntryActions(
|
||||
'/shows/test-show/episode-03.mkv',
|
||||
'/shows/test-show/episode-02.mkv',
|
||||
'/shows/test-show/episode-04.mkv',
|
||||
),
|
||||
[
|
||||
{ kind: 'previous', label: 'Previous episode: episode-02.mkv' },
|
||||
{ kind: 'replay', label: 'Replay last watched: episode-03.mkv' },
|
||||
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
|
||||
{ kind: 'browse', label: 'Browse episodes' },
|
||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('history entry menu omits replay when the last watched file is gone', () => {
|
||||
assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [
|
||||
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
|
||||
{ kind: 'browse', label: 'Browse episodes' },
|
||||
{ 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);
|
||||
|
||||
+16
-1
@@ -3,7 +3,22 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { findRofiTheme } from './picker';
|
||||
import { findRofiTheme, formatRofiPrompt } from './picker';
|
||||
|
||||
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
||||
|
||||
test('formatRofiPrompt appends a single trailing space', () => {
|
||||
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
|
||||
});
|
||||
|
||||
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
|
||||
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
|
||||
});
|
||||
|
||||
test('formatRofiPrompt leaves an empty prompt empty', () => {
|
||||
assert.equal(formatRofiPrompt(''), '');
|
||||
assert.equal(formatRofiPrompt(' '), '');
|
||||
});
|
||||
|
||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||
|
||||
|
||||
+13
-4
@@ -17,13 +17,22 @@ export function escapeShellSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rofi renders the prompt flush against the input field, so keep exactly one
|
||||
* trailing space to separate them.
|
||||
*/
|
||||
export function formatRofiPrompt(prompt: string): string {
|
||||
const trimmed = prompt.trimEnd();
|
||||
return trimmed ? `${trimmed} ` : '';
|
||||
}
|
||||
|
||||
export function showRofiFlatMenu(
|
||||
items: string[],
|
||||
prompt: string,
|
||||
initialQuery = '',
|
||||
themePath: string | null = null,
|
||||
): string {
|
||||
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
|
||||
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
|
||||
if (themePath) {
|
||||
args.push('-theme', themePath);
|
||||
} else {
|
||||
@@ -110,7 +119,7 @@ export async function promptOptionalJellyfinSearch(
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
if (useRofi && commandExists('rofi')) {
|
||||
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
|
||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
} else {
|
||||
@@ -157,7 +166,7 @@ function showRofiIconMenu(
|
||||
themePath: string | null = null,
|
||||
): number {
|
||||
if (entries.length === 0) return -1;
|
||||
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
|
||||
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
|
||||
if (initialQuery) rofiArgs.push('-filter', initialQuery);
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
@@ -391,7 +400,7 @@ export function showRofiMenu(
|
||||
'-dmenu',
|
||||
'-i',
|
||||
'-p',
|
||||
'Select Video ',
|
||||
formatRofiPrompt('Select Video'),
|
||||
'-show-icons',
|
||||
'-theme-str',
|
||||
'configuration { font: "Noto Sans CJK JP Regular 8";}',
|
||||
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.0-beta.1",
|
||||
"version": "0.19.0-beta.3",
|
||||
"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": "4.3.0",
|
||||
"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,29 +6,50 @@
|
||||
### 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 between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
|
||||
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
|
||||
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
|
||||
|
||||
- **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.
|
||||
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
|
||||
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
|
||||
|
||||
- **Post-Playback History Menu**
|
||||
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
||||
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Clipboard-Video Shortcut**: The "append clipboard video to queue" shortcut is now configurable (`shortcuts.appendClipboardVideoToQueue`) instead of fixed.
|
||||
- **Clipboard-Video Shortcut**
|
||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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.
|
||||
- **Word Highlighting Accuracy**
|
||||
- Fixed several incorrect word highlighting and annotation cases: 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 skipped for next-level ("N+1") highlighting.
|
||||
|
||||
- **Startup Playback Pausing Too Early**
|
||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
||||
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
||||
|
||||
- **Linux AppImage Crash Notification on Quit**
|
||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
|
||||
|
||||
- **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