mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix(jellyfin): show overlay, inject plugin, and fix stats title on playb
- Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws
This commit is contained in:
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: settings
|
|
||||||
|
|
||||||
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Grouped Jellyfin playback stats under Jellyfin item metadata instead of stream URLs, so watched episodes merge with matching local library titles and keep clean display names.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: config
|
|
||||||
|
|
||||||
- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Clarified that beta/RC update checks are controlled by `updates.channel`; set it to `"prerelease"` to receive beta/RC updates.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Showed the visible subtitle overlay automatically during Jellyfin playback so configured `subtitleStyle` appearance applies to Jellyfin subtitles.
|
||||||
|
- Injected the bundled mpv plugin when SubMiner auto-launches mpv for Jellyfin playback, restoring mpv-side keybindings without needing overlay focus.
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: launcher
|
|
||||||
breaking: true
|
|
||||||
|
|
||||||
- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage.
|
|
||||||
- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`.
|
|
||||||
- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: config
|
|
||||||
|
|
||||||
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
|
|
||||||
@@ -1304,6 +1304,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to
|
|||||||
|
|
||||||
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
|
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
|
||||||
|
|
||||||
|
Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
|
||||||
|
|
||||||
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
|
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
|
||||||
|
|
||||||
### Discord Rich Presence
|
### Discord Rich Presence
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ Optional stream overrides:
|
|||||||
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`.
|
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`.
|
||||||
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
|
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
|
||||||
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
|
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
|
||||||
|
- When SubMiner auto-launches mpv for Jellyfin playback, it injects the bundled mpv plugin unless an installed SubMiner mpv plugin is already present. This keeps mpv-side keybindings available without clicking the overlay first.
|
||||||
|
- Jellyfin playback shows the SubMiner visible overlay before selecting subtitle tracks, so `subtitleStyle` controls the rendered subtitle appearance. Use the overlay toggle shortcut if you want to hide it for a session.
|
||||||
|
|
||||||
## Cast To Device Mode (jellyfin-mpv-shim style)
|
## Cast To Device Mode (jellyfin-mpv-shim style)
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
|||||||
3. For alass, select a reference subtitle track from the video.
|
3. For alass, select a reference subtitle track from the video.
|
||||||
4. SubMiner runs the sync and reloads the corrected subtitle.
|
4. SubMiner runs the sync and reloads the corrected subtitle.
|
||||||
|
|
||||||
|
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
||||||
|
|
||||||
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
||||||
|
|
||||||
## N+1 Word Highlighting
|
## N+1 Word Highlighting
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ Automatic checks log failures quietly so playback is not interrupted.
|
|||||||
|
|
||||||
**"SubMiner is up to date" but a prerelease exists**
|
**"SubMiner is up to date" but a prerelease exists**
|
||||||
|
|
||||||
SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
|
SubMiner uses the configured release channel for update checks. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
|
||||||
|
|
||||||
**Launcher update shows a sudo command**
|
**Launcher update shows a sudo command**
|
||||||
|
|
||||||
|
|||||||
@@ -833,6 +833,7 @@ test('startOverlay uses caller config dir for app control socket discovery', asy
|
|||||||
const { dir, socketPath } = createTempSocketPath();
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
const configDir = path.join(dir, 'launcher-config');
|
const configDir = path.join(dir, 'launcher-config');
|
||||||
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
|
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||||
const receivedControlArgv: string[][] = [];
|
const receivedControlArgv: string[][] = [];
|
||||||
|
|||||||
@@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan
|
|||||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
|
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startAppLifecycle drains queued second-instance commands when app ready runtime fails', async () => {
|
||||||
|
const handled: string[] = [];
|
||||||
|
let controlArgvHandler: ((argv: string[]) => void) | null = null;
|
||||||
|
let readyHandler: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
const { deps } = createDeps({
|
||||||
|
shouldStartApp: () => true,
|
||||||
|
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
|
||||||
|
handleCliCommand: (args, source) => {
|
||||||
|
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
|
||||||
|
},
|
||||||
|
startControlServer: (handler) => {
|
||||||
|
controlArgvHandler = handler;
|
||||||
|
},
|
||||||
|
whenReady: (handler) => {
|
||||||
|
readyHandler = handler;
|
||||||
|
},
|
||||||
|
onReady: async () => {
|
||||||
|
handled.push('ready');
|
||||||
|
throw new Error('ready failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
startAppLifecycle(makeArgs({ background: true }), deps);
|
||||||
|
|
||||||
|
assert.ok(controlArgvHandler);
|
||||||
|
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||||
|
assert.deepEqual(handled, []);
|
||||||
|
|
||||||
|
assert.ok(readyHandler);
|
||||||
|
await assert.rejects((readyHandler as () => Promise<void>)(), /ready failed/);
|
||||||
|
|
||||||
|
assert.deepEqual(handled, ['ready', 'second-instance:start']);
|
||||||
|
|
||||||
|
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||||
|
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
|
||||||
|
});
|
||||||
|
|
||||||
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
|
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
|
||||||
let windowAllClosedHandler: (() => void) | null = null;
|
let windowAllClosedHandler: (() => void) | null = null;
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
|
|||||||
@@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.whenReady(async () => {
|
deps.whenReady(async () => {
|
||||||
|
try {
|
||||||
await deps.onReady();
|
await deps.onReady();
|
||||||
|
} finally {
|
||||||
appReadyRuntimeComplete = true;
|
appReadyRuntimeComplete = true;
|
||||||
flushPendingSecondInstanceCommands();
|
flushPendingSecondInstanceCommands();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
deps.onWindowAllClosed(() => {
|
deps.onWindowAllClosed(() => {
|
||||||
|
|||||||
@@ -1552,6 +1552,98 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Jellyfin playback metadata links stream videos to existing series title', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
|
||||||
|
tracker.handleMediaChange('/tmp/The Beginning After the End S02E01.mkv', 'Episode 1');
|
||||||
|
await waitForPendingAnimeMetadata(tracker);
|
||||||
|
tracker.destroy();
|
||||||
|
tracker = null;
|
||||||
|
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
tracker.recordJellyfinPlaybackMetadata({
|
||||||
|
mediaPath:
|
||||||
|
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
|
||||||
|
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||||
|
itemTitle: 'The Princess Begins Adventuring',
|
||||||
|
seriesTitle: 'The Beginning After the End',
|
||||||
|
seasonNumber: 2,
|
||||||
|
episodeNumber: 2,
|
||||||
|
itemId: 'item-2',
|
||||||
|
});
|
||||||
|
tracker.handleMediaChange(
|
||||||
|
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
|
||||||
|
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||||
|
);
|
||||||
|
tracker.handleMediaChange(null, null);
|
||||||
|
tracker.recordJellyfinPlaybackMetadata({
|
||||||
|
mediaPath:
|
||||||
|
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
|
||||||
|
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||||
|
itemTitle: 'The Princess Begins Adventuring',
|
||||||
|
seriesTitle: 'The Beginning After the End',
|
||||||
|
seasonNumber: 2,
|
||||||
|
episodeNumber: 2,
|
||||||
|
itemId: 'item-2',
|
||||||
|
});
|
||||||
|
tracker.handleMediaChange(
|
||||||
|
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
|
||||||
|
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||||
|
const rows = privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
v.source_url,
|
||||||
|
v.canonical_title AS video_title,
|
||||||
|
v.parsed_title,
|
||||||
|
v.parsed_season,
|
||||||
|
v.parsed_episode,
|
||||||
|
v.parser_source,
|
||||||
|
a.canonical_title AS anime_title
|
||||||
|
FROM imm_videos v
|
||||||
|
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||||
|
ORDER BY v.video_id
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
|
source_url: string | null;
|
||||||
|
video_title: string;
|
||||||
|
parsed_title: string | null;
|
||||||
|
parsed_season: number | null;
|
||||||
|
parsed_episode: number | null;
|
||||||
|
parser_source: string | null;
|
||||||
|
anime_title: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
assert.equal(rows.length, 2);
|
||||||
|
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
|
||||||
|
const jellyfinRow = rows.find(
|
||||||
|
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
|
||||||
|
);
|
||||||
|
assert.ok(jellyfinRow);
|
||||||
|
assert.equal(
|
||||||
|
jellyfinRow.video_title,
|
||||||
|
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||||
|
);
|
||||||
|
assert.equal(jellyfinRow.parsed_title, 'The Beginning After the End');
|
||||||
|
assert.equal(jellyfinRow.parsed_season, 2);
|
||||||
|
assert.equal(jellyfinRow.parsed_episode, 2);
|
||||||
|
assert.equal(jellyfinRow.parser_source, 'jellyfin');
|
||||||
|
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End');
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('applies configurable queue, flush, and retention policy', async () => {
|
test('applies configurable queue, flush, and retention policy', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -301,6 +301,33 @@ export type {
|
|||||||
VocabularyStatsRow,
|
VocabularyStatsRow,
|
||||||
} from './immersion-tracker/types';
|
} from './immersion-tracker/types';
|
||||||
|
|
||||||
|
export interface JellyfinPlaybackMetadataInput {
|
||||||
|
mediaPath: string;
|
||||||
|
displayTitle: string;
|
||||||
|
itemTitle: string;
|
||||||
|
seriesTitle: string | null;
|
||||||
|
seasonNumber: number | null;
|
||||||
|
episodeNumber: number | null;
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetadataInt(value: number | null | undefined): number | null {
|
||||||
|
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
|
||||||
|
const normalizedItemId = normalizeText(itemId);
|
||||||
|
if (!normalizedItemId) {
|
||||||
|
return mediaPath;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(mediaPath);
|
||||||
|
return `jellyfin://${parsed.host}/item/${encodeURIComponent(normalizedItemId)}`;
|
||||||
|
} catch {
|
||||||
|
return `jellyfin://item/${encodeURIComponent(normalizedItemId)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ImmersionTrackerService {
|
export class ImmersionTrackerService {
|
||||||
private readonly logger = createLogger('main:immersion-tracker');
|
private readonly logger = createLogger('main:immersion-tracker');
|
||||||
private readonly db: DatabaseSync;
|
private readonly db: DatabaseSync;
|
||||||
@@ -337,6 +364,7 @@ export class ImmersionTrackerService {
|
|||||||
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
||||||
private readonly recordedSubtitleKeys = new Set<string>();
|
private readonly recordedSubtitleKeys = new Set<string>();
|
||||||
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
||||||
|
private readonly mediaPathAliases = new Map<string, string>();
|
||||||
private readonly resolveLegacyVocabularyPos:
|
private readonly resolveLegacyVocabularyPos:
|
||||||
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
|
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -1115,8 +1143,85 @@ export class ImmersionTrackerService {
|
|||||||
rebuildLifetimeSummaryTables(this.db);
|
rebuildLifetimeSummaryTables(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
|
||||||
|
const rawPath = normalizeMediaPath(metadata.mediaPath);
|
||||||
|
if (!rawPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||||
|
this.mediaPathAliases.set(rawPath, normalizedPath);
|
||||||
|
|
||||||
|
const displayTitle =
|
||||||
|
normalizeText(metadata.displayTitle) ||
|
||||||
|
normalizeText(metadata.itemTitle) ||
|
||||||
|
deriveCanonicalTitle(normalizedPath);
|
||||||
|
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
||||||
|
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||||
|
const libraryTitle = seriesTitle || itemTitle;
|
||||||
|
if (!libraryTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(
|
||||||
|
this.db,
|
||||||
|
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
|
||||||
|
{
|
||||||
|
canonicalTitle: displayTitle,
|
||||||
|
sourcePath: null,
|
||||||
|
sourceUrl: normalizedPath,
|
||||||
|
sourceType: SOURCE_TYPE_REMOTE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const previousLink = this.db
|
||||||
|
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||||
|
.get(videoId) as { animeId: number | null } | null;
|
||||||
|
const metadataJson = JSON.stringify({
|
||||||
|
source: 'jellyfin',
|
||||||
|
itemId: normalizeText(metadata.itemId) || null,
|
||||||
|
itemTitle,
|
||||||
|
seriesTitle: seriesTitle || null,
|
||||||
|
displayTitle,
|
||||||
|
seasonNumber: normalizeMetadataInt(metadata.seasonNumber),
|
||||||
|
episodeNumber: normalizeMetadataInt(metadata.episodeNumber),
|
||||||
|
});
|
||||||
|
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||||
|
parsedTitle: libraryTitle,
|
||||||
|
canonicalTitle: libraryTitle,
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(this.db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: null,
|
||||||
|
parsedTitle: libraryTitle,
|
||||||
|
parsedSeason: normalizeMetadataInt(metadata.seasonNumber),
|
||||||
|
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber),
|
||||||
|
parserSource: 'jellyfin',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: metadataJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasLifetimeMedia = Boolean(
|
||||||
|
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
|
||||||
|
);
|
||||||
|
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
|
||||||
|
rebuildLifetimeSummaryTables(this.db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasJellyfinMetadata(videoId: number): boolean {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
|
||||||
|
.get(videoId) as { parserSource: string | null } | null;
|
||||||
|
return row?.parserSource === 'jellyfin';
|
||||||
|
}
|
||||||
|
|
||||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
const rawPath = normalizeMediaPath(mediaPath);
|
||||||
|
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
|
||||||
const normalizedTitle = normalizeText(mediaTitle);
|
const normalizedTitle = normalizeText(mediaTitle);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
|
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
|
||||||
@@ -1164,7 +1269,7 @@ export class ImmersionTrackerService {
|
|||||||
if (youtubeVideoId) {
|
if (youtubeVideoId) {
|
||||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||||
} else {
|
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
||||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||||
}
|
}
|
||||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||||
|
|||||||
@@ -560,6 +560,10 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu
|
|||||||
|
|
||||||
assert.equal(plan.mode, 'direct');
|
assert.equal(plan.mode, 'direct');
|
||||||
assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope');
|
assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope');
|
||||||
|
assert.equal(plan.itemTitle, 'A New Hope');
|
||||||
|
assert.equal(plan.seriesTitle, 'Galaxy Quest');
|
||||||
|
assert.equal(plan.seasonNumber, 2);
|
||||||
|
assert.equal(plan.episodeNumber, 7);
|
||||||
assert.equal(plan.audioStreamIndex, 6);
|
assert.equal(plan.audioStreamIndex, 6);
|
||||||
assert.equal(plan.subtitleStreamIndex, 9);
|
assert.equal(plan.subtitleStreamIndex, 9);
|
||||||
assert.equal(plan.startTimeTicks, 35_000_000);
|
assert.equal(plan.startTimeTicks, 35_000_000);
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan {
|
|||||||
mode: 'direct' | 'transcode';
|
mode: 'direct' | 'transcode';
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
itemTitle: string;
|
||||||
|
seriesTitle: string | null;
|
||||||
|
seasonNumber: number | null;
|
||||||
|
episodeNumber: number | null;
|
||||||
startTimeTicks: number;
|
startTimeTicks: number;
|
||||||
audioStreamIndex: number | null;
|
audioStreamIndex: number | null;
|
||||||
subtitleStreamIndex: number | null;
|
subtitleStreamIndex: number | null;
|
||||||
@@ -292,14 +296,24 @@ function getStreamDefaults(source: JellyfinMediaSource): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getItemTitle(item: JellyfinItem): string {
|
||||||
|
return ensureString(item.Name).trim() || 'Jellyfin Item';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeriesTitle(item: JellyfinItem): string | null {
|
||||||
|
return ensureString(item.SeriesName).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
function getDisplayTitle(item: JellyfinItem): string {
|
function getDisplayTitle(item: JellyfinItem): string {
|
||||||
|
const itemTitle = getItemTitle(item);
|
||||||
if (item.Type === 'Episode') {
|
if (item.Type === 'Episode') {
|
||||||
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
|
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
|
||||||
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
|
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
|
||||||
const prefix = item.SeriesName ? `${item.SeriesName} ` : '';
|
const seriesTitle = getSeriesTitle(item);
|
||||||
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim();
|
const prefix = seriesTitle ? `${seriesTitle} ` : '';
|
||||||
|
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${itemTitle}`.trim();
|
||||||
}
|
}
|
||||||
return ensureString(item.Name).trim() || 'Jellyfin Item';
|
return itemTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
|
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
|
||||||
@@ -521,10 +535,16 @@ export async function resolvePlaybackPlan(
|
|||||||
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
|
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
|
||||||
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
|
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
|
||||||
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
|
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
|
||||||
|
const itemTitle = getItemTitle(item);
|
||||||
|
const seriesTitle = item.Type === 'Episode' ? getSeriesTitle(item) : null;
|
||||||
const basePlan: JellyfinPlaybackPlan = {
|
const basePlan: JellyfinPlaybackPlan = {
|
||||||
mode: 'transcode',
|
mode: 'transcode',
|
||||||
url: '',
|
url: '',
|
||||||
title: getDisplayTitle(item),
|
title: getDisplayTitle(item),
|
||||||
|
itemTitle,
|
||||||
|
seriesTitle,
|
||||||
|
seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null,
|
||||||
|
episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null,
|
||||||
startTimeTicks,
|
startTimeTicks,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
subtitleStreamIndex,
|
subtitleStreamIndex,
|
||||||
|
|||||||
@@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
|
|||||||
test('triggerSubsyncFromConfig opens manual picker', async () => {
|
test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||||
const osd: string[] = [];
|
const osd: string[] = [];
|
||||||
let payloadTrackCount = 0;
|
let payloadTrackCount = 0;
|
||||||
|
let ffsubsyncAvailable: boolean | null = null;
|
||||||
let inProgressState: boolean | null = null;
|
let inProgressState: boolean | null = null;
|
||||||
|
|
||||||
await triggerSubsyncFromConfig(
|
await triggerSubsyncFromConfig(
|
||||||
makeDeps({
|
makeDeps({
|
||||||
openManualPicker: (payload) => {
|
openManualPicker: (payload) => {
|
||||||
payloadTrackCount = payload.sourceTracks.length;
|
payloadTrackCount = payload.sourceTracks.length;
|
||||||
|
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
osd.push(text);
|
osd.push(text);
|
||||||
@@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(payloadTrackCount, 1);
|
assert.equal(payloadTrackCount, 1);
|
||||||
|
assert.equal(ffsubsyncAvailable, true);
|
||||||
assert.ok(osd.includes('Subsync: choose engine and source'));
|
assert.ok(osd.includes('Subsync: choose engine and source'));
|
||||||
assert.equal(inProgressState, false);
|
assert.equal(inProgressState, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media paths', async () => {
|
||||||
|
let ffsubsyncAvailable: boolean | null = null;
|
||||||
|
|
||||||
|
await triggerSubsyncFromConfig(
|
||||||
|
makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
lang: 'eng',
|
||||||
|
'external-filename': 'https://jellyfin.example/subs/eng.srt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
openManualPicker: (payload) => {
|
||||||
|
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(ffsubsyncAvailable, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
||||||
const osd: string[] = [];
|
const osd: string[] = [];
|
||||||
let payloadTrackCount = 0;
|
let payloadTrackCount = 0;
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps
|
|||||||
const client = getMpvClientForSubsync(deps);
|
const client = getMpvClientForSubsync(deps);
|
||||||
const context = await gatherSubsyncContext(client);
|
const context = await gatherSubsyncContext(client);
|
||||||
const payload: SubsyncManualPayload = {
|
const payload: SubsyncManualPayload = {
|
||||||
|
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
|
||||||
sourceTracks: context.sourceTracks
|
sourceTracks: context.sourceTracks
|
||||||
.filter((track) => typeof track.id === 'number')
|
.filter((track) => typeof track.id === 'number')
|
||||||
.map((track) => ({
|
.map((track) => ({
|
||||||
|
|||||||
+51
@@ -2747,6 +2747,7 @@ const {
|
|||||||
reportJellyfinRemoteStopped,
|
reportJellyfinRemoteStopped,
|
||||||
startJellyfinRemoteSession,
|
startJellyfinRemoteSession,
|
||||||
stopJellyfinRemoteSession,
|
stopJellyfinRemoteSession,
|
||||||
|
cleanupJellyfinSubtitleCache,
|
||||||
runJellyfinCommand,
|
runJellyfinCommand,
|
||||||
openJellyfinSetupWindow,
|
openJellyfinSetupWindow,
|
||||||
getJellyfinClientInfo,
|
getJellyfinClientInfo,
|
||||||
@@ -2770,6 +2771,15 @@ const {
|
|||||||
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
|
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
|
||||||
|
getInstalledPluginDetection: () =>
|
||||||
|
detectInstalledMpvPlugin({
|
||||||
|
platform: process.platform,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
|
appDataDir: app.getPath('appData'),
|
||||||
|
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
|
||||||
|
}),
|
||||||
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
||||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||||
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
||||||
@@ -2805,6 +2815,41 @@ const {
|
|||||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
},
|
},
|
||||||
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||||
|
cacheSubtitleTrack: async (track) => {
|
||||||
|
if (!track.deliveryUrl) {
|
||||||
|
throw new Error('Jellyfin subtitle track has no delivery URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir = await fs.promises.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), 'subminer-jellyfin-subtitles-'),
|
||||||
|
);
|
||||||
|
const urlPath = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(track.deliveryUrl).pathname;
|
||||||
|
} catch {
|
||||||
|
return track.deliveryUrl;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const ext = path.extname(urlPath).slice(0, 16) || '.srt';
|
||||||
|
const subtitlePath = path.join(cacheDir, `track-${track.index}${ext}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(track.deliveryUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
await fs.promises.writeFile(subtitlePath, bytes);
|
||||||
|
} catch (error) {
|
||||||
|
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { path: subtitlePath, cleanupDir: cacheDir };
|
||||||
|
},
|
||||||
|
cleanupCachedSubtitles: (dirs) => {
|
||||||
|
for (const dir of dirs) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
logDebug: (message, error) => {
|
logDebug: (message, error) => {
|
||||||
logger.debug(message, error);
|
logger.debug(message, error);
|
||||||
},
|
},
|
||||||
@@ -2823,6 +2868,7 @@ const {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
|
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
|
||||||
|
showVisibleOverlay: () => setVisibleOverlayVisible(true),
|
||||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||||
armQuitOnDisconnect: () => {
|
armQuitOnDisconnect: () => {
|
||||||
jellyfinPlayQuitOnDisconnectArmed = false;
|
jellyfinPlayQuitOnDisconnectArmed = false;
|
||||||
@@ -2846,6 +2892,10 @@ const {
|
|||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
showMpvOsd(text);
|
showMpvOsd(text);
|
||||||
},
|
},
|
||||||
|
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||||
|
ensureImmersionTrackerStarted();
|
||||||
|
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
remoteComposerOptions: {
|
remoteComposerOptions: {
|
||||||
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
|
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
|
||||||
@@ -3615,6 +3665,7 @@ const {
|
|||||||
appState.yomitanSettingsWindow = null;
|
appState.yomitanSettingsWindow = null;
|
||||||
},
|
},
|
||||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||||
|
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||||
stopDiscordPresenceService: () => {
|
stopDiscordPresenceService: () => {
|
||||||
void appState.discordPresenceService?.stop();
|
void appState.discordPresenceService?.stop();
|
||||||
appState.discordPresenceService = null;
|
appState.discordPresenceService = null;
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
|||||||
});
|
});
|
||||||
runtime.sendToActiveOverlayWindow(
|
runtime.sendToActiveOverlayWindow(
|
||||||
'subsync:open-manual',
|
'subsync:open-manual',
|
||||||
{ sourceTracks: [] },
|
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||||
{
|
{
|
||||||
restoreOnModalClose: 'subsync',
|
restoreOnModalClose: 'subsync',
|
||||||
},
|
},
|
||||||
@@ -459,7 +459,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact
|
|||||||
});
|
});
|
||||||
runtime.sendToActiveOverlayWindow(
|
runtime.sendToActiveOverlayWindow(
|
||||||
'subsync:open-manual',
|
'subsync:open-manual',
|
||||||
{ sourceTracks: [] },
|
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||||
{
|
{
|
||||||
restoreOnModalClose: 'subsync',
|
restoreOnModalClose: 'subsync',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,13 +40,15 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
assert.equal(calls.length, 30);
|
assert.equal(calls.length, 31);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
destroyYomitanSettingsWindow: () => void;
|
destroyYomitanSettingsWindow: () => void;
|
||||||
clearYomitanSettingsWindow: () => void;
|
clearYomitanSettingsWindow: () => void;
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): void => {
|
return (): void => {
|
||||||
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.destroyYomitanSettingsWindow();
|
deps.destroyYomitanSettingsWindow();
|
||||||
deps.clearYomitanSettingsWindow();
|
deps.clearYomitanSettingsWindow();
|
||||||
deps.stopJellyfinRemoteSession();
|
deps.stopJellyfinRemoteSession();
|
||||||
|
deps.cleanupJellyfinSubtitleCache();
|
||||||
deps.stopDiscordPresenceService();
|
deps.stopDiscordPresenceService();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||||
|
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
assert.ok(calls.includes('destroy-first-run-window'));
|
assert.ok(calls.includes('destroy-first-run-window'));
|
||||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||||
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
assert.ok(calls.includes('stop-discord-presence'));
|
assert.ok(calls.includes('stop-discord-presence'));
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||||
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
clearYomitanSettingsWindow: () => void;
|
clearYomitanSettingsWindow: () => void;
|
||||||
|
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
},
|
},
|
||||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||||
|
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
wait: async () => {},
|
wait: async () => {},
|
||||||
|
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
|
||||||
|
cleanupCachedSubtitles: () => {},
|
||||||
logDebug: () => {},
|
logDebug: () => {},
|
||||||
},
|
},
|
||||||
playJellyfinItemInMpvMainDeps: {
|
playJellyfinItemInMpvMainDeps: {
|
||||||
@@ -58,11 +60,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
mode: 'direct',
|
mode: 'direct',
|
||||||
url: 'https://example.test/video.m3u8',
|
url: 'https://example.test/video.m3u8',
|
||||||
title: 'Episode 1',
|
title: 'Episode 1',
|
||||||
|
itemTitle: 'Episode 1',
|
||||||
|
seriesTitle: null,
|
||||||
|
seasonNumber: null,
|
||||||
|
episodeNumber: null,
|
||||||
startTimeTicks: 0,
|
startTimeTicks: 0,
|
||||||
audioStreamIndex: null,
|
audioStreamIndex: null,
|
||||||
subtitleStreamIndex: null,
|
subtitleStreamIndex: null,
|
||||||
}),
|
}),
|
||||||
applyJellyfinMpvDefaults: () => {},
|
applyJellyfinMpvDefaults: () => {},
|
||||||
|
showVisibleOverlay: () => {},
|
||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
armQuitOnDisconnect: () => {},
|
armQuitOnDisconnect: () => {},
|
||||||
schedule: () => undefined,
|
schedule: () => undefined,
|
||||||
@@ -189,6 +196,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
||||||
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
||||||
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
|
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
|
||||||
|
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
|
||||||
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
|
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
|
||||||
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
|
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
|
||||||
assert.equal(typeof composed.runJellyfinCommand, 'function');
|
assert.equal(typeof composed.runJellyfinCommand, 'function');
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
|||||||
typeof composeJellyfinRemoteHandlers
|
typeof composeJellyfinRemoteHandlers
|
||||||
>['handleJellyfinRemoteGeneralCommand'];
|
>['handleJellyfinRemoteGeneralCommand'];
|
||||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||||
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||||
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
|
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
|
||||||
@@ -280,6 +281,7 @@ export function composeJellyfinRuntimeHandlers(
|
|||||||
handleJellyfinRemotePlaystate,
|
handleJellyfinRemotePlaystate,
|
||||||
handleJellyfinRemoteGeneralCommand,
|
handleJellyfinRemoteGeneralCommand,
|
||||||
playJellyfinItemInMpv,
|
playJellyfinItemInMpv,
|
||||||
|
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
||||||
startJellyfinRemoteSession,
|
startJellyfinRemoteSession,
|
||||||
stopJellyfinRemoteSession,
|
stopJellyfinRemoteSession,
|
||||||
runJellyfinCommand,
|
runJellyfinCommand,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: async () => {},
|
stopJellyfinRemoteSession: async () => {},
|
||||||
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
},
|
},
|
||||||
shouldRestoreWindowsOnActivateMainDeps: {
|
shouldRestoreWindowsOnActivateMainDeps: {
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
|||||||
url: 'u',
|
url: 'u',
|
||||||
mode: 'direct',
|
mode: 'direct',
|
||||||
title: 't',
|
title: 't',
|
||||||
|
itemTitle: 't',
|
||||||
|
seriesTitle: null,
|
||||||
|
seasonNumber: null,
|
||||||
|
episodeNumber: null,
|
||||||
startTimeTicks: 0,
|
startTimeTicks: 0,
|
||||||
audioStreamIndex: null,
|
audioStreamIndex: null,
|
||||||
subtitleStreamIndex: null,
|
subtitleStreamIndex: null,
|
||||||
}),
|
}),
|
||||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||||
|
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||||
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
|
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
|
||||||
armQuitOnDisconnect: () => calls.push('arm'),
|
armQuitOnDisconnect: () => calls.push('arm'),
|
||||||
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
|
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
|
||||||
@@ -49,12 +54,17 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
|||||||
url: 'u',
|
url: 'u',
|
||||||
mode: 'direct',
|
mode: 'direct',
|
||||||
title: 't',
|
title: 't',
|
||||||
|
itemTitle: 't',
|
||||||
|
seriesTitle: null,
|
||||||
|
seasonNumber: null,
|
||||||
|
episodeNumber: null,
|
||||||
startTimeTicks: 0,
|
startTimeTicks: 0,
|
||||||
audioStreamIndex: null,
|
audioStreamIndex: null,
|
||||||
subtitleStreamIndex: null,
|
subtitleStreamIndex: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
|
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
|
||||||
|
deps.showVisibleOverlay();
|
||||||
deps.sendMpvCommand(['show-text', 'x']);
|
deps.sendMpvCommand(['show-text', 'x']);
|
||||||
deps.armQuitOnDisconnect();
|
deps.armQuitOnDisconnect();
|
||||||
deps.schedule(() => {}, 500);
|
deps.schedule(() => {}, 500);
|
||||||
@@ -85,6 +95,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
|||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'defaults',
|
'defaults',
|
||||||
|
'visible-overlay',
|
||||||
'cmd:show-text',
|
'cmd:show-text',
|
||||||
'arm',
|
'arm',
|
||||||
'schedule:500',
|
'schedule:500',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
|||||||
getMpvClient: () => deps.getMpvClient(),
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params),
|
resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params),
|
||||||
applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient),
|
applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient),
|
||||||
|
showVisibleOverlay: () => deps.showVisibleOverlay(),
|
||||||
sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command),
|
sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command),
|
||||||
armQuitOnDisconnect: () => deps.armQuitOnDisconnect(),
|
armQuitOnDisconnect: () => deps.armQuitOnDisconnect(),
|
||||||
schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs),
|
schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs),
|
||||||
@@ -19,5 +20,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
|||||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
reportPlaying: (payload) => deps.reportPlaying(payload),
|
||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
|
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||||
|
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ test('playback handler throws when mpv is not connected', async () => {
|
|||||||
throw new Error('unreachable');
|
throw new Error('unreachable');
|
||||||
},
|
},
|
||||||
applyJellyfinMpvDefaults: () => {},
|
applyJellyfinMpvDefaults: () => {},
|
||||||
|
showVisibleOverlay: () => {},
|
||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
armQuitOnDisconnect: () => {},
|
armQuitOnDisconnect: () => {},
|
||||||
schedule: () => {},
|
schedule: () => {},
|
||||||
@@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const activeStates: Array<Record<string, unknown>> = [];
|
const activeStates: Array<Record<string, unknown>> = [];
|
||||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||||
|
const statsMetadata: Array<Record<string, unknown>> = [];
|
||||||
const handler = createPlayJellyfinItemInMpvHandler({
|
const handler = createPlayJellyfinItemInMpvHandler({
|
||||||
ensureMpvConnectedForPlayback: async () => true,
|
ensureMpvConnectedForPlayback: async () => true,
|
||||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||||
@@ -59,11 +61,16 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
url: 'https://stream.example/video.m3u8',
|
url: 'https://stream.example/video.m3u8',
|
||||||
mode: 'direct',
|
mode: 'direct',
|
||||||
title: 'Episode 1',
|
title: 'Episode 1',
|
||||||
|
itemTitle: 'Episode 1',
|
||||||
|
seriesTitle: 'Show Title',
|
||||||
|
seasonNumber: 1,
|
||||||
|
episodeNumber: 1,
|
||||||
startTimeTicks: 12_000_000,
|
startTimeTicks: 12_000_000,
|
||||||
audioStreamIndex: 1,
|
audioStreamIndex: 1,
|
||||||
subtitleStreamIndex: 2,
|
subtitleStreamIndex: 2,
|
||||||
}),
|
}),
|
||||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||||
|
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||||
sendMpvCommand: (command) => commands.push(command),
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
armQuitOnDisconnect: () => calls.push('arm'),
|
armQuitOnDisconnect: () => calls.push('arm'),
|
||||||
schedule: (callback, delayMs) => {
|
schedule: (callback, delayMs) => {
|
||||||
@@ -75,6 +82,8 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
|
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
|
||||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
|
recordJellyfinPlaybackMetadata: (metadata) =>
|
||||||
|
statsMetadata.push(metadata as Record<string, unknown>),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
@@ -87,7 +96,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
assert.deepEqual(commands.slice(0, 5), [
|
assert.deepEqual(commands.slice(0, 5), [
|
||||||
['set_property', 'sub-auto', 'no'],
|
['set_property', 'sub-auto', 'no'],
|
||||||
['loadfile', 'https://stream.example/video.m3u8', 'replace'],
|
['loadfile', 'https://stream.example/video.m3u8', 'replace'],
|
||||||
['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'],
|
['set_property', 'force-media-title', 'Episode 1'],
|
||||||
['set_property', 'sid', 'no'],
|
['set_property', 'sid', 'no'],
|
||||||
['seek', 1.2, 'absolute+exact'],
|
['seek', 1.2, 'absolute+exact'],
|
||||||
]);
|
]);
|
||||||
@@ -97,6 +106,11 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
|
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
|
||||||
|
|
||||||
assert.ok(calls.includes('defaults'));
|
assert.ok(calls.includes('defaults'));
|
||||||
|
assert.ok(calls.includes('visible-overlay'));
|
||||||
|
assert.ok(
|
||||||
|
calls.indexOf('visible-overlay') < calls.indexOf('preload'),
|
||||||
|
'visible overlay should be shown before Jellyfin subtitles are selected',
|
||||||
|
);
|
||||||
assert.ok(calls.includes('arm'));
|
assert.ok(calls.includes('arm'));
|
||||||
assert.ok(calls.includes('preload'));
|
assert.ok(calls.includes('preload'));
|
||||||
assert.ok(calls.includes('progress:0'));
|
assert.ok(calls.includes('progress:0'));
|
||||||
@@ -106,6 +120,17 @@ test('playback handler drives mpv commands and playback state', async () => {
|
|||||||
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
||||||
assert.equal(reportPayloads.length, 1);
|
assert.equal(reportPayloads.length, 1);
|
||||||
assert.equal(reportPayloads[0]?.eventName, 'start');
|
assert.equal(reportPayloads[0]?.eventName, 'start');
|
||||||
|
assert.deepEqual(statsMetadata, [
|
||||||
|
{
|
||||||
|
mediaPath: 'https://stream.example/video.m3u8',
|
||||||
|
displayTitle: 'Episode 1',
|
||||||
|
itemTitle: 'Episode 1',
|
||||||
|
seriesTitle: 'Show Title',
|
||||||
|
seasonNumber: 1,
|
||||||
|
episodeNumber: 1,
|
||||||
|
itemId: 'item-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('playback handler applies start override to stream url for remote resume', async () => {
|
test('playback handler applies start override to stream url for remote resume', async () => {
|
||||||
@@ -117,11 +142,16 @@ test('playback handler applies start override to stream url for remote resume',
|
|||||||
url: 'https://stream.example/video.m3u8?api_key=token',
|
url: 'https://stream.example/video.m3u8?api_key=token',
|
||||||
mode: 'transcode',
|
mode: 'transcode',
|
||||||
title: 'Episode 2',
|
title: 'Episode 2',
|
||||||
|
itemTitle: 'Episode 2',
|
||||||
|
seriesTitle: null,
|
||||||
|
seasonNumber: null,
|
||||||
|
episodeNumber: null,
|
||||||
startTimeTicks: 0,
|
startTimeTicks: 0,
|
||||||
audioStreamIndex: null,
|
audioStreamIndex: null,
|
||||||
subtitleStreamIndex: null,
|
subtitleStreamIndex: null,
|
||||||
}),
|
}),
|
||||||
applyJellyfinMpvDefaults: () => {},
|
applyJellyfinMpvDefaults: () => {},
|
||||||
|
showVisibleOverlay: () => {},
|
||||||
sendMpvCommand: (command) => commands.push(command),
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
armQuitOnDisconnect: () => {},
|
armQuitOnDisconnect: () => {},
|
||||||
schedule: () => {},
|
schedule: () => {},
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ type ActivePlaybackState = {
|
|||||||
playMethod: 'DirectPlay' | 'Transcode';
|
playMethod: 'DirectPlay' | 'Transcode';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JellyfinPlaybackStatsMetadata = {
|
||||||
|
mediaPath: string;
|
||||||
|
displayTitle: string;
|
||||||
|
itemTitle: string;
|
||||||
|
seriesTitle: string | null;
|
||||||
|
seasonNumber: number | null;
|
||||||
|
episodeNumber: number | null;
|
||||||
|
itemId: string;
|
||||||
|
};
|
||||||
|
|
||||||
function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string {
|
function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string {
|
||||||
if (typeof startTimeTicksOverride !== 'number') return url;
|
if (typeof startTimeTicksOverride !== 'number') return url;
|
||||||
try {
|
try {
|
||||||
@@ -43,6 +53,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
subtitleStreamIndex?: number | null;
|
subtitleStreamIndex?: number | null;
|
||||||
}) => Promise<JellyfinPlaybackPlan>;
|
}) => Promise<JellyfinPlaybackPlan>;
|
||||||
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
|
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
|
||||||
|
showVisibleOverlay: () => void;
|
||||||
sendMpvCommand: (command: Array<string | number>) => void;
|
sendMpvCommand: (command: Array<string | number>) => void;
|
||||||
armQuitOnDisconnect: () => void;
|
armQuitOnDisconnect: () => void;
|
||||||
schedule: (callback: () => void, delayMs: number) => void;
|
schedule: (callback: () => void, delayMs: number) => void;
|
||||||
@@ -63,6 +74,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
eventName: 'start';
|
eventName: 'start';
|
||||||
}) => void;
|
}) => void;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
|
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (params: {
|
return async (params: {
|
||||||
session: JellyfinAuthSession;
|
session: JellyfinAuthSession;
|
||||||
@@ -94,15 +106,20 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||||
|
deps.recordJellyfinPlaybackMetadata?.({
|
||||||
|
mediaPath: playbackUrl,
|
||||||
|
displayTitle: plan.title,
|
||||||
|
itemTitle: plan.itemTitle,
|
||||||
|
seriesTitle: plan.seriesTitle,
|
||||||
|
seasonNumber: plan.seasonNumber,
|
||||||
|
episodeNumber: plan.episodeNumber,
|
||||||
|
itemId: params.itemId,
|
||||||
|
});
|
||||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
||||||
if (params.setQuitOnDisconnectArm !== false) {
|
if (params.setQuitOnDisconnectArm !== false) {
|
||||||
deps.armQuitOnDisconnect();
|
deps.armQuitOnDisconnect();
|
||||||
}
|
}
|
||||||
deps.sendMpvCommand([
|
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
|
||||||
'set_property',
|
|
||||||
'force-media-title',
|
|
||||||
`[Jellyfin/${plan.mode}] ${plan.title}`,
|
|
||||||
]);
|
|
||||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||||
deps.schedule(() => {
|
deps.schedule(() => {
|
||||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||||
@@ -116,6 +133,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
|||||||
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deps.showVisibleOverlay();
|
||||||
deps.preloadExternalSubtitles({
|
deps.preloadExternalSubtitles({
|
||||||
session: params.session,
|
session: params.session,
|
||||||
clientInfo: params.clientInfo,
|
clientInfo: params.clientInfo,
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
|||||||
getLaunchMode: () => 'fullscreen',
|
getLaunchMode: () => 'fullscreen',
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
execPath: '/tmp/subminer',
|
execPath: '/tmp/subminer',
|
||||||
|
getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua',
|
||||||
|
getInstalledPluginDetection: () => ({
|
||||||
|
installed: false,
|
||||||
|
path: null,
|
||||||
|
version: null,
|
||||||
|
source: null,
|
||||||
|
message: null,
|
||||||
|
}),
|
||||||
defaultMpvLogPath: '/tmp/mpv.log',
|
defaultMpvLogPath: '/tmp/mpv.log',
|
||||||
defaultMpvArgs: ['--no-config'],
|
defaultMpvArgs: ['--no-config'],
|
||||||
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
||||||
@@ -51,6 +59,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
|||||||
assert.equal(deps.getLaunchMode(), 'fullscreen');
|
assert.equal(deps.getLaunchMode(), 'fullscreen');
|
||||||
assert.equal(deps.platform, 'darwin');
|
assert.equal(deps.platform, 'darwin');
|
||||||
assert.equal(deps.execPath, '/tmp/subminer');
|
assert.equal(deps.execPath, '/tmp/subminer');
|
||||||
|
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
||||||
|
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
|
||||||
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
|
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
|
||||||
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||||
deps.removeSocketPath('/tmp/mpv.sock');
|
deps.removeSocketPath('/tmp/mpv.sock');
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
|||||||
getLaunchMode: () => deps.getLaunchMode(),
|
getLaunchMode: () => deps.getLaunchMode(),
|
||||||
platform: deps.platform,
|
platform: deps.platform,
|
||||||
execPath: deps.execPath,
|
execPath: deps.execPath,
|
||||||
|
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
|
||||||
|
getInstalledPluginDetection: deps.getInstalledPluginDetection,
|
||||||
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
||||||
defaultMpvLogPath: deps.defaultMpvLogPath,
|
defaultMpvLogPath: deps.defaultMpvLogPath,
|
||||||
defaultMpvArgs: deps.defaultMpvArgs,
|
defaultMpvArgs: deps.defaultMpvArgs,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
|||||||
getLaunchMode: () => 'maximized',
|
getLaunchMode: () => 'maximized',
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||||
|
getRuntimePluginEntrypoint: () =>
|
||||||
|
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||||
defaultMpvLogPath: '/tmp/mp.log',
|
defaultMpvLogPath: '/tmp/mp.log',
|
||||||
defaultMpvArgs: ['--sid=auto'],
|
defaultMpvArgs: ['--sid=auto'],
|
||||||
removeSocketPath: () => {},
|
removeSocketPath: () => {},
|
||||||
@@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
|||||||
assert.equal(spawnedArgs.length, 1);
|
assert.equal(spawnedArgs.length, 1);
|
||||||
assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes'));
|
assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes'));
|
||||||
assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
|
assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
|
||||||
|
assert.ok(
|
||||||
|
spawnedArgs[0]!.includes(
|
||||||
|
'--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
|
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
|
||||||
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
|
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
|
||||||
});
|
});
|
||||||
@@ -101,6 +108,43 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
|||||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||||
|
const spawnedArgs: string[][] = [];
|
||||||
|
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||||
|
getSocketPath: () => '/tmp/subminer.sock',
|
||||||
|
getLaunchMode: () => 'normal',
|
||||||
|
platform: 'linux',
|
||||||
|
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||||
|
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
||||||
|
getInstalledPluginDetection: () => ({
|
||||||
|
installed: true,
|
||||||
|
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
||||||
|
version: '0.1.0',
|
||||||
|
source: 'default-config',
|
||||||
|
message: null,
|
||||||
|
}),
|
||||||
|
defaultMpvLogPath: '/tmp/mp.log',
|
||||||
|
defaultMpvArgs: ['--sid=auto'],
|
||||||
|
removeSocketPath: () => {},
|
||||||
|
spawnMpv: (args) => {
|
||||||
|
spawnedArgs.push(args);
|
||||||
|
return {
|
||||||
|
on: () => {},
|
||||||
|
unref: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
logWarn: () => {},
|
||||||
|
logInfo: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
launch();
|
||||||
|
assert.equal(
|
||||||
|
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
||||||
|
});
|
||||||
|
|
||||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||||
let autoLaunchInFlight: Promise<boolean> | null = null;
|
let autoLaunchInFlight: Promise<boolean> | null = null;
|
||||||
let launchCalls = 0;
|
let launchCalls = 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type SubminerPluginRuntimeScriptOptConfig,
|
type SubminerPluginRuntimeScriptOptConfig,
|
||||||
} from '../../shared/subminer-plugin-script-opts';
|
} from '../../shared/subminer-plugin-script-opts';
|
||||||
import type { MpvLaunchMode } from '../../types/config';
|
import type { MpvLaunchMode } from '../../types/config';
|
||||||
|
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||||
|
|
||||||
type MpvClientLike = {
|
type MpvClientLike = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = {
|
|||||||
getLaunchMode: () => MpvLaunchMode;
|
getLaunchMode: () => MpvLaunchMode;
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
execPath: string;
|
execPath: string;
|
||||||
|
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||||
|
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
||||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||||
defaultMpvLogPath: string;
|
defaultMpvLogPath: string;
|
||||||
defaultMpvArgs: readonly string[];
|
defaultMpvArgs: readonly string[];
|
||||||
@@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
|||||||
)
|
)
|
||||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||||
|
const installedPlugin = deps.getInstalledPluginDetection?.();
|
||||||
|
const runtimePluginEntrypoint = installedPlugin?.installed
|
||||||
|
? ''
|
||||||
|
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
||||||
|
if (installedPlugin?.installed && installedPlugin.path) {
|
||||||
|
deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`);
|
||||||
|
}
|
||||||
const mpvArgs = [
|
const mpvArgs = [
|
||||||
...deps.defaultMpvArgs,
|
...deps.defaultMpvArgs,
|
||||||
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
||||||
|
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
|
||||||
'--idle=yes',
|
'--idle=yes',
|
||||||
scriptOpts,
|
scriptOpts,
|
||||||
`--log-file=${deps.defaultMpvLogPath}`,
|
`--log-file=${deps.defaultMpvLogPath}`,
|
||||||
|
|||||||
@@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Jellyfin Setup</title>
|
<title>Jellyfin Setup</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
|
:root {
|
||||||
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
|
color-scheme: dark;
|
||||||
main { padding: 22px; }
|
--ctp-red: #ed8796;
|
||||||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
|
--ctp-peach: #f5a97f;
|
||||||
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
--ctp-yellow: #eed49f;
|
||||||
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
--ctp-green: #a6da95;
|
||||||
input { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
|
--ctp-blue: #8aadf4;
|
||||||
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
|
--ctp-lavender: #b7bdf8;
|
||||||
button:disabled { cursor: wait; opacity: .68; }
|
--ctp-text: #cad3f5;
|
||||||
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
|
--ctp-subtext1: #b8c0e0;
|
||||||
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
|
--ctp-subtext0: #a5adcb;
|
||||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
--ctp-overlay2: #939ab7;
|
||||||
|
--ctp-overlay1: #8087a2;
|
||||||
|
--ctp-overlay0: #6e738d;
|
||||||
|
--ctp-surface1: #494d64;
|
||||||
|
--ctp-surface0: #363a4f;
|
||||||
|
--ctp-base: #24273a;
|
||||||
|
--ctp-mantle: #1e2030;
|
||||||
|
--ctp-crust: #181926;
|
||||||
|
--line: rgba(110, 115, 141, 0.28);
|
||||||
|
--line-soft: rgba(110, 115, 141, 0.14);
|
||||||
|
--text: var(--ctp-text);
|
||||||
|
--muted: var(--ctp-subtext0);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; }
|
||||||
|
html { background: var(--ctp-base); }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
main { padding: 32px 22px; max-width: 520px; margin: 0 auto; }
|
||||||
|
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 800; color: var(--ctp-text); letter-spacing: -0.01em; }
|
||||||
|
p { margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||||
|
label { display: block; margin: 14px 0 6px; font-size: 11px; font-weight: 800; color: var(--ctp-overlay2); text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(24, 25, 38, 0.85);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||||
|
}
|
||||||
|
input::placeholder { color: var(--ctp-overlay0); }
|
||||||
|
input:hover { border-color: rgba(138, 173, 244, 0.32); }
|
||||||
|
input:focus {
|
||||||
|
border-color: rgba(138, 173, 244, 0.65);
|
||||||
|
background: rgba(24, 25, 38, 0.95);
|
||||||
|
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 60ms ease;
|
||||||
|
}
|
||||||
|
button:active { transform: translateY(1px); }
|
||||||
|
button:disabled { cursor: wait; opacity: 0.7; }
|
||||||
|
button.primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--ctp-blue);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
button.primary:hover:not(:disabled) { filter: brightness(1.06); }
|
||||||
|
button.primary:disabled {
|
||||||
|
background: rgba(54, 58, 79, 0.55);
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(54, 58, 79, 0.5);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
button.secondary:hover:not(:disabled) {
|
||||||
|
border-color: rgba(138, 173, 244, 0.45);
|
||||||
|
background: rgba(73, 77, 100, 0.6);
|
||||||
|
color: var(--ctp-lavender);
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
background: rgba(237, 135, 150, 0.12);
|
||||||
|
color: var(--ctp-red);
|
||||||
|
border-color: rgba(237, 135, 150, 0.45);
|
||||||
|
}
|
||||||
|
button.danger:hover:not(:disabled) {
|
||||||
|
background: rgba(237, 135, 150, 0.22);
|
||||||
|
}
|
||||||
|
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
|
||||||
.actions .primary { grid-column: 1 / -1; }
|
.actions .primary { grid-column: 1 / -1; }
|
||||||
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
|
.status {
|
||||||
.status.success { color: var(--accent); }
|
min-height: 18px;
|
||||||
.status.error { color: var(--danger); }
|
margin-top: 14px;
|
||||||
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
|
font-size: 12.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.status:empty { display: none; }
|
||||||
|
.status.loading,
|
||||||
|
.status.success,
|
||||||
|
.status.error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status.success {
|
||||||
|
border-color: rgba(166, 218, 149, 0.45);
|
||||||
|
background: rgba(166, 218, 149, 0.1);
|
||||||
|
color: var(--ctp-green);
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
border-color: rgba(237, 135, 150, 0.55);
|
||||||
|
background: rgba(237, 135, 150, 0.1);
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ctp-overlay2);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
|||||||
wait: async () => {
|
wait: async () => {
|
||||||
calls.push('wait');
|
calls.push('wait');
|
||||||
},
|
},
|
||||||
|
cacheSubtitleTrack: async () => {
|
||||||
|
calls.push('cache');
|
||||||
|
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
|
||||||
|
},
|
||||||
|
cleanupCachedSubtitles: () => calls.push('cleanup'),
|
||||||
logDebug: (message) => calls.push(`debug:${message}`),
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -21,6 +26,8 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
|||||||
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
||||||
deps.sendMpvCommand(['set_property', 'sid', 'auto']);
|
deps.sendMpvCommand(['set_property', 'sid', 'auto']);
|
||||||
await deps.wait(1);
|
await deps.wait(1);
|
||||||
|
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
|
||||||
|
deps.cleanupCachedSubtitles(['/tmp/subs']);
|
||||||
deps.logDebug('oops', null);
|
deps.logDebug('oops', null);
|
||||||
assert.deepEqual(calls, ['list', 'send', 'wait', 'debug:oops']);
|
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
|||||||
getMpvClient: () => deps.getMpvClient(),
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
sendMpvCommand: (command) => deps.sendMpvCommand(command),
|
sendMpvCommand: (command) => deps.sendMpvCommand(command),
|
||||||
wait: (ms: number) => deps.wait(ms),
|
wait: (ms: number) => deps.wait(ms),
|
||||||
|
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
|
||||||
|
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
|
||||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,45 @@ const clientInfo = {
|
|||||||
deviceId: 'dev',
|
deviceId: 'dev',
|
||||||
};
|
};
|
||||||
|
|
||||||
test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => {
|
function makeDeps(overrides: {
|
||||||
|
listJellyfinSubtitleTracks?: Parameters<
|
||||||
|
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||||
|
>[0]['listJellyfinSubtitleTracks'];
|
||||||
|
getMpvClient?: Parameters<
|
||||||
|
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||||
|
>[0]['getMpvClient'];
|
||||||
|
sendMpvCommand?: Parameters<
|
||||||
|
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||||
|
>[0]['sendMpvCommand'];
|
||||||
|
wait?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['wait'];
|
||||||
|
cacheSubtitleTrack?: Parameters<
|
||||||
|
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||||
|
>[0]['cacheSubtitleTrack'];
|
||||||
|
cleanupCachedSubtitles?: Parameters<
|
||||||
|
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||||
|
>[0]['cleanupCachedSubtitles'];
|
||||||
|
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
listJellyfinSubtitleTracks: overrides.listJellyfinSubtitleTracks ?? (async () => []),
|
||||||
|
getMpvClient: overrides.getMpvClient ?? (() => null),
|
||||||
|
sendMpvCommand: overrides.sendMpvCommand ?? (() => {}),
|
||||||
|
wait: overrides.wait ?? (async () => {}),
|
||||||
|
cacheSubtitleTrack:
|
||||||
|
overrides.cacheSubtitleTrack ??
|
||||||
|
(async (track) => ({
|
||||||
|
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||||
|
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||||
|
})),
|
||||||
|
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
|
||||||
|
logDebug: overrides.logDebug ?? (() => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('preload jellyfin subtitles caches external tracks locally and chooses japanese+english tracks', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
makeDeps({
|
||||||
listJellyfinSubtitleTracks: async () => [
|
listJellyfinSubtitleTracks: async () => [
|
||||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||||
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||||
@@ -30,32 +66,81 @@ test('preload jellyfin subtitles adds external tracks and chooses japanese+engli
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
sendMpvCommand: (command) => commands.push(command),
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
wait: async () => {},
|
cacheSubtitleTrack: async (track) => ({
|
||||||
logDebug: () => {},
|
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||||
});
|
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
|
||||||
assert.deepEqual(commands, [
|
assert.deepEqual(commands, [
|
||||||
['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'],
|
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'],
|
||||||
['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'],
|
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'cached', 'English SDH', 'eng'],
|
||||||
['set_property', 'sid', 5],
|
['set_property', 'sid', 5],
|
||||||
['set_property', 'secondary-sid', 6],
|
['set_property', 'secondary-sid', 6],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => {
|
||||||
|
const cleanupCalls: string[][] = [];
|
||||||
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
makeDeps({
|
||||||
|
listJellyfinSubtitleTracks: async () => [
|
||||||
|
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||||
|
],
|
||||||
|
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||||
|
cacheSubtitleTrack: async (track) => ({
|
||||||
|
path: `/tmp/subminer-jellyfin-subtitles-${track.index}/track.srt`,
|
||||||
|
cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`,
|
||||||
|
}),
|
||||||
|
cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
await preload({ session, clientInfo, itemId: 'item-2' });
|
||||||
|
|
||||||
|
assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => {
|
||||||
|
const cleanupCalls: string[][] = [];
|
||||||
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
makeDeps({
|
||||||
|
listJellyfinSubtitleTracks: async () => [
|
||||||
|
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||||
|
],
|
||||||
|
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||||
|
cacheSubtitleTrack: async () => ({
|
||||||
|
path: '/tmp/subminer-jellyfin-subtitles-active/track.srt',
|
||||||
|
cleanupDir: '/tmp/subminer-jellyfin-subtitles-active',
|
||||||
|
}),
|
||||||
|
cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
preload.cleanupCachedSubtitles();
|
||||||
|
preload.cleanupCachedSubtitles();
|
||||||
|
|
||||||
|
assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-active']]);
|
||||||
|
});
|
||||||
|
|
||||||
test('preload jellyfin subtitles exits quietly when no external tracks', async () => {
|
test('preload jellyfin subtitles exits quietly when no external tracks', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
let waited = false;
|
let waited = false;
|
||||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
makeDeps({
|
||||||
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
|
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
|
||||||
getMpvClient: () => ({ requestProperty: async () => [] }),
|
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||||
sendMpvCommand: (command) => commands.push(command),
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
wait: async () => {
|
wait: async () => {
|
||||||
waited = true;
|
waited = true;
|
||||||
},
|
},
|
||||||
logDebug: () => {},
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
|
||||||
@@ -65,7 +150,8 @@ test('preload jellyfin subtitles exits quietly when no external tracks', async (
|
|||||||
|
|
||||||
test('preload jellyfin subtitles logs debug on failure', async () => {
|
test('preload jellyfin subtitles logs debug on failure', async () => {
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
makeDeps({
|
||||||
listJellyfinSubtitleTracks: async () => {
|
listJellyfinSubtitleTracks: async () => {
|
||||||
throw new Error('network down');
|
throw new Error('network down');
|
||||||
},
|
},
|
||||||
@@ -73,7 +159,8 @@ test('preload jellyfin subtitles logs debug on failure', async () => {
|
|||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
wait: async () => {},
|
wait: async () => {},
|
||||||
logDebug: (message) => logs.push(message),
|
logDebug: (message) => logs.push(message),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,23 @@ type JellyfinSubtitleTrack = {
|
|||||||
deliveryUrl?: string | null;
|
deliveryUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CachedSubtitleTrack = {
|
||||||
|
path: string;
|
||||||
|
cleanupDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
type MpvClientLike = {
|
type MpvClientLike = {
|
||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
|
||||||
|
session: JellyfinSession;
|
||||||
|
clientInfo: JellyfinClientInfo;
|
||||||
|
itemId: string;
|
||||||
|
}) => Promise<void>) & {
|
||||||
|
cleanupCachedSubtitles: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeLang(value: unknown): string {
|
function normalizeLang(value: unknown): string {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -90,13 +103,26 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
sendMpvCommand: (command: Array<string | number>) => void;
|
sendMpvCommand: (command: Array<string | number>) => void;
|
||||||
wait: (ms: number) => Promise<void>;
|
wait: (ms: number) => Promise<void>;
|
||||||
|
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
|
||||||
|
cleanupCachedSubtitles: (dirs: string[]) => void;
|
||||||
logDebug: (message: string, error: unknown) => void;
|
logDebug: (message: string, error: unknown) => void;
|
||||||
}) {
|
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||||
return async (params: {
|
const activeCacheDirs = new Set<string>();
|
||||||
|
|
||||||
|
function cleanupActiveCache(): void {
|
||||||
|
const dirs = [...activeCacheDirs];
|
||||||
|
activeCacheDirs.clear();
|
||||||
|
if (dirs.length === 0) return;
|
||||||
|
deps.cleanupCachedSubtitles(dirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preload = async (params: {
|
||||||
session: JellyfinSession;
|
session: JellyfinSession;
|
||||||
clientInfo: JellyfinClientInfo;
|
clientInfo: JellyfinClientInfo;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
|
cleanupActiveCache();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tracks = await deps.listJellyfinSubtitleTracks(
|
const tracks = await deps.listJellyfinSubtitleTracks(
|
||||||
params.session,
|
params.session,
|
||||||
@@ -117,7 +143,9 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
seenUrls.add(track.deliveryUrl);
|
seenUrls.add(track.deliveryUrl);
|
||||||
const labelBase = (track.title || track.language || '').trim();
|
const labelBase = (track.title || track.language || '').trim();
|
||||||
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
||||||
deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']);
|
const cached = await deps.cacheSubtitleTrack(track);
|
||||||
|
activeCacheDirs.add(cached.cleanupDir);
|
||||||
|
deps.sendMpvCommand(['sub-add', cached.path, 'cached', label, track.language || '']);
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.wait(250);
|
await deps.wait(250);
|
||||||
@@ -154,4 +182,8 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
|
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return Object.assign(preload, {
|
||||||
|
cleanupCachedSubtitles: cleanupActiveCache,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,3 +175,57 @@ test('managed local subtitle selection keeps waiting for primary after early sec
|
|||||||
['set_property', 'sid', 3],
|
['set_property', 'sid', 3],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('managed local subtitle selection keeps pending refresh after early primary-only track list', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const scheduled = new Map<number, () => void>();
|
||||||
|
let nextTimerId = 1;
|
||||||
|
|
||||||
|
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||||
|
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||||
|
{ type: 'sub', id: 4, lang: 'en', title: 'en.srt', external: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected property: ${name}`);
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
getPrimarySubtitleLanguages: () => [],
|
||||||
|
getSecondarySubtitleLanguages: () => [],
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
schedule: (callback) => {
|
||||||
|
const timerId = nextTimerId++;
|
||||||
|
scheduled.set(timerId, callback);
|
||||||
|
return timerId as never;
|
||||||
|
},
|
||||||
|
clearScheduled: (timer) => {
|
||||||
|
scheduled.delete(timer as never);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||||
|
runtime.handleSubtitleTrackListChange([
|
||||||
|
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [['set_property', 'sid', 3]]);
|
||||||
|
assert.equal(scheduled.size, 1);
|
||||||
|
|
||||||
|
const refresh = [...scheduled.values()][0];
|
||||||
|
assert.ok(refresh);
|
||||||
|
refresh();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'sid', 3],
|
||||||
|
['set_property', 'secondary-sid', 4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -212,12 +212,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
pendingTimer = null;
|
pendingTimer = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasAppliedSelectionForCurrentMediaPath = (): boolean =>
|
||||||
|
appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath;
|
||||||
|
|
||||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||||
if (
|
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||||
!currentMediaPath ||
|
|
||||||
(appliedPrimaryMediaPath === currentMediaPath &&
|
|
||||||
appliedSecondaryMediaPath === currentMediaPath)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selection = resolveManagedLocalSubtitleSelection({
|
const selection = resolveManagedLocalSubtitleSelection({
|
||||||
@@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||||
appliedSecondaryMediaPath = currentMediaPath;
|
appliedSecondaryMediaPath = currentMediaPath;
|
||||||
}
|
}
|
||||||
if (appliedPrimaryMediaPath === currentMediaPath) {
|
if (hasAppliedSelectionForCurrentMediaPath()) {
|
||||||
clearPendingTimer();
|
clearPendingTimer();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
|
|
||||||
const scheduleRefresh = (): void => {
|
const scheduleRefresh = (): void => {
|
||||||
clearPendingTimer();
|
clearPendingTimer();
|
||||||
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingTimer = deps.schedule(() => {
|
pendingTimer = deps.schedule(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { openSubsyncManualModal } from './subsync-open';
|
|||||||
import type { SubsyncManualPayload } from '../../types';
|
import type { SubsyncManualPayload } from '../../types';
|
||||||
|
|
||||||
const payload: SubsyncManualPayload = {
|
const payload: SubsyncManualPayload = {
|
||||||
|
ffsubsyncAvailable: true,
|
||||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -361,3 +361,38 @@ test('manual prerelease update check uses prerelease release and launcher channe
|
|||||||
'restart-dialog',
|
'restart-dialog',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual update check keeps current prerelease builds on configured stable channel', async () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
getCurrentVersion: () => '0.15.0-beta.3',
|
||||||
|
checkAppUpdate: async (channel) => {
|
||||||
|
calls.push(`app:${channel}`);
|
||||||
|
return { available: false, version: '0.15.0-beta.3' };
|
||||||
|
},
|
||||||
|
fetchLatestStableRelease: async (channel) => {
|
||||||
|
calls.push(`fetch:${channel}`);
|
||||||
|
return {
|
||||||
|
tag_name: 'v0.14.0',
|
||||||
|
prerelease: false,
|
||||||
|
draft: false,
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
showNoUpdateDialog: async (version) => {
|
||||||
|
calls.push(`no-update:${version}`);
|
||||||
|
},
|
||||||
|
showUpdateAvailableDialog: async () => {
|
||||||
|
throw new Error('unexpected update dialog');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const service = createUpdateService(deps);
|
||||||
|
|
||||||
|
const result = await service.checkForUpdates({ source: 'manual' });
|
||||||
|
|
||||||
|
assert.equal(result.status, 'up-to-date');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'app:stable',
|
||||||
|
'fetch:stable',
|
||||||
|
'no-update:0.15.0-beta.3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
|||||||
|
|
||||||
const subsyncEngineFfsubsync = {
|
const subsyncEngineFfsubsync = {
|
||||||
checked: false,
|
checked: false,
|
||||||
|
disabled: false,
|
||||||
addEventListener: engineFfsubsyncEvents.addEventListener,
|
addEventListener: engineFfsubsyncEvents.addEventListener,
|
||||||
dispatch: engineFfsubsyncEvents.dispatch,
|
dispatch: engineFfsubsyncEvents.dispatch,
|
||||||
};
|
};
|
||||||
@@ -194,6 +195,7 @@ test('manual subsync failure closes during run, then reopens modal with error',
|
|||||||
harness.modal.wireDomEvents();
|
harness.modal.wireDomEvents();
|
||||||
harness.modal.openSubsyncModal({
|
harness.modal.openSubsyncModal({
|
||||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||||
|
ffsubsyncAvailable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
harness.runButton.dispatch('click');
|
harness.runButton.dispatch('click');
|
||||||
@@ -224,3 +226,21 @@ test('manual subsync failure closes during run, then reopens modal with error',
|
|||||||
harness.restoreGlobals();
|
harness.restoreGlobals();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subsync modal disables ffsubsync when payload marks it unavailable', () => {
|
||||||
|
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
harness.modal.openSubsyncModal({
|
||||||
|
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||||
|
ffsubsyncAvailable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
||||||
|
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
|
||||||
|
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true);
|
||||||
|
assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.');
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export function createSubsyncModal(
|
|||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
let ffsubsyncAvailable = true;
|
||||||
|
|
||||||
function setSubsyncStatus(message: string, isError = false): void {
|
function setSubsyncStatus(message: string, isError = false): void {
|
||||||
ctx.dom.subsyncStatus.textContent = message;
|
ctx.dom.subsyncStatus.textContent = message;
|
||||||
ctx.dom.subsyncStatus.classList.toggle('error', isError);
|
ctx.dom.subsyncStatus.classList.toggle('error', isError);
|
||||||
@@ -46,18 +48,24 @@ export function createSubsyncModal(
|
|||||||
|
|
||||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
||||||
ctx.state.subsyncSubmitting = false;
|
ctx.state.subsyncSubmitting = false;
|
||||||
ctx.dom.subsyncRunButton.disabled = false;
|
|
||||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
||||||
|
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||||
|
|
||||||
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
||||||
ctx.dom.subsyncEngineAlass.checked = hasSources;
|
ctx.dom.subsyncEngineAlass.checked = hasSources;
|
||||||
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources;
|
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable;
|
||||||
|
ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable;
|
||||||
|
ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable;
|
||||||
|
|
||||||
renderSubsyncSourceTracks();
|
renderSubsyncSourceTracks();
|
||||||
updateSubsyncSourceVisibility();
|
updateSubsyncSourceVisibility();
|
||||||
|
|
||||||
setSubsyncStatus(
|
setSubsyncStatus(
|
||||||
hasSources
|
!ffsubsyncAvailable && hasSources
|
||||||
|
? 'Choose alass source, then run.'
|
||||||
|
: !ffsubsyncAvailable
|
||||||
|
? 'No source subtitles available for alass.'
|
||||||
|
: hasSources
|
||||||
? 'Choose engine and source, then run.'
|
? 'Choose engine and source, then run.'
|
||||||
: 'No source subtitles available for alass. Use ffsubsync.',
|
: 'No source subtitles available for alass. Use ffsubsync.',
|
||||||
false,
|
false,
|
||||||
@@ -77,7 +85,7 @@ export function createSubsyncModal(
|
|||||||
sourceTrackId: number | null,
|
sourceTrackId: number | null,
|
||||||
message: string,
|
message: string,
|
||||||
): void {
|
): void {
|
||||||
openSubsyncModal({ sourceTracks });
|
openSubsyncModal({ sourceTracks, ffsubsyncAvailable });
|
||||||
|
|
||||||
if (engine === 'alass' && sourceTracks.length > 0) {
|
if (engine === 'alass' && sourceTracks.length > 0) {
|
||||||
ctx.dom.subsyncEngineAlass.checked = true;
|
ctx.dom.subsyncEngineAlass.checked = true;
|
||||||
@@ -85,7 +93,7 @@ export function createSubsyncModal(
|
|||||||
if (Number.isFinite(sourceTrackId)) {
|
if (Number.isFinite(sourceTrackId)) {
|
||||||
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
|
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (ffsubsyncAvailable) {
|
||||||
ctx.dom.subsyncEngineAlass.checked = false;
|
ctx.dom.subsyncEngineAlass.checked = false;
|
||||||
ctx.dom.subsyncEngineFfsubsync.checked = true;
|
ctx.dom.subsyncEngineFfsubsync.checked = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface SubsyncSourceTrack {
|
|||||||
|
|
||||||
export interface SubsyncManualPayload {
|
export interface SubsyncManualPayload {
|
||||||
sourceTracks: SubsyncSourceTrack[];
|
sourceTracks: SubsyncSourceTrack[];
|
||||||
|
ffsubsyncAvailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsyncManualRunRequest {
|
export interface SubsyncManualRunRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user