mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-01 06:12:07 -07:00
Add playlist browser overlay modal (#37)
This commit is contained in:
@@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
|
||||
removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex'];
|
||||
movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex'];
|
||||
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
|
||||
}
|
||||
|
||||
@@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
|
||||
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
@@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
|
||||
removePlaylistBrowserIndex: params.removePlaylistBrowserIndex,
|
||||
movePlaylistBrowserIndex: params.movePlaylistBrowserIndex,
|
||||
getImmersionTracker: params.getImmersionTracker,
|
||||
};
|
||||
}
|
||||
@@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
|
||||
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
|
||||
@@ -125,3 +125,54 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
|
||||
await handler();
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
|
||||
const calls: string[] = [];
|
||||
const attemptedKeys = new Set<string>();
|
||||
const mediaKey = '/tmp/video.mkv';
|
||||
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => mediaKey,
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => mediaKey,
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
|
||||
processNextAnilistRetryUpdate: async () => {
|
||||
attemptedKeys.add(attemptKey);
|
||||
calls.push('process-retry');
|
||||
return { ok: true, message: 'retry ok' };
|
||||
},
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
attemptedKeys.add(key);
|
||||
calls.push(`remember:${key}`);
|
||||
},
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(calls.includes('update'), false);
|
||||
assert.equal(calls.includes('enqueue'), false);
|
||||
assert.equal(calls.includes('mark-failure'), false);
|
||||
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']);
|
||||
});
|
||||
|
||||
@@ -165,6 +165,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
deps.setInFlight(true);
|
||||
try {
|
||||
await deps.processNextAnilistRetryUpdate();
|
||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||
if (!accessToken) {
|
||||
|
||||
@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
@@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
getPlaylistBrowserSnapshot: async () =>
|
||||
({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}) as never,
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
ankiJimakuDeps: {
|
||||
|
||||
@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
|
||||
@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
||||
triggerSubsyncFromConfig: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
|
||||
@@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
},
|
||||
openPlaylistBrowser: () => {
|
||||
calls.push('playlist-browser');
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
@@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
deps.triggerSubsyncFromConfig();
|
||||
deps.openRuntimeOptionsPalette();
|
||||
void deps.openYoutubeTrackPicker();
|
||||
void deps.openPlaylistBrowser();
|
||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||
deps.showMpvOsd('hello');
|
||||
deps.replayCurrentSubtitle();
|
||||
@@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
'subsync',
|
||||
'palette',
|
||||
'youtube-picker',
|
||||
'playlist-browser',
|
||||
'osd:hello',
|
||||
'replay',
|
||||
'next',
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
|
||||
46
src/main/runtime/playlist-browser-ipc.ts
Normal file
46
src/main/runtime/playlist-browser-ipc.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime';
|
||||
import {
|
||||
appendPlaylistBrowserFileRuntime,
|
||||
getPlaylistBrowserSnapshotRuntime,
|
||||
movePlaylistBrowserIndexRuntime,
|
||||
playPlaylistBrowserIndexRuntime,
|
||||
removePlaylistBrowserIndexRuntime,
|
||||
type PlaylistBrowserRuntimeDeps,
|
||||
} from './playlist-browser-runtime';
|
||||
|
||||
type PlaylistBrowserMainDeps = Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
| 'getPlaylistBrowserSnapshot'
|
||||
| 'appendPlaylistBrowserFile'
|
||||
| 'playPlaylistBrowserIndex'
|
||||
| 'removePlaylistBrowserIndex'
|
||||
| 'movePlaylistBrowserIndex'
|
||||
>;
|
||||
|
||||
export type PlaylistBrowserIpcRuntime = {
|
||||
playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps;
|
||||
playlistBrowserMainDeps: PlaylistBrowserMainDeps;
|
||||
};
|
||||
|
||||
export function createPlaylistBrowserIpcRuntime(
|
||||
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
|
||||
): PlaylistBrowserIpcRuntime {
|
||||
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
|
||||
getMpvClient,
|
||||
};
|
||||
|
||||
return {
|
||||
playlistBrowserRuntimeDeps,
|
||||
playlistBrowserMainDeps: {
|
||||
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
|
||||
appendPlaylistBrowserFile: (filePath: string) =>
|
||||
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
|
||||
playPlaylistBrowserIndex: (index: number) =>
|
||||
playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
|
||||
removePlaylistBrowserIndex: (index: number) =>
|
||||
removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
|
||||
movePlaylistBrowserIndex: (index: number, direction: 1 | -1) =>
|
||||
movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction),
|
||||
},
|
||||
};
|
||||
}
|
||||
28
src/main/runtime/playlist-browser-open.test.ts
Normal file
28
src/main/runtime/playlist-browser-open.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openPlaylistBrowser } from './playlist-browser-open';
|
||||
|
||||
test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const opened = openPlaylistBrowser({
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('prereqs');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('windows');
|
||||
},
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
|
||||
calls.push(`send:${channel}`);
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(runtimeOptions, {
|
||||
restoreOnModalClose: 'playlist-browser',
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]);
|
||||
});
|
||||
23
src/main/runtime/playlist-browser-open.ts
Normal file
23
src/main/runtime/playlist-browser-open.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
|
||||
const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser';
|
||||
|
||||
export function openPlaylistBrowser(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
}): boolean {
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, {
|
||||
restoreOnModalClose: PLAYLIST_BROWSER_MODAL,
|
||||
});
|
||||
}
|
||||
487
src/main/runtime/playlist-browser-runtime.test.ts
Normal file
487
src/main/runtime/playlist-browser-runtime.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
|
||||
import type { PlaylistBrowserQueueItem } from '../../types';
|
||||
import {
|
||||
appendPlaylistBrowserFileRuntime,
|
||||
getPlaylistBrowserSnapshotRuntime,
|
||||
movePlaylistBrowserIndexRuntime,
|
||||
playPlaylistBrowserIndexRuntime,
|
||||
removePlaylistBrowserIndexRuntime,
|
||||
} from './playlist-browser-runtime';
|
||||
|
||||
type FakePlaylistEntry = {
|
||||
current?: boolean;
|
||||
playing?: boolean;
|
||||
filename: string;
|
||||
title?: string;
|
||||
id?: number;
|
||||
};
|
||||
|
||||
function createTempVideoDir(t: TestContext): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
|
||||
t.after(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createFakeMpvClient(options: {
|
||||
currentVideoPath: string;
|
||||
playlist: FakePlaylistEntry[];
|
||||
connected?: boolean;
|
||||
}) {
|
||||
let playlist = options.playlist.map((item, index) => ({
|
||||
id: item.id ?? index + 1,
|
||||
current: item.current ?? false,
|
||||
playing: item.playing ?? item.current ?? false,
|
||||
filename: item.filename,
|
||||
title: item.title ?? null,
|
||||
}));
|
||||
const commands: Array<(string | number)[]> = [];
|
||||
|
||||
const syncFlags = (): void => {
|
||||
let playingIndex = playlist.findIndex((item) => item.current || item.playing);
|
||||
if (playingIndex < 0 && playlist.length > 0) {
|
||||
playingIndex = 0;
|
||||
}
|
||||
playlist = playlist.map((item, index) => ({
|
||||
...item,
|
||||
current: index === playingIndex,
|
||||
playing: index === playingIndex,
|
||||
}));
|
||||
};
|
||||
|
||||
syncFlags();
|
||||
|
||||
return {
|
||||
connected: options.connected ?? true,
|
||||
currentVideoPath: options.currentVideoPath,
|
||||
async requestProperty(name: string): Promise<unknown> {
|
||||
if (name === 'playlist') {
|
||||
return playlist;
|
||||
}
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return playlist.findIndex((item) => item.current || item.playing);
|
||||
}
|
||||
if (name === 'path') {
|
||||
return this.currentVideoPath;
|
||||
}
|
||||
throw new Error(`Unexpected property: ${name}`);
|
||||
},
|
||||
send(payload: { command: unknown[] }): boolean {
|
||||
const command = payload.command as (string | number)[];
|
||||
commands.push(command);
|
||||
const [action, first, second] = command;
|
||||
if (action === 'loadfile' && typeof first === 'string' && second === 'append') {
|
||||
playlist.push({
|
||||
id: playlist.length + 1,
|
||||
filename: first,
|
||||
title: null,
|
||||
current: false,
|
||||
playing: false,
|
||||
});
|
||||
syncFlags();
|
||||
return true;
|
||||
}
|
||||
if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) {
|
||||
playlist = playlist.map((item, index) => ({
|
||||
...item,
|
||||
current: index === first,
|
||||
playing: index === first,
|
||||
}));
|
||||
this.currentVideoPath = playlist[first]!.filename;
|
||||
return true;
|
||||
}
|
||||
if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) {
|
||||
const removingCurrent = playlist[first]!.current || playlist[first]!.playing;
|
||||
playlist.splice(first, 1);
|
||||
if (removingCurrent) {
|
||||
syncFlags();
|
||||
this.currentVideoPath =
|
||||
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
action === 'playlist-move' &&
|
||||
typeof first === 'number' &&
|
||||
typeof second === 'number' &&
|
||||
playlist[first]
|
||||
) {
|
||||
const [moved] = playlist.splice(first, 1);
|
||||
playlist.splice(second, 0, moved!);
|
||||
syncFlags();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getCommands(): Array<(string | number)[]> {
|
||||
return commands;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const special = path.join(dir, 'Show - Special.mp4');
|
||||
const ignored = path.join(dir, 'notes.txt');
|
||||
fs.writeFileSync(episode2, '');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(special, '');
|
||||
fs.writeFileSync(ignored, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode2,
|
||||
playlist: [
|
||||
{ filename: episode1, current: false, playing: false, title: 'Episode 1' },
|
||||
{ filename: episode2, current: true, playing: true, title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
|
||||
const snapshot = await getPlaylistBrowserSnapshotRuntime({
|
||||
getMpvClient: () => mpvClient,
|
||||
});
|
||||
|
||||
assert.equal(snapshot.directoryAvailable, true);
|
||||
assert.equal(snapshot.directoryPath, dir);
|
||||
assert.equal(snapshot.currentFilePath, episode2);
|
||||
assert.equal(snapshot.playingIndex, 1);
|
||||
assert.deepEqual(
|
||||
snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]),
|
||||
[
|
||||
['Show - S01E01.mkv', false],
|
||||
['Show - S01E02.mkv', true],
|
||||
['Show - Special.mp4', false],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot.playlistItems.map((item) => ({
|
||||
index: item.index,
|
||||
displayLabel: item.displayLabel,
|
||||
current: item.current,
|
||||
})),
|
||||
[
|
||||
{ index: 0, displayLabel: 'Episode 1', current: false },
|
||||
{ index: 1, displayLabel: 'Episode 2', current: true },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime clamps stale playing index to the playlist bounds', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true, playing: true, title: 'Episode 1' },
|
||||
{ filename: episode2, title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
|
||||
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return 99;
|
||||
}
|
||||
return requestProperty(name);
|
||||
};
|
||||
|
||||
const snapshot = await getPlaylistBrowserSnapshotRuntime({
|
||||
getMpvClient: () => mpvClient,
|
||||
});
|
||||
|
||||
assert.equal(snapshot.playingIndex, 1);
|
||||
});
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => {
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: 'https://example.com/video.m3u8',
|
||||
playlist: [{ filename: 'https://example.com/video.m3u8', current: true }],
|
||||
});
|
||||
|
||||
const snapshot = await getPlaylistBrowserSnapshotRuntime({
|
||||
getMpvClient: () => mpvClient,
|
||||
});
|
||||
|
||||
assert.equal(snapshot.directoryAvailable, false);
|
||||
assert.equal(snapshot.directoryItems.length, 0);
|
||||
assert.match(snapshot.directoryStatus, /local filesystem/i);
|
||||
assert.equal(snapshot.playlistItems.length, 1);
|
||||
});
|
||||
|
||||
test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
const episode3 = path.join(dir, 'Show - S01E03.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
fs.writeFileSync(episode3, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true, title: 'Episode 1' },
|
||||
{ filename: episode2, title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
|
||||
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
schedule: (callback: () => void, delayMs: number) => {
|
||||
scheduled.push({ callback, delayMs });
|
||||
},
|
||||
};
|
||||
|
||||
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
|
||||
assert.equal(appendResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']);
|
||||
assert.deepEqual(
|
||||
appendResult.snapshot?.playlistItems.map((item) => item.path),
|
||||
[episode1, episode2, episode3],
|
||||
);
|
||||
|
||||
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1);
|
||||
assert.equal(moveResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]);
|
||||
assert.deepEqual(
|
||||
moveResult.snapshot?.playlistItems.map((item) => item.path),
|
||||
[episode1, episode3, episode2],
|
||||
);
|
||||
|
||||
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||
assert.equal(playResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
]);
|
||||
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
||||
scheduled[0]?.callback();
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
]);
|
||||
assert.equal(playResult.snapshot?.playingIndex, 1);
|
||||
|
||||
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2);
|
||||
assert.equal(removeResult.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]);
|
||||
assert.deepEqual(
|
||||
removeResult.snapshot?.playlistItems.map((item) => item.path),
|
||||
[episode1, episode3],
|
||||
);
|
||||
});
|
||||
|
||||
test('playlist-browser mutation runtimes report MPV send rejection', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
const episode3 = path.join(dir, 'Show - S01E03.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
fs.writeFileSync(episode3, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true, title: 'Episode 1' },
|
||||
{ filename: episode2, title: 'Episode 2' },
|
||||
{ filename: episode3, title: 'Episode 3' },
|
||||
],
|
||||
});
|
||||
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
|
||||
mpvClient.send = () => false;
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
schedule: (callback: () => void, delayMs: number) => {
|
||||
scheduled.push({ callback, delayMs });
|
||||
},
|
||||
};
|
||||
|
||||
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
|
||||
assert.equal(appendResult.ok, false);
|
||||
assert.equal(appendResult.snapshot, undefined);
|
||||
|
||||
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||
assert.equal(playResult.ok, false);
|
||||
assert.equal(playResult.snapshot, undefined);
|
||||
assert.deepEqual(scheduled, []);
|
||||
|
||||
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 1);
|
||||
assert.equal(removeResult.ok, false);
|
||||
assert.equal(removeResult.snapshot, undefined);
|
||||
|
||||
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
|
||||
assert.equal(moveResult.ok, false);
|
||||
assert.equal(moveResult.snapshot, undefined);
|
||||
});
|
||||
|
||||
test('appendPlaylistBrowserFileRuntime returns an error result when statSync throws', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
|
||||
const mutableFs = fs as typeof fs & { statSync: typeof fs.statSync };
|
||||
const originalStatSync = mutableFs.statSync;
|
||||
mutableFs.statSync = ((targetPath: fs.PathLike) => {
|
||||
if (path.resolve(String(targetPath)) === episode1) {
|
||||
throw new Error('EACCES');
|
||||
}
|
||||
return originalStatSync(targetPath);
|
||||
}) as typeof fs.statSync;
|
||||
|
||||
try {
|
||||
const result = await appendPlaylistBrowserFileRuntime(
|
||||
{
|
||||
getMpvClient: () =>
|
||||
createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [{ filename: episode1, current: true }],
|
||||
}),
|
||||
},
|
||||
episode1,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
message: 'Playlist browser file is not readable.',
|
||||
});
|
||||
} finally {
|
||||
mutableFs.statSync = originalStatSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true },
|
||||
{ filename: episode2 },
|
||||
],
|
||||
});
|
||||
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
};
|
||||
|
||||
const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1);
|
||||
assert.deepEqual(moveUp, {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the top.',
|
||||
});
|
||||
|
||||
const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
|
||||
assert.deepEqual(moveDown, {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the bottom.',
|
||||
});
|
||||
});
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [{ filename: episode1, current: true, title: '' }],
|
||||
});
|
||||
|
||||
const snapshot = await getPlaylistBrowserSnapshotRuntime({
|
||||
getMpvClient: () => mpvClient,
|
||||
});
|
||||
|
||||
const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem;
|
||||
assert.equal(item.displayLabel, 'Show - S01E01.mkv');
|
||||
assert.equal(item.path, episode1);
|
||||
});
|
||||
|
||||
test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => {
|
||||
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: 'https://example.com/video-1.m3u8',
|
||||
playlist: [
|
||||
{ filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' },
|
||||
{ filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await playPlaylistBrowserIndexRuntime(
|
||||
{
|
||||
getMpvClient: () => mpvClient,
|
||||
schedule: (callback, delayMs) => {
|
||||
scheduled.push({ callback, delayMs });
|
||||
},
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]);
|
||||
assert.equal(scheduled.length, 0);
|
||||
});
|
||||
|
||||
test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm callbacks', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
const episode3 = path.join(dir, 'Show - S01E03.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
fs.writeFileSync(episode3, '');
|
||||
|
||||
const scheduled: Array<() => void> = [];
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true, title: 'Episode 1' },
|
||||
{ filename: episode2, title: 'Episode 2' },
|
||||
{ filename: episode3, title: 'Episode 3' },
|
||||
],
|
||||
});
|
||||
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
schedule: (callback: () => void) => {
|
||||
scheduled.push(callback);
|
||||
},
|
||||
};
|
||||
|
||||
const firstPlay = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||
const secondPlay = await playPlaylistBrowserIndexRuntime(deps, 2);
|
||||
|
||||
assert.equal(firstPlay.ok, true);
|
||||
assert.equal(secondPlay.ok, true);
|
||||
assert.equal(scheduled.length, 2);
|
||||
|
||||
scheduled[0]?.();
|
||||
scheduled[1]?.();
|
||||
|
||||
assert.deepEqual(
|
||||
mpvClient.getCommands().slice(-6),
|
||||
[
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 2],
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
],
|
||||
);
|
||||
});
|
||||
361
src/main/runtime/playlist-browser-runtime.ts
Normal file
361
src/main/runtime/playlist-browser-runtime.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
PlaylistBrowserDirectoryItem,
|
||||
PlaylistBrowserMutationResult,
|
||||
PlaylistBrowserQueueItem,
|
||||
PlaylistBrowserSnapshot,
|
||||
} from '../../types';
|
||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||
import { hasVideoExtension } from '../../shared/video-extensions';
|
||||
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
||||
|
||||
type PlaylistLike = {
|
||||
filename?: unknown;
|
||||
title?: unknown;
|
||||
id?: unknown;
|
||||
current?: unknown;
|
||||
playing?: unknown;
|
||||
};
|
||||
|
||||
type MpvPlaylistBrowserClientLike = {
|
||||
connected: boolean;
|
||||
currentVideoPath?: string | null;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
send: (payload: { command: unknown[]; request_id?: number }) => boolean;
|
||||
};
|
||||
|
||||
export type PlaylistBrowserRuntimeDeps = {
|
||||
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
|
||||
schedule?: (callback: () => void, delayMs: number) => void;
|
||||
};
|
||||
|
||||
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
|
||||
|
||||
function trimToNull(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
async function readProperty(
|
||||
client: MpvPlaylistBrowserClientLike | null,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!client?.requestProperty) return null;
|
||||
try {
|
||||
return await client.requestProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCurrentFilePath(
|
||||
client: MpvPlaylistBrowserClientLike | null,
|
||||
): Promise<string | null> {
|
||||
const currentVideoPath = trimToNull(client?.currentVideoPath);
|
||||
if (currentVideoPath) return currentVideoPath;
|
||||
return trimToNull(await readProperty(client, 'path'));
|
||||
}
|
||||
|
||||
function resolveDirectorySnapshot(
|
||||
currentFilePath: string | null,
|
||||
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
|
||||
if (!currentFilePath) {
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
directoryItems: [],
|
||||
directoryPath: null,
|
||||
directoryStatus: 'Current media path is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
if (isRemoteMediaPath(currentFilePath)) {
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
directoryItems: [],
|
||||
directoryPath: null,
|
||||
directoryStatus: 'Directory browser requires a local filesystem video.',
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(currentFilePath);
|
||||
const directoryPath = path.dirname(resolvedPath);
|
||||
try {
|
||||
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
||||
const videoPaths = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => hasVideoExtension(path.extname(name)))
|
||||
.map((name) => path.join(directoryPath, name));
|
||||
|
||||
const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems(
|
||||
videoPaths,
|
||||
).map((item) => ({
|
||||
...item,
|
||||
isCurrentFile: item.path === resolvedPath,
|
||||
}));
|
||||
|
||||
return {
|
||||
directoryAvailable: true,
|
||||
directoryItems,
|
||||
directoryPath,
|
||||
directoryStatus: directoryPath,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
directoryItems: [],
|
||||
directoryPath,
|
||||
directoryStatus: `Could not read parent directory: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((entry, index) => {
|
||||
const item = (entry ?? {}) as PlaylistLike;
|
||||
const filename = trimToNull(item.filename) ?? '';
|
||||
const title = trimToNull(item.title);
|
||||
const normalizedPath =
|
||||
filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename);
|
||||
return {
|
||||
index,
|
||||
id: typeof item.id === 'number' ? item.id : null,
|
||||
filename,
|
||||
title,
|
||||
displayLabel:
|
||||
title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`),
|
||||
current: item.current === true,
|
||||
playing: item.playing === true,
|
||||
path: normalizedPath,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConnectedClient(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
): MpvPlaylistBrowserClientLike | { ok: false; message: string } {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'MPV is not connected.',
|
||||
};
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function buildRejectedCommandResult(): PlaylistBrowserMutationResult {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Could not send command to MPV.',
|
||||
};
|
||||
}
|
||||
|
||||
async function getPlaylistItemsFromClient(
|
||||
client: MpvPlaylistBrowserClientLike | null,
|
||||
): Promise<PlaylistBrowserQueueItem[]> {
|
||||
return normalizePlaylistItems(await readProperty(client, 'playlist'));
|
||||
}
|
||||
|
||||
function resolvePlayingIndex(
|
||||
playlistItems: PlaylistBrowserQueueItem[],
|
||||
playingPosValue: unknown,
|
||||
): number | null {
|
||||
if (playlistItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)) {
|
||||
return Math.min(Math.max(playingPosValue, 0), playlistItems.length - 1);
|
||||
}
|
||||
const playingIndex = playlistItems.findIndex((item) => item.current || item.playing);
|
||||
return playingIndex >= 0 ? playingIndex : null;
|
||||
}
|
||||
|
||||
export async function getPlaylistBrowserSnapshotRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
): Promise<PlaylistBrowserSnapshot> {
|
||||
const client = deps.getMpvClient();
|
||||
const currentFilePath = await resolveCurrentFilePath(client);
|
||||
const [playlistItems, playingPosValue] = await Promise.all([
|
||||
getPlaylistItemsFromClient(client),
|
||||
readProperty(client, 'playlist-playing-pos'),
|
||||
]);
|
||||
|
||||
return {
|
||||
...resolveDirectorySnapshot(currentFilePath),
|
||||
playlistItems,
|
||||
playingIndex: resolvePlayingIndex(playlistItems, playingPosValue),
|
||||
currentFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function validatePlaylistIndex(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
): Promise<
|
||||
| { ok: false; message: string }
|
||||
| { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] }
|
||||
> {
|
||||
const client = ensureConnectedClient(deps);
|
||||
if ('ok' in client) {
|
||||
return client;
|
||||
}
|
||||
const playlistItems = await getPlaylistItemsFromClient(client);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist item not found.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
playlistItems,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMutationResult(
|
||||
message: string,
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
return {
|
||||
ok: true,
|
||||
message,
|
||||
snapshot: await getPlaylistBrowserSnapshotRuntime(deps),
|
||||
};
|
||||
}
|
||||
|
||||
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
|
||||
client.send({ command: ['set_property', 'sid', 'auto'] });
|
||||
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
|
||||
}
|
||||
|
||||
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
|
||||
client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] });
|
||||
}
|
||||
|
||||
function isLocalPlaylistItem(
|
||||
item: PlaylistBrowserQueueItem | null | undefined,
|
||||
): item is PlaylistBrowserQueueItem & { path: string } {
|
||||
return Boolean(item?.path && !isRemoteMediaPath(item.path));
|
||||
}
|
||||
|
||||
function scheduleLocalSubtitleSelectionRearm(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
client: MpvPlaylistBrowserClientLike,
|
||||
expectedPath: string,
|
||||
): void {
|
||||
const nextToken = (pendingLocalSubtitleSelectionRearms.get(client) ?? 0) + 1;
|
||||
pendingLocalSubtitleSelectionRearms.set(client, nextToken);
|
||||
(deps.schedule ?? setTimeout)(() => {
|
||||
if (pendingLocalSubtitleSelectionRearms.get(client) !== nextToken) return;
|
||||
pendingLocalSubtitleSelectionRearms.delete(client);
|
||||
const currentPath = trimToNull(client.currentVideoPath);
|
||||
if (currentPath && path.resolve(currentPath) !== expectedPath) {
|
||||
return;
|
||||
}
|
||||
rearmLocalSubtitleSelection(client);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
export async function appendPlaylistBrowserFileRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
filePath: string,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const client = ensureConnectedClient(deps);
|
||||
if ('ok' in client) {
|
||||
return client;
|
||||
}
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedPath);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist browser file is not readable.',
|
||||
};
|
||||
}
|
||||
if (!stats.isFile()) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist browser file is not readable.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!client.send({ command: ['loadfile', resolvedPath, 'append'] })) {
|
||||
return buildRejectedCommandResult();
|
||||
}
|
||||
return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps);
|
||||
}
|
||||
|
||||
export async function playPlaylistBrowserIndexRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const result = await validatePlaylistIndex(deps, index);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const targetItem = result.playlistItems[index] ?? null;
|
||||
if (isLocalPlaylistItem(targetItem)) {
|
||||
prepareLocalSubtitleAutoload(result.client);
|
||||
}
|
||||
if (!result.client.send({ command: ['playlist-play-index', index] })) {
|
||||
return buildRejectedCommandResult();
|
||||
}
|
||||
if (isLocalPlaylistItem(targetItem)) {
|
||||
scheduleLocalSubtitleSelectionRearm(deps, result.client, path.resolve(targetItem.path));
|
||||
}
|
||||
return buildMutationResult(`Playing playlist item ${index + 1}`, deps);
|
||||
}
|
||||
|
||||
export async function removePlaylistBrowserIndexRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const result = await validatePlaylistIndex(deps, index);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result.client.send({ command: ['playlist-remove', index] })) {
|
||||
return buildRejectedCommandResult();
|
||||
}
|
||||
return buildMutationResult(`Removed playlist item ${index + 1}`, deps);
|
||||
}
|
||||
|
||||
export async function movePlaylistBrowserIndexRuntime(
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
index: number,
|
||||
direction: -1 | 1,
|
||||
): Promise<PlaylistBrowserMutationResult> {
|
||||
const result = await validatePlaylistIndex(deps, index);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the top.',
|
||||
};
|
||||
}
|
||||
if (targetIndex >= result.playlistItems.length) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Playlist item is already at the bottom.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.client.send({ command: ['playlist-move', index, targetIndex] })) {
|
||||
return buildRejectedCommandResult();
|
||||
}
|
||||
return buildMutationResult(`Moved playlist item ${index + 1}`, deps);
|
||||
}
|
||||
50
src/main/runtime/playlist-browser-sort.test.ts
Normal file
50
src/main/runtime/playlist-browser-sort.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
||||
|
||||
test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => {
|
||||
const root = '/library/show';
|
||||
const items = sortPlaylistBrowserDirectoryItems([
|
||||
path.join(root, 'Show - S01E10.mkv'),
|
||||
path.join(root, 'Show - S01E02.mkv'),
|
||||
path.join(root, 'Show - S01E01.mkv'),
|
||||
path.join(root, 'Show - Episode 7.mkv'),
|
||||
path.join(root, 'Show - 01x03.mkv'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.basename),
|
||||
[
|
||||
'Show - S01E01.mkv',
|
||||
'Show - S01E02.mkv',
|
||||
'Show - 01x03.mkv',
|
||||
'Show - Episode 7.mkv',
|
||||
'Show - S01E10.mkv',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.episodeLabel),
|
||||
['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => {
|
||||
const root = '/library/show';
|
||||
const items = sortPlaylistBrowserDirectoryItems([
|
||||
path.join(root, 'Show Part 10.mkv'),
|
||||
path.join(root, 'Show Part 2.mkv'),
|
||||
path.join(root, 'Show Part 1.mkv'),
|
||||
path.join(root, 'Show Special.mkv'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.basename),
|
||||
['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.episodeLabel),
|
||||
[null, null, null, null],
|
||||
);
|
||||
});
|
||||
129
src/main/runtime/playlist-browser-sort.ts
Normal file
129
src/main/runtime/playlist-browser-sort.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from 'node:path';
|
||||
|
||||
type ParsedEpisodeKey = {
|
||||
season: number | null;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
type SortToken = string | number;
|
||||
|
||||
export type PlaylistBrowserSortedDirectoryItem = {
|
||||
path: string;
|
||||
basename: string;
|
||||
episodeLabel: string | null;
|
||||
};
|
||||
|
||||
const COLLATOR = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
function parseEpisodeKey(basename: string): ParsedEpisodeKey | null {
|
||||
const name = basename.replace(/\.[^.]+$/, '');
|
||||
const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i);
|
||||
if (seasonEpisode) {
|
||||
return {
|
||||
season: Number(seasonEpisode[1]),
|
||||
episode: Number(seasonEpisode[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i);
|
||||
if (seasonByX) {
|
||||
return {
|
||||
season: Number(seasonByX[1]),
|
||||
episode: Number(seasonByX[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const namedEpisode = name.match(
|
||||
/(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i,
|
||||
);
|
||||
if (namedEpisode) {
|
||||
return {
|
||||
season: null,
|
||||
episode: Number(namedEpisode[1]),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null {
|
||||
if (!parsed) return null;
|
||||
if (parsed.season !== null) {
|
||||
return `S${parsed.season}E${parsed.episode}`;
|
||||
}
|
||||
return `E${parsed.episode}`;
|
||||
}
|
||||
|
||||
function tokenizeNaturalSort(basename: string): SortToken[] {
|
||||
return basename
|
||||
.toLowerCase()
|
||||
.split(/(\d+)/)
|
||||
.filter((token) => token.length > 0)
|
||||
.map((token) => (/^\d+$/.test(token) ? Number(token) : token));
|
||||
}
|
||||
|
||||
function compareNaturalTokens(left: SortToken[], right: SortToken[]): number {
|
||||
const maxLength = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const a = left[index];
|
||||
const b = right[index];
|
||||
if (a === undefined) return -1;
|
||||
if (b === undefined) return 1;
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
if (a !== b) return a - b;
|
||||
continue;
|
||||
}
|
||||
const comparison = COLLATOR.compare(String(a), String(b));
|
||||
if (comparison !== 0) return comparison;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortPlaylistBrowserDirectoryItems(
|
||||
paths: string[],
|
||||
): PlaylistBrowserSortedDirectoryItem[] {
|
||||
return paths
|
||||
.map((pathValue) => {
|
||||
const basename = path.basename(pathValue);
|
||||
const parsed = parseEpisodeKey(basename);
|
||||
return {
|
||||
path: pathValue,
|
||||
basename,
|
||||
parsed,
|
||||
episodeLabel: buildEpisodeLabel(parsed),
|
||||
naturalTokens: tokenizeNaturalSort(basename),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.parsed && right.parsed) {
|
||||
if (
|
||||
left.parsed.season !== null &&
|
||||
right.parsed.season !== null &&
|
||||
left.parsed.season !== right.parsed.season
|
||||
) {
|
||||
return left.parsed.season - right.parsed.season;
|
||||
}
|
||||
if (left.parsed.episode !== right.parsed.episode) {
|
||||
return left.parsed.episode - right.parsed.episode;
|
||||
}
|
||||
} else if (left.parsed && !right.parsed) {
|
||||
return -1;
|
||||
} else if (!left.parsed && right.parsed) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens);
|
||||
if (naturalComparison !== 0) {
|
||||
return naturalComparison;
|
||||
}
|
||||
return COLLATOR.compare(left.basename, right.basename);
|
||||
})
|
||||
.map(({ path: itemPath, basename, episodeLabel }) => ({
|
||||
path: itemPath,
|
||||
basename,
|
||||
episodeLabel,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user