mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(launcher): add post-playback history menu with previous episode (#170)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
+9
-7
@@ -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) {
|
||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||
return;
|
||||
}
|
||||
appContext.args.target = 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;
|
||||
}
|
||||
|
||||
await runPlaybackCommand(appContext);
|
||||
|
||||
Reference in New Issue
Block a user