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:
@@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan
|
||||
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', () => {
|
||||
let windowAllClosedHandler: (() => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
|
||||
@@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
}
|
||||
|
||||
deps.whenReady(async () => {
|
||||
await deps.onReady();
|
||||
appReadyRuntimeComplete = true;
|
||||
flushPendingSecondInstanceCommands();
|
||||
try {
|
||||
await deps.onReady();
|
||||
} finally {
|
||||
appReadyRuntimeComplete = true;
|
||||
flushPendingSecondInstanceCommands();
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -301,6 +301,33 @@ export type {
|
||||
VocabularyStatsRow,
|
||||
} 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 {
|
||||
private readonly logger = createLogger('main:immersion-tracker');
|
||||
private readonly db: DatabaseSync;
|
||||
@@ -337,6 +364,7 @@ export class ImmersionTrackerService {
|
||||
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
||||
private readonly recordedSubtitleKeys = new Set<string>();
|
||||
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
||||
private readonly mediaPathAliases = new Map<string, string>();
|
||||
private readonly resolveLegacyVocabularyPos:
|
||||
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
|
||||
| undefined;
|
||||
@@ -1115,8 +1143,85 @@ export class ImmersionTrackerService {
|
||||
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 {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
const rawPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
this.logger.info(
|
||||
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
|
||||
@@ -1164,7 +1269,7 @@ export class ImmersionTrackerService {
|
||||
if (youtubeVideoId) {
|
||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||
} else {
|
||||
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
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.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.subtitleStreamIndex, 9);
|
||||
assert.equal(plan.startTimeTicks, 35_000_000);
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan {
|
||||
mode: 'direct' | 'transcode';
|
||||
url: string;
|
||||
title: string;
|
||||
itemTitle: string;
|
||||
seriesTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
startTimeTicks: number;
|
||||
audioStreamIndex: 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 {
|
||||
const itemTitle = getItemTitle(item);
|
||||
if (item.Type === 'Episode') {
|
||||
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
|
||||
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
|
||||
const prefix = item.SeriesName ? `${item.SeriesName} ` : '';
|
||||
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim();
|
||||
const seriesTitle = getSeriesTitle(item);
|
||||
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 {
|
||||
@@ -521,10 +535,16 @@ export async function resolvePlaybackPlan(
|
||||
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
|
||||
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
|
||||
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 = {
|
||||
mode: 'transcode',
|
||||
url: '',
|
||||
title: getDisplayTitle(item),
|
||||
itemTitle,
|
||||
seriesTitle,
|
||||
seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null,
|
||||
episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null,
|
||||
startTimeTicks,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
|
||||
@@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
|
||||
test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let ffsubsyncAvailable: boolean | null = null;
|
||||
let inProgressState: boolean | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
@@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.equal(ffsubsyncAvailable, true);
|
||||
assert.ok(osd.includes('Subsync: choose engine and source'));
|
||||
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 () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
@@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps
|
||||
const client = getMpvClientForSubsync(deps);
|
||||
const context = await gatherSubsyncContext(client);
|
||||
const payload: SubsyncManualPayload = {
|
||||
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
|
||||
sourceTracks: context.sourceTracks
|
||||
.filter((track) => typeof track.id === 'number')
|
||||
.map((track) => ({
|
||||
|
||||
+51
@@ -2747,6 +2747,7 @@ const {
|
||||
reportJellyfinRemoteStopped,
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
cleanupJellyfinSubtitleCache,
|
||||
runJellyfinCommand,
|
||||
openJellyfinSetupWindow,
|
||||
getJellyfinClientInfo,
|
||||
@@ -2770,6 +2771,15 @@ const {
|
||||
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||
platform: process.platform,
|
||||
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(),
|
||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
||||
@@ -2805,6 +2815,41 @@ const {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
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) => {
|
||||
logger.debug(message, error);
|
||||
},
|
||||
@@ -2823,6 +2868,7 @@ const {
|
||||
},
|
||||
),
|
||||
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
|
||||
showVisibleOverlay: () => setVisibleOverlayVisible(true),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
armQuitOnDisconnect: () => {
|
||||
jellyfinPlayQuitOnDisconnectArmed = false;
|
||||
@@ -2846,6 +2892,10 @@ const {
|
||||
showMpvOsd: (text) => {
|
||||
showMpvOsd(text);
|
||||
},
|
||||
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
|
||||
},
|
||||
},
|
||||
remoteComposerOptions: {
|
||||
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
|
||||
@@ -3615,6 +3665,7 @@ const {
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
appState.discordPresenceService = null;
|
||||
|
||||
@@ -313,7 +313,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ sourceTracks: [] },
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
@@ -459,7 +459,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ sourceTracks: [] },
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
|
||||
@@ -40,13 +40,15 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 30);
|
||||
assert.equal(calls.length, 31);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
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-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
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-yomitan-settings-window'));
|
||||
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('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
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,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
|
||||
cleanupCachedSubtitles: () => {},
|
||||
logDebug: () => {},
|
||||
},
|
||||
playJellyfinItemInMpvMainDeps: {
|
||||
@@ -58,11 +60,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
mode: 'direct',
|
||||
url: 'https://example.test/video.m3u8',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => undefined,
|
||||
@@ -189,6 +196,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
||||
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
|
||||
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
|
||||
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.runJellyfinCommand, 'function');
|
||||
|
||||
@@ -142,6 +142,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemoteGeneralCommand'];
|
||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
|
||||
@@ -280,6 +281,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
handleJellyfinRemotePlaystate,
|
||||
handleJellyfinRemoteGeneralCommand,
|
||||
playJellyfinItemInMpv,
|
||||
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
runJellyfinCommand,
|
||||
|
||||
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
|
||||
@@ -11,11 +11,16 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
url: 'u',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
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',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
},
|
||||
);
|
||||
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
|
||||
deps.showVisibleOverlay();
|
||||
deps.sendMpvCommand(['show-text', 'x']);
|
||||
deps.armQuitOnDisconnect();
|
||||
deps.schedule(() => {}, 500);
|
||||
@@ -85,6 +95,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'defaults',
|
||||
'visible-overlay',
|
||||
'cmd:show-text',
|
||||
'arm',
|
||||
'schedule:500',
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params),
|
||||
applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient),
|
||||
showVisibleOverlay: () => deps.showVisibleOverlay(),
|
||||
sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command),
|
||||
armQuitOnDisconnect: () => deps.armQuitOnDisconnect(),
|
||||
schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs),
|
||||
@@ -19,5 +20,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
||||
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');
|
||||
},
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
@@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
const calls: string[] = [];
|
||||
const activeStates: Array<Record<string, unknown>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const statsMetadata: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
@@ -59,11 +61,16 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
startTimeTicks: 12_000_000,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (callback, delayMs) => {
|
||||
@@ -75,6 +82,8 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
recordJellyfinPlaybackMetadata: (metadata) =>
|
||||
statsMetadata.push(metadata as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await handler({
|
||||
@@ -87,7 +96,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
assert.deepEqual(commands.slice(0, 5), [
|
||||
['set_property', 'sub-auto', 'no'],
|
||||
['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'],
|
||||
['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.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('preload'));
|
||||
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(reportPayloads.length, 1);
|
||||
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 () => {
|
||||
@@ -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',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
|
||||
@@ -16,6 +16,16 @@ type ActivePlaybackState = {
|
||||
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 {
|
||||
if (typeof startTimeTicksOverride !== 'number') return url;
|
||||
try {
|
||||
@@ -43,6 +53,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
subtitleStreamIndex?: number | null;
|
||||
}) => Promise<JellyfinPlaybackPlan>;
|
||||
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
|
||||
showVisibleOverlay: () => void;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
armQuitOnDisconnect: () => void;
|
||||
schedule: (callback: () => void, delayMs: number) => void;
|
||||
@@ -63,6 +74,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
eventName: 'start';
|
||||
}) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
session: JellyfinAuthSession;
|
||||
@@ -94,15 +106,20 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
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']);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
deps.sendMpvCommand([
|
||||
'set_property',
|
||||
'force-media-title',
|
||||
`[Jellyfin/${plan.mode}] ${plan.title}`,
|
||||
]);
|
||||
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.schedule(() => {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
@@ -116,6 +133,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
||||
}
|
||||
|
||||
deps.showVisibleOverlay();
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
|
||||
@@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
getLaunchMode: () => 'fullscreen',
|
||||
platform: 'darwin',
|
||||
execPath: '/tmp/subminer',
|
||||
getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
source: null,
|
||||
message: null,
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--no-config'],
|
||||
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.platform, 'darwin');
|
||||
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.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||
deps.removeSocketPath('/tmp/mpv.sock');
|
||||
|
||||
@@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
execPath: deps.execPath,
|
||||
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
|
||||
getInstalledPluginDetection: deps.getInstalledPluginDetection,
|
||||
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
||||
defaultMpvLogPath: deps.defaultMpvLogPath,
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
|
||||
@@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
getLaunchMode: () => 'maximized',
|
||||
platform: 'darwin',
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
getRuntimePluginEntrypoint: () =>
|
||||
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
@@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
assert.equal(spawnedArgs.length, 1);
|
||||
assert.ok(spawnedArgs[0]!.includes('--window-maximized=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(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/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
let autoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let launchCalls = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type SubminerPluginRuntimeScriptOptConfig,
|
||||
} from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
@@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = {
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
@@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
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 = [
|
||||
...deps.defaultMpvArgs,
|
||||
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
||||
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
|
||||
'--idle=yes',
|
||||
scriptOpts,
|
||||
`--log-file=${deps.defaultMpvLogPath}`,
|
||||
|
||||
@@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
|
||||
<meta charset="utf-8" />
|
||||
<title>Jellyfin Setup</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
|
||||
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
|
||||
main { padding: 22px; }
|
||||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
|
||||
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
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; }
|
||||
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
|
||||
button:disabled { cursor: wait; opacity: .68; }
|
||||
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
|
||||
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--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; }
|
||||
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
|
||||
.status.success { color: var(--accent); }
|
||||
.status.error { color: var(--danger); }
|
||||
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
|
||||
.status {
|
||||
min-height: 18px;
|
||||
margin-top: 14px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -14,6 +14,11 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
wait: async () => {
|
||||
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}`),
|
||||
})();
|
||||
|
||||
@@ -21,6 +26,8 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'auto']);
|
||||
await deps.wait(1);
|
||||
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
|
||||
deps.cleanupCachedSubtitles(['/tmp/subs']);
|
||||
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(),
|
||||
sendMpvCommand: (command) => deps.sendMpvCommand(command),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,47 +15,132 @@ const clientInfo = {
|
||||
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 preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
{ index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
|
||||
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
{ index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{ type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true },
|
||||
{ type: 'sub', id: 6, lang: 'eng', title: 'English', external: true },
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {},
|
||||
logDebug: () => {},
|
||||
});
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'],
|
||||
['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'],
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'],
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'cached', 'English SDH', 'eng'],
|
||||
['set_property', 'sid', 5],
|
||||
['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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let waited = false;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
|
||||
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {
|
||||
waited = true;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }],
|
||||
getMpvClient: () => ({ requestProperty: async () => [] }),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: async () => {
|
||||
waited = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
@@ -65,15 +150,17 @@ test('preload jellyfin subtitles exits quietly when no external tracks', async (
|
||||
|
||||
test('preload jellyfin subtitles logs debug on failure', async () => {
|
||||
const logs: string[] = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler({
|
||||
listJellyfinSubtitleTracks: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
logDebug: (message) => logs.push(message),
|
||||
});
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
logDebug: (message) => logs.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
|
||||
@@ -18,10 +18,23 @@ type JellyfinSubtitleTrack = {
|
||||
deliveryUrl?: string | null;
|
||||
};
|
||||
|
||||
type CachedSubtitleTrack = {
|
||||
path: string;
|
||||
cleanupDir: string;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}) => Promise<void>) & {
|
||||
cleanupCachedSubtitles: () => void;
|
||||
};
|
||||
|
||||
function normalizeLang(value: unknown): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
@@ -90,13 +103,26 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
|
||||
cleanupCachedSubtitles: (dirs: string[]) => void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (params: {
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
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;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
cleanupActiveCache();
|
||||
|
||||
try {
|
||||
const tracks = await deps.listJellyfinSubtitleTracks(
|
||||
params.session,
|
||||
@@ -117,7 +143,9 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
seenUrls.add(track.deliveryUrl);
|
||||
const labelBase = (track.title || track.language || '').trim();
|
||||
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);
|
||||
@@ -154,4 +182,8 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
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],
|
||||
]);
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const hasAppliedSelectionForCurrentMediaPath = (): boolean =>
|
||||
appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath;
|
||||
|
||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||
if (
|
||||
!currentMediaPath ||
|
||||
(appliedPrimaryMediaPath === currentMediaPath &&
|
||||
appliedSecondaryMediaPath === currentMediaPath)
|
||||
) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
@@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||
appliedSecondaryMediaPath = currentMediaPath;
|
||||
}
|
||||
if (appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (hasAppliedSelectionForCurrentMediaPath()) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
};
|
||||
@@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
|
||||
const scheduleRefresh = (): void => {
|
||||
clearPendingTimer();
|
||||
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { openSubsyncManualModal } from './subsync-open';
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
|
||||
const payload: SubsyncManualPayload = {
|
||||
ffsubsyncAvailable: true,
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
};
|
||||
|
||||
|
||||
@@ -361,3 +361,38 @@ test('manual prerelease update check uses prerelease release and launcher channe
|
||||
'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 = {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
addEventListener: engineFfsubsyncEvents.addEventListener,
|
||||
dispatch: engineFfsubsyncEvents.dispatch,
|
||||
};
|
||||
@@ -194,6 +195,7 @@ test('manual subsync failure closes during run, then reopens modal with error',
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openSubsyncModal({
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
ffsubsyncAvailable: true,
|
||||
});
|
||||
|
||||
harness.runButton.dispatch('click');
|
||||
@@ -224,3 +226,21 @@ test('manual subsync failure closes during run, then reopens modal with error',
|
||||
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;
|
||||
},
|
||||
) {
|
||||
let ffsubsyncAvailable = true;
|
||||
|
||||
function setSubsyncStatus(message: string, isError = false): void {
|
||||
ctx.dom.subsyncStatus.textContent = message;
|
||||
ctx.dom.subsyncStatus.classList.toggle('error', isError);
|
||||
@@ -46,20 +48,26 @@ export function createSubsyncModal(
|
||||
|
||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
|
||||
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
||||
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();
|
||||
updateSubsyncSourceVisibility();
|
||||
|
||||
setSubsyncStatus(
|
||||
hasSources
|
||||
? 'Choose engine and source, then run.'
|
||||
: 'No source subtitles available for alass. Use ffsubsync.',
|
||||
!ffsubsyncAvailable && hasSources
|
||||
? 'Choose alass source, then run.'
|
||||
: !ffsubsyncAvailable
|
||||
? 'No source subtitles available for alass.'
|
||||
: hasSources
|
||||
? 'Choose engine and source, then run.'
|
||||
: 'No source subtitles available for alass. Use ffsubsync.',
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -77,7 +85,7 @@ export function createSubsyncModal(
|
||||
sourceTrackId: number | null,
|
||||
message: string,
|
||||
): void {
|
||||
openSubsyncModal({ sourceTracks });
|
||||
openSubsyncModal({ sourceTracks, ffsubsyncAvailable });
|
||||
|
||||
if (engine === 'alass' && sourceTracks.length > 0) {
|
||||
ctx.dom.subsyncEngineAlass.checked = true;
|
||||
@@ -85,7 +93,7 @@ export function createSubsyncModal(
|
||||
if (Number.isFinite(sourceTrackId)) {
|
||||
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
|
||||
}
|
||||
} else {
|
||||
} else if (ffsubsyncAvailable) {
|
||||
ctx.dom.subsyncEngineAlass.checked = false;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = true;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface SubsyncSourceTrack {
|
||||
|
||||
export interface SubsyncManualPayload {
|
||||
sourceTracks: SubsyncSourceTrack[];
|
||||
ffsubsyncAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface SubsyncManualRunRequest {
|
||||
|
||||
Reference in New Issue
Block a user