mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-05 00:12:06 -07:00
[codex] Make Windows mpv shortcut self-contained (#40)
This commit is contained in:
@@ -369,7 +369,8 @@ export class AnkiIntegration {
|
||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||
},
|
||||
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
|
||||
findDuplicateNoteIds: (expression, noteInfo) =>
|
||||
this.findDuplicateNoteIds(expression, noteInfo),
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||
},
|
||||
@@ -1082,10 +1083,7 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private async findDuplicateNoteIds(
|
||||
expression: string,
|
||||
noteInfo: NoteInfo,
|
||||
): Promise<number[]> {
|
||||
private async findDuplicateNoteIds(expression: string, noteInfo: NoteInfo): Promise<number[]> {
|
||||
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
||||
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
|
||||
@@ -162,7 +162,8 @@ export class AnkiConnectProxyServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
||||
const forwardedBody =
|
||||
req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
||||
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
||||
const contentType =
|
||||
typeof req.headers['content-type'] === 'string'
|
||||
@@ -272,7 +273,9 @@ export class AnkiConnectProxyServer {
|
||||
|
||||
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
||||
const action =
|
||||
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
return requestJson;
|
||||
}
|
||||
@@ -301,9 +304,13 @@ export class AnkiConnectProxyServer {
|
||||
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
||||
? params.subminerDuplicateNoteIds
|
||||
: [];
|
||||
return [...new Set(rawNoteIds.filter((entry): entry is number => {
|
||||
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||
}))].sort((left, right) => left - right);
|
||||
return [
|
||||
...new Set(
|
||||
rawNoteIds.filter((entry): entry is number => {
|
||||
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||
}),
|
||||
),
|
||||
].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
||||
|
||||
@@ -113,10 +113,7 @@ interface CardCreationDeps {
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
trackLastAddedNoteId?: (noteId: number) => void;
|
||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
findDuplicateNoteIds?: (
|
||||
expression: string,
|
||||
noteInfo: CardCreationNoteInfo,
|
||||
) => Promise<number[]>;
|
||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||
}
|
||||
|
||||
@@ -573,10 +570,7 @@ export class CardCreationService {
|
||||
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'Failed to capture pre-add duplicate note ids:',
|
||||
(error as Error).message,
|
||||
);
|
||||
log.warn('Failed to capture pre-add duplicate note ids:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,9 +722,7 @@ export class CardCreationService {
|
||||
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||
return {
|
||||
noteId: -1,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([name, value]) => [name, { value }]),
|
||||
),
|
||||
fields: Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value }])),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -307,21 +307,27 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
|
||||
};
|
||||
|
||||
let notesInfoCalls = 0;
|
||||
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
|
||||
findNotes: async () => [200],
|
||||
notesInfo: async (noteIds) => {
|
||||
notesInfoCalls += 1;
|
||||
return noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
}));
|
||||
const duplicateIds = await findDuplicateNoteIds(
|
||||
'貴様',
|
||||
100,
|
||||
currentNote,
|
||||
{
|
||||
findNotes: async () => [200],
|
||||
notesInfo: async (noteIds) => {
|
||||
notesInfoCalls += 1;
|
||||
return noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
}));
|
||||
},
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
},
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
}, 0);
|
||||
0,
|
||||
);
|
||||
|
||||
assert.deepEqual(duplicateIds, []);
|
||||
assert.equal(notesInfoCalls, 0);
|
||||
|
||||
@@ -24,13 +24,7 @@ export async function findDuplicateNote(
|
||||
noteInfo: NoteInfo,
|
||||
deps: DuplicateDetectionDeps,
|
||||
): Promise<number | null> {
|
||||
const duplicateNoteIds = await findDuplicateNoteIds(
|
||||
expression,
|
||||
excludeNoteId,
|
||||
noteInfo,
|
||||
deps,
|
||||
1,
|
||||
);
|
||||
const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps, 1);
|
||||
return duplicateNoteIds[0] ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv/);
|
||||
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
|
||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
|
||||
@@ -12,7 +12,7 @@ ${B}Usage:${R} subminer ${D}[command] [options]${R}
|
||||
${B}Session${R}
|
||||
--background Start in tray/background mode
|
||||
--start Connect to mpv and launch overlay
|
||||
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
||||
--launch-mpv ${D}[targets...]${R} Launch mpv with SubMiner defaults and exit
|
||||
--stop Stop the running instance
|
||||
--stats Open the stats dashboard in your browser
|
||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||
|
||||
@@ -2138,7 +2138,7 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for YouTube auto-loading\./,
|
||||
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
|
||||
);
|
||||
assert.doesNotMatch(output, /"mode": "automatic"/);
|
||||
assert.doesNotMatch(output, /"fixWithAi": false/);
|
||||
|
||||
@@ -35,7 +35,7 @@ const {
|
||||
startupWarmups,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
@@ -60,6 +60,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
|
||||
@@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
| 'ankiConnect'
|
||||
| 'jimaku'
|
||||
| 'anilist'
|
||||
| 'mpv'
|
||||
| 'yomitan'
|
||||
| 'jellyfin'
|
||||
| 'discordPresence'
|
||||
@@ -90,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
mpv: {
|
||||
executablePath: '',
|
||||
},
|
||||
anilist: {
|
||||
enabled: false,
|
||||
accessToken: '',
|
||||
|
||||
@@ -28,6 +28,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'ankiConnect.enabled',
|
||||
'anilist.characterDictionary.enabled',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
@@ -48,6 +49,7 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
'yomitan',
|
||||
'mpv',
|
||||
'immersionTracking',
|
||||
];
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@ export function buildCoreConfigOptionRegistry(
|
||||
path: 'youtube.primarySubLanguages',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtube.primarySubLanguages.join(','),
|
||||
description: 'Comma-separated primary subtitle language priority for YouTube auto-loading.',
|
||||
description:
|
||||
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
|
||||
},
|
||||
{
|
||||
path: 'controller.enabled',
|
||||
|
||||
@@ -238,6 +238,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
|
||||
},
|
||||
{
|
||||
path: 'mpv.executablePath',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.executablePath,
|
||||
description:
|
||||
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'Secondary Subtitles',
|
||||
description: [
|
||||
'Dual subtitle track options.',
|
||||
'Used by the YouTube subtitle loading flow as secondary language preferences.',
|
||||
'Used by managed subtitle loading as secondary language preferences for local and YouTube playback.',
|
||||
],
|
||||
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
||||
key: 'secondarySub',
|
||||
@@ -131,7 +131,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
},
|
||||
{
|
||||
title: 'YouTube Playback Settings',
|
||||
description: ['Defaults for SubMiner YouTube subtitle loading and languages.'],
|
||||
description: [
|
||||
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
|
||||
],
|
||||
key: 'youtube',
|
||||
},
|
||||
{
|
||||
@@ -153,6 +155,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'yomitan',
|
||||
},
|
||||
{
|
||||
title: 'MPV Launcher',
|
||||
description: [
|
||||
'Optional mpv.exe override for Windows playback entry points.',
|
||||
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||
],
|
||||
key: 'mpv',
|
||||
},
|
||||
{
|
||||
title: 'Jellyfin',
|
||||
description: [
|
||||
|
||||
31
src/config/resolve/integrations.test.ts
Normal file
31
src/config/resolve/integrations.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
|
||||
test('resolveConfig trims configured mpv executable path', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
mpv: {
|
||||
executablePath: ' C:\\Program Files\\mpv\\mpv.exe ',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.mpv.executablePath, 'C:\\Program Files\\mpv\\mpv.exe');
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig warns for invalid mpv executable path type', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
mpv: {
|
||||
executablePath: 42 as never,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.mpv.executablePath, '');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.deepEqual(warnings[0], {
|
||||
path: 'mpv.executablePath',
|
||||
value: 42,
|
||||
fallback: '',
|
||||
message: 'Expected string.',
|
||||
});
|
||||
});
|
||||
@@ -228,6 +228,22 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.mpv)) {
|
||||
const executablePath = asString(src.mpv.executablePath);
|
||||
if (executablePath !== undefined) {
|
||||
resolved.mpv.executablePath = executablePath.trim();
|
||||
} else if (src.mpv.executablePath !== undefined) {
|
||||
warn(
|
||||
'mpv.executablePath',
|
||||
src.mpv.executablePath,
|
||||
resolved.mpv.executablePath,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
} else if (src.mpv !== undefined) {
|
||||
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.jellyfin)) {
|
||||
const enabled = asBoolean(src.jellyfin.enabled);
|
||||
if (enabled !== undefined) {
|
||||
|
||||
@@ -83,7 +83,9 @@ const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinitio
|
||||
},
|
||||
};
|
||||
|
||||
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
|
||||
function resolvePresenceStyle(
|
||||
preset: DiscordPresenceStylePreset | undefined,
|
||||
): PresenceStyleDefinition {
|
||||
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
|
||||
}
|
||||
|
||||
@@ -130,9 +132,7 @@ export function buildDiscordPresenceActivity(
|
||||
const status = buildStatus(snapshot);
|
||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||
const details =
|
||||
snapshot.connected && snapshot.mediaPath
|
||||
? trimField(title)
|
||||
: style.fallbackDetails;
|
||||
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||
const state =
|
||||
snapshot.connected && snapshot.mediaPath
|
||||
@@ -157,10 +157,7 @@ export function buildDiscordPresenceActivity(
|
||||
if (style.smallImageText.trim().length > 0) {
|
||||
activity.smallImageText = trimField(style.smallImageText.trim());
|
||||
}
|
||||
if (
|
||||
style.buttonLabel.trim().length > 0 &&
|
||||
/^https?:\/\//.test(style.buttonUrl.trim())
|
||||
) {
|
||||
if (style.buttonLabel.trim().length > 0 && /^https?:\/\//.test(style.buttonUrl.trim())) {
|
||||
activity.buttons = [
|
||||
{
|
||||
label: trimField(style.buttonLabel.trim(), 32),
|
||||
|
||||
@@ -380,42 +380,22 @@ export class ImmersionTrackerService {
|
||||
};
|
||||
};
|
||||
|
||||
const eventsRetention = daysToRetentionWindow(
|
||||
retention.eventsDays,
|
||||
7,
|
||||
3650,
|
||||
);
|
||||
const telemetryRetention = daysToRetentionWindow(
|
||||
retention.telemetryDays,
|
||||
30,
|
||||
3650,
|
||||
);
|
||||
const sessionsRetention = daysToRetentionWindow(
|
||||
retention.sessionsDays,
|
||||
30,
|
||||
3650,
|
||||
);
|
||||
const eventsRetention = daysToRetentionWindow(retention.eventsDays, 7, 3650);
|
||||
const telemetryRetention = daysToRetentionWindow(retention.telemetryDays, 30, 3650);
|
||||
const sessionsRetention = daysToRetentionWindow(retention.sessionsDays, 30, 3650);
|
||||
this.eventsRetentionMs = eventsRetention.ms;
|
||||
this.eventsRetentionDays = eventsRetention.days;
|
||||
this.telemetryRetentionMs = telemetryRetention.ms;
|
||||
this.telemetryRetentionDays = telemetryRetention.days;
|
||||
this.sessionsRetentionMs = sessionsRetention.ms;
|
||||
this.sessionsRetentionDays = sessionsRetention.days;
|
||||
this.dailyRollupRetentionMs = daysToRetentionWindow(
|
||||
retention.dailyRollupsDays,
|
||||
365,
|
||||
36500,
|
||||
).ms;
|
||||
this.dailyRollupRetentionMs = daysToRetentionWindow(retention.dailyRollupsDays, 365, 36500).ms;
|
||||
this.monthlyRollupRetentionMs = daysToRetentionWindow(
|
||||
retention.monthlyRollupsDays,
|
||||
5 * 365,
|
||||
36500,
|
||||
).ms;
|
||||
this.vacuumIntervalMs = daysToRetentionWindow(
|
||||
retention.vacuumIntervalDays,
|
||||
7,
|
||||
3650,
|
||||
).ms;
|
||||
this.vacuumIntervalMs = daysToRetentionWindow(retention.vacuumIntervalDays, 7, 3650).ms;
|
||||
this.db = new Database(this.dbPath);
|
||||
applyPragmas(this.db);
|
||||
ensureSchema(this.db);
|
||||
|
||||
@@ -975,79 +975,79 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
);
|
||||
}
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const insertMonthlyRollup = db.prepare(
|
||||
`
|
||||
);
|
||||
const insertMonthlyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
);
|
||||
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'二月',
|
||||
'二月',
|
||||
'にがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
).run(
|
||||
'二月',
|
||||
'二月',
|
||||
'にがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'三月',
|
||||
'三月',
|
||||
'さんがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
).run(
|
||||
'三月',
|
||||
'三月',
|
||||
'さんがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.newWords.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.episodes.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.lookups.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.newWords.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.episodes.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.lookups.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
@@ -1230,18 +1230,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'猫',
|
||||
'猫',
|
||||
'ねこ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
String(twoDaysAgo),
|
||||
String(twoDaysAgo),
|
||||
1,
|
||||
);
|
||||
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', String(twoDaysAgo), String(twoDaysAgo), 1);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.newWordsToday, 1);
|
||||
|
||||
@@ -82,12 +82,9 @@ function hasRetainedPriorSession(
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(
|
||||
videoId,
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
currentSessionId,
|
||||
) as { found: number } | null;
|
||||
.get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as {
|
||||
found: number;
|
||||
} | null;
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
@@ -150,7 +147,7 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
|
||||
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
|
||||
}
|
||||
|
||||
function rebuildLifetimeSummariesInternal(
|
||||
|
||||
@@ -126,9 +126,9 @@ test('pruneRawRetention skips disabled retention windows', () => {
|
||||
const remainingTelemetry = db
|
||||
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
|
||||
.get() as { count: number };
|
||||
const remainingSessions = db
|
||||
.prepare('SELECT COUNT(*) AS count FROM imm_sessions')
|
||||
.get() as { count: number };
|
||||
const remainingSessions = db.prepare('SELECT COUNT(*) AS count FROM imm_sessions').get() as {
|
||||
count: number;
|
||||
};
|
||||
|
||||
assert.equal(result.deletedSessionEvents, 0);
|
||||
assert.equal(result.deletedTelemetryRows, 0);
|
||||
|
||||
@@ -56,10 +56,7 @@ export function pruneRawRetention(
|
||||
sessionsRetentionDays?: number;
|
||||
},
|
||||
): RawRetentionResult {
|
||||
const resolveCutoff = (
|
||||
retentionMs: number,
|
||||
retentionDays: number | undefined,
|
||||
): string => {
|
||||
const resolveCutoff = (retentionMs: number, retentionDays: number | undefined): string => {
|
||||
if (retentionDays !== undefined) {
|
||||
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
|
||||
}
|
||||
@@ -68,9 +65,11 @@ export function pruneRawRetention(
|
||||
|
||||
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
||||
? (
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
|
||||
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
|
||||
) as { changes: number }
|
||||
db
|
||||
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes
|
||||
: 0;
|
||||
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)
|
||||
|
||||
@@ -150,9 +150,11 @@ export function getSessionEvents(
|
||||
ORDER BY ts_ms ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
|
||||
tsMs: number | string;
|
||||
}>;
|
||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
|
||||
SessionEventRow & {
|
||||
tsMs: number | string;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
||||
|
||||
@@ -355,9 +355,7 @@ export function upsertCoverArt(
|
||||
const fetchedAtMs = toDbTimestamp(nowMs());
|
||||
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
||||
const computedCoverBlobHash =
|
||||
coverBlob && coverBlob.length > 0
|
||||
? createHash('sha256').update(coverBlob).digest('hex')
|
||||
: null;
|
||||
coverBlob && coverBlob.length > 0 ? createHash('sha256').update(coverBlob).digest('hex') : null;
|
||||
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
|
||||
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
|
||||
coverBlobHash = existing?.coverBlobHash ?? null;
|
||||
|
||||
@@ -39,10 +39,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
||||
ORDER BY s.started_at_ms DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const rows = prepared.all(limit) as Array<SessionSummaryQueryRow & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string | null;
|
||||
}>;
|
||||
const rows = prepared.all(limit) as Array<
|
||||
SessionSummaryQueryRow & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string | null;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
||||
@@ -69,19 +71,21 @@ export function getSessionTimeline(
|
||||
`;
|
||||
|
||||
if (limit === undefined) {
|
||||
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}>;
|
||||
const rows = db.prepare(select).all(sessionId) as Array<
|
||||
SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
const rows = db
|
||||
.prepare(`${select}\n LIMIT ?`)
|
||||
.all(sessionId, limit) as Array<SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}>;
|
||||
const rows = db.prepare(`${select}\n LIMIT ?`).all(sessionId, limit) as Array<
|
||||
SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||
|
||||
@@ -359,10 +359,7 @@ function getNumericCalendarValue(
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
|
||||
export function getLocalEpochDay(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalEpochDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -375,10 +372,7 @@ export function getLocalEpochDay(
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalMonthKey(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalMonthKey(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -391,10 +385,7 @@ export function getLocalMonthKey(
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalDayOfWeek(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalDayOfWeek(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -407,10 +398,7 @@ export function getLocalDayOfWeek(
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalHourOfDay(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
export function getLocalHourOfDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
@@ -458,7 +446,8 @@ export function getShiftedLocalDayTimestamp(
|
||||
dayOffset: number,
|
||||
): string {
|
||||
const normalizedDayOffset = Math.trunc(dayOffset);
|
||||
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
||||
const modifier =
|
||||
normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
|
||||
@@ -87,7 +87,20 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
||||
'90d': 90,
|
||||
};
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const MONTH_NAMES = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
@@ -101,7 +114,11 @@ function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
|
||||
}
|
||||
const currentTimestamp = currentDbTimestamp();
|
||||
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
|
||||
const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
|
||||
const cutoffMs = getShiftedLocalDayTimestamp(
|
||||
db,
|
||||
currentTimestamp,
|
||||
-(TREND_DAY_LIMITS[range] - 1),
|
||||
);
|
||||
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
|
||||
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
|
||||
const currentYear = Math.floor(currentMonthKey / 100);
|
||||
@@ -630,8 +647,10 @@ export function getTrendsDashboard(
|
||||
|
||||
const animePerDay = {
|
||||
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
||||
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
|
||||
rollup.totalActiveMin,
|
||||
watchTime: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
titlesByVideoId,
|
||||
(rollup) => rollup.totalActiveMin,
|
||||
),
|
||||
cards: buildPerAnimeFromDailyRollups(
|
||||
dailyRollups,
|
||||
|
||||
@@ -184,6 +184,39 @@ test('dispatchMpvProtocolMessage sets secondary subtitle track based on track li
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage prefers the already selected matching secondary track', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{
|
||||
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
data: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'ja.srt',
|
||||
selected: false,
|
||||
external: true,
|
||||
'external-filename': '/tmp/dupe.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 3,
|
||||
lang: 'ja',
|
||||
title: 'ja.srt',
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': '/tmp/dupe.srt',
|
||||
},
|
||||
],
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
|
||||
@@ -93,6 +93,101 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
id: number;
|
||||
lang: string;
|
||||
title: string;
|
||||
selected: boolean;
|
||||
external: boolean;
|
||||
externalFilename: string | null;
|
||||
};
|
||||
|
||||
function normalizeSubtitleTrackCandidate(
|
||||
track: Record<string, unknown>,
|
||||
): SubtitleTrackCandidate | null {
|
||||
const id =
|
||||
typeof track.id === 'number'
|
||||
? track.id
|
||||
: typeof track.id === 'string'
|
||||
? Number(track.id.trim())
|
||||
: Number.NaN;
|
||||
if (!Number.isInteger(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const externalFilename =
|
||||
typeof track['external-filename'] === 'string' && track['external-filename'].trim().length > 0
|
||||
? track['external-filename'].trim()
|
||||
: typeof track.external_filename === 'string' && track.external_filename.trim().length > 0
|
||||
? track.external_filename.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
id,
|
||||
lang: String(track.lang || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
title: String(track.title || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
selected: track.selected === true,
|
||||
external: track.external === true,
|
||||
externalFilename,
|
||||
};
|
||||
}
|
||||
|
||||
function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
|
||||
if (track.externalFilename) {
|
||||
return `external:${track.externalFilename.toLowerCase()}`;
|
||||
}
|
||||
if (track.title.length > 0) {
|
||||
return `title:${track.title}`;
|
||||
}
|
||||
return `id:${track.id}`;
|
||||
}
|
||||
|
||||
function pickSecondarySubtitleTrackId(
|
||||
tracks: Array<Record<string, unknown>>,
|
||||
preferredLanguages: string[],
|
||||
): number | null {
|
||||
const normalizedLanguages = preferredLanguages
|
||||
.map((language) => language.trim().toLowerCase())
|
||||
.filter((language) => language.length > 0);
|
||||
if (normalizedLanguages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subtitleTracks = tracks
|
||||
.filter((track) => track.type === 'sub')
|
||||
.map(normalizeSubtitleTrackCandidate)
|
||||
.filter((track): track is SubtitleTrackCandidate => track !== null);
|
||||
|
||||
const dedupedTracks = new Map<string, SubtitleTrackCandidate>();
|
||||
for (const track of subtitleTracks) {
|
||||
const identity = getSubtitleTrackIdentity(track);
|
||||
const existing = dedupedTracks.get(identity);
|
||||
if (!existing || (track.selected && !existing.selected)) {
|
||||
dedupedTracks.set(identity, track);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTracks = [...dedupedTracks.values()];
|
||||
|
||||
for (const language of normalizedLanguages) {
|
||||
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
|
||||
if (selectedMatch) {
|
||||
return selectedMatch.id;
|
||||
}
|
||||
|
||||
const match = uniqueTracks.find((track) => track.lang === language);
|
||||
if (match) {
|
||||
return match.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function splitMpvMessagesFromBuffer(
|
||||
buffer: string,
|
||||
onMessage?: MpvMessageParser,
|
||||
@@ -283,15 +378,11 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (Array.isArray(tracks)) {
|
||||
const config = deps.getResolvedConfig();
|
||||
const languages = config.secondarySub?.secondarySubLanguages || [];
|
||||
const subTracks = tracks.filter((track) => track.type === 'sub');
|
||||
for (const language of languages) {
|
||||
const match = subTracks.find((track) => track.lang === language);
|
||||
if (match) {
|
||||
deps.sendCommand({
|
||||
command: ['set_property', 'secondary-sid', match.id],
|
||||
});
|
||||
break;
|
||||
}
|
||||
const secondaryTrackId = pickSecondarySubtitleTrackId(tracks, languages);
|
||||
if (secondaryTrackId !== null) {
|
||||
deps.sendCommand({
|
||||
command: ['set_property', 'secondary-sid', secondaryTrackId],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
||||
|
||||
@@ -102,10 +102,7 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
|
||||
res.end(Buffer.from(body));
|
||||
}
|
||||
|
||||
function startNodeHttpServer(
|
||||
app: Hono,
|
||||
config: StatsServerConfig,
|
||||
): { close: () => void } {
|
||||
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
|
||||
const server = http.createServer((req, res) => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -1075,11 +1072,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
|
||||
const bunRuntime = globalThis as typeof globalThis & {
|
||||
Bun?: {
|
||||
serve?: (options: {
|
||||
fetch: (typeof app)['fetch'];
|
||||
port: number;
|
||||
hostname: string;
|
||||
}) => { stop: () => void };
|
||||
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
|
||||
stop: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -92,6 +92,52 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
|
||||
assert.equal(inProgressState, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return '/tmp/video.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||
{
|
||||
id: 2,
|
||||
type: 'sub',
|
||||
selected: true,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': '/tmp/ref.srt',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': '/tmp/ref.srt',
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
||||
const osd: string[] = [];
|
||||
await triggerSubsyncFromConfig(
|
||||
|
||||
@@ -76,6 +76,35 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
|
||||
});
|
||||
}
|
||||
|
||||
function getSourceTrackIdentity(track: MpvTrack): string {
|
||||
if (
|
||||
track.external &&
|
||||
typeof track['external-filename'] === 'string' &&
|
||||
track['external-filename'].length > 0
|
||||
) {
|
||||
return `external:${track['external-filename'].toLowerCase()}`;
|
||||
}
|
||||
if (typeof track.id === 'number') {
|
||||
return `id:${track.id}`;
|
||||
}
|
||||
if (typeof track.title === 'string' && track.title.length > 0) {
|
||||
return `title:${track.title.toLowerCase()}`;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
|
||||
const deduped = new Map<string, MpvTrack>();
|
||||
for (const track of tracks) {
|
||||
const identity = getSourceTrackIdentity(track);
|
||||
const existing = deduped.get(identity);
|
||||
if (!existing || (track.selected && !existing.selected)) {
|
||||
deduped.set(identity, track);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
||||
isSubsyncInProgress: () => boolean;
|
||||
setSubsyncInProgress: (inProgress: boolean) => void;
|
||||
@@ -123,12 +152,13 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
||||
const filename = track['external-filename'];
|
||||
return typeof filename === 'string' && filename.length > 0;
|
||||
});
|
||||
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
sourceTracks,
|
||||
sourceTracks: uniqueSourceTracks,
|
||||
audioStreamIndex: client.currentAudioStreamIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2029,7 +2029,8 @@ export async function addYomitanNoteViaSearch(
|
||||
: null,
|
||||
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
||||
? envelope.duplicateNoteIds.filter(
|
||||
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
||||
(entry): entry is number =>
|
||||
typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -68,6 +68,15 @@ export function resolveExternalYomitanExtensionPath(
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
||||
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
|
||||
const candidate = path.join(normalizedProfilePath, 'extensions', 'yomitan');
|
||||
const fallbackCandidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
||||
|
||||
const candidates = candidate === fallbackCandidate ? [candidate] : [candidate, fallbackCandidate];
|
||||
for (const root of candidates) {
|
||||
if (existsSync(path.join(root, 'manifest.json'))) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
|
||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/usr/bin/env node
|
||||
const script =
|
||||
process.platform === 'win32'
|
||||
? `#!/usr/bin/env bun
|
||||
process.stdout.write(${JSON.stringify(payload)});
|
||||
`
|
||||
: `#!/usr/bin/env sh
|
||||
cat <<'EOF' | base64 -d
|
||||
${Buffer.from(payload).toString('base64')}
|
||||
EOF
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -28,8 +35,15 @@ process.stdout.write(${JSON.stringify(payload)});
|
||||
|
||||
function makeHangingFakeYtDlpScript(dir: string): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/usr/bin/env node
|
||||
const script =
|
||||
process.platform === 'win32'
|
||||
? `#!/usr/bin/env bun
|
||||
setInterval(() => {}, 1000);
|
||||
`
|
||||
: `#!/usr/bin/env sh
|
||||
while :; do
|
||||
sleep 1;
|
||||
done
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -44,11 +58,19 @@ async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeFakeYtDlpScript(binDir, payload);
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
process.env.SUBMINER_YTDLP_BIN =
|
||||
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalCommand === undefined) {
|
||||
delete process.env.SUBMINER_YTDLP_BIN;
|
||||
} else {
|
||||
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -59,11 +81,19 @@ async function withHangingFakeYtDlp<T>(fn: () => Promise<T>): Promise<T> {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeHangingFakeYtDlpScript(binDir);
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
process.env.SUBMINER_YTDLP_BIN =
|
||||
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalCommand === undefined) {
|
||||
delete process.env.SUBMINER_YTDLP_BIN;
|
||||
} else {
|
||||
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||
|
||||
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
@@ -87,7 +88,7 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
|
||||
export async function probeYoutubeVideoMetadata(
|
||||
targetUrl: string,
|
||||
): Promise<YoutubeVideoMetadata | null> {
|
||||
const { stdout } = await runCapture('yt-dlp', [
|
||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||
'--dump-single-json',
|
||||
'--no-warnings',
|
||||
'--skip-download',
|
||||
|
||||
@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
|
||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/usr/bin/env node
|
||||
const script =
|
||||
process.platform === 'win32'
|
||||
? `#!/usr/bin/env bun
|
||||
process.stdout.write(${JSON.stringify(payload)});
|
||||
`
|
||||
: `#!/usr/bin/env sh
|
||||
cat <<'EOF' | base64 -d
|
||||
${Buffer.from(payload).toString('base64')}
|
||||
EOF
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||
|
||||
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
||||
@@ -88,8 +89,7 @@ export async function resolveYoutubePlaybackUrl(
|
||||
targetUrl: string,
|
||||
format = DEFAULT_PLAYBACK_FORMAT,
|
||||
): Promise<string> {
|
||||
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
|
||||
const { stdout } = await runCapture(ytDlpCommand, [
|
||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||
'--get-url',
|
||||
'--no-warnings',
|
||||
'-f',
|
||||
|
||||
@@ -16,7 +16,7 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
|
||||
function makeFakeYtDlpScript(dir: string): string {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/usr/bin/env node
|
||||
const script = `#!/usr/bin/env bun
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
@@ -87,6 +87,87 @@ if (process.env.YTDLP_FAKE_MODE === 'multi') {
|
||||
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||
}
|
||||
process.exit(0);
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
fs.chmodSync(scriptPath, 0o755);
|
||||
if (process.platform === 'win32') {
|
||||
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nbun "${scriptPath}" %*\r\n`, 'utf8');
|
||||
}
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
function makeFakeYtDlpShellScript(dir: string): string {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const script = `#!/bin/sh
|
||||
has_auto_subs=0
|
||||
wants_auto_subs=0
|
||||
wants_manual_subs=0
|
||||
sub_lang=''
|
||||
output_template=''
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--write-auto-subs)
|
||||
wants_auto_subs=1
|
||||
;;
|
||||
--write-subs)
|
||||
wants_manual_subs=1
|
||||
;;
|
||||
--sub-langs)
|
||||
sub_lang="$2"
|
||||
shift
|
||||
;;
|
||||
-o)
|
||||
output_template="$2"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ "$YTDLP_EXPECT_AUTO_SUBS" = "1" ] && [ "$wants_auto_subs" != "1" ]; then
|
||||
exit 2
|
||||
fi
|
||||
if [ "$YTDLP_EXPECT_MANUAL_SUBS" = "1" ] && [ "$wants_manual_subs" != "1" ]; then
|
||||
exit 3
|
||||
fi
|
||||
if [ -n "$YTDLP_EXPECT_SUB_LANG" ] && [ "$sub_lang" != "$YTDLP_EXPECT_SUB_LANG" ]; then
|
||||
exit 4
|
||||
fi
|
||||
|
||||
prefix="\${output_template%.%(ext)s}"
|
||||
if [ -z "$prefix" ]; then
|
||||
exit 1
|
||||
fi
|
||||
dir="\${prefix%/*}"
|
||||
[ -d "$dir" ] || /bin/mkdir -p "$dir"
|
||||
|
||||
if [ "$YTDLP_FAKE_MODE" = "multi" ]; then
|
||||
OLD_IFS="$IFS"
|
||||
IFS=","
|
||||
for lang in $sub_lang; do
|
||||
if [ -n "$lang" ]; then
|
||||
printf 'WEBVTT\\n' > "\${prefix}.\${lang}.vtt"
|
||||
fi
|
||||
done
|
||||
IFS="$OLD_IFS"
|
||||
elif [ "$YTDLP_FAKE_MODE" = "rolling-auto" ]; then
|
||||
printf 'WEBVTT\\n\\n00:00:01.000 --> 00:00:02.000\\n今日は\\n\\n00:00:02.000 --> 00:00:03.000\\n今日はいい天気ですね\\n\\n00:00:03.000 --> 00:00:04.000\\n今日はいい天気ですね本当に\\n' > "\${prefix}.vtt"
|
||||
elif [ "$YTDLP_FAKE_MODE" = "multi-primary-only-fail" ]; then
|
||||
primary_lang="\${sub_lang%%,*}"
|
||||
if [ -n "$primary_lang" ]; then
|
||||
printf 'WEBVTT\\n' > "\${prefix}.\${primary_lang}.vtt"
|
||||
fi
|
||||
printf "ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n" 1>&2
|
||||
exit 1
|
||||
elif [ "$YTDLP_FAKE_MODE" = "both" ]; then
|
||||
printf 'WEBVTT\\n' > "\${prefix}.vtt"
|
||||
printf 'webp' > "\${prefix}.orig.webp"
|
||||
elif [ "$YTDLP_FAKE_MODE" = "webp-only" ]; then
|
||||
printf 'webp' > "\${prefix}.orig.webp"
|
||||
else
|
||||
printf 'WEBVTT\\n' > "\${prefix}.vtt"
|
||||
fi
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
fs.chmodSync(scriptPath, 0o755);
|
||||
@@ -100,7 +181,11 @@ async function withFakeYtDlp<T>(
|
||||
return await withTempDir(async (root) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeFakeYtDlpScript(binDir);
|
||||
if (process.platform === 'win32') {
|
||||
makeFakeYtDlpScript(binDir);
|
||||
} else {
|
||||
makeFakeYtDlpShellScript(binDir);
|
||||
}
|
||||
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
@@ -114,6 +199,43 @@ async function withFakeYtDlp<T>(
|
||||
});
|
||||
}
|
||||
|
||||
async function withFakeYtDlpCommand<T>(
|
||||
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
|
||||
fn: (dir: string, binDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await withTempDir(async (root) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||
process.env.PATH = '';
|
||||
process.env.YTDLP_FAKE_MODE = mode;
|
||||
process.env.SUBMINER_YTDLP_BIN =
|
||||
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||
if (process.platform === 'win32') {
|
||||
makeFakeYtDlpScript(binDir);
|
||||
} else {
|
||||
makeFakeYtDlpShellScript(binDir);
|
||||
}
|
||||
try {
|
||||
return await fn(root, binDir);
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
delete process.env.YTDLP_FAKE_MODE;
|
||||
if (originalCommand === undefined) {
|
||||
delete process.env.SUBMINER_YTDLP_BIN;
|
||||
} else {
|
||||
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function withFakeYtDlpExpectations<T>(
|
||||
expectations: Partial<
|
||||
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
|
||||
@@ -179,6 +301,29 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlpCommand('both', async (root) => {
|
||||
const result = await downloadYoutubeSubtitleTrack({
|
||||
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
outputDir: path.join(root, 'out'),
|
||||
track: {
|
||||
id: 'auto:ja-orig',
|
||||
language: 'ja',
|
||||
sourceLanguage: 'ja-orig',
|
||||
kind: 'auto',
|
||||
label: 'Japanese (auto)',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(path.extname(result.path), '.vtt');
|
||||
assert.match(path.basename(result.path), /^auto-ja-orig\./);
|
||||
});
|
||||
});
|
||||
|
||||
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { YoutubeTrackOption } from './track-probe';
|
||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||
import {
|
||||
convertYoutubeTimedTextToVtt,
|
||||
isYoutubeTimedTextExtension,
|
||||
@@ -237,7 +238,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
|
||||
}),
|
||||
];
|
||||
|
||||
await runCapture('yt-dlp', args);
|
||||
await runCapture(getYoutubeYtDlpCommand(), args);
|
||||
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
||||
if (!subtitlePath) {
|
||||
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
||||
@@ -281,7 +282,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
|
||||
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
||||
|
||||
const result = await runCaptureDetailed(
|
||||
'yt-dlp',
|
||||
getYoutubeYtDlpCommand(),
|
||||
buildDownloadArgs({
|
||||
targetUrl: input.targetUrl,
|
||||
outputTemplate,
|
||||
|
||||
@@ -17,10 +17,18 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
|
||||
const scriptPath = path.join(dir, 'yt-dlp');
|
||||
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
const script = rawScript
|
||||
? stdoutBody
|
||||
: `#!/usr/bin/env node
|
||||
const script =
|
||||
process.platform === 'win32'
|
||||
? rawScript
|
||||
? stdoutBody
|
||||
: `#!/usr/bin/env bun
|
||||
process.stdout.write(${JSON.stringify(stdoutBody)});
|
||||
`
|
||||
: `#!/bin/sh
|
||||
PATH=/usr/bin:/bin:/usr/local/bin
|
||||
cat <<'SUBMINER_EOF' | base64 -d
|
||||
${Buffer.from(stdoutBody).toString('base64')}
|
||||
SUBMINER_EOF
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -39,11 +47,50 @@ async function withFakeYtDlp<T>(
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||
const originalPath = process.env.PATH ?? '';
|
||||
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||
process.env.SUBMINER_YTDLP_BIN =
|
||||
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalCommand === undefined) {
|
||||
delete process.env.SUBMINER_YTDLP_BIN;
|
||||
} else {
|
||||
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function withFakeYtDlpCommand<T>(
|
||||
payload: unknown,
|
||||
fn: () => Promise<T>,
|
||||
options: { rawScript?: boolean } = {},
|
||||
): Promise<T> {
|
||||
return await withTempDir(async (root) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||
const originalPath = process.env.PATH;
|
||||
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||
process.env.PATH = '';
|
||||
process.env.SUBMINER_YTDLP_BIN =
|
||||
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalCommand === undefined) {
|
||||
delete process.env.SUBMINER_YTDLP_BIN;
|
||||
} else {
|
||||
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -69,6 +116,28 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withFakeYtDlpCommand(
|
||||
{
|
||||
id: 'abc123',
|
||||
title: 'Example',
|
||||
subtitles: {
|
||||
ja: [{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese manual' }],
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(result.videoId, 'abc123');
|
||||
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.vtt');
|
||||
assert.equal(result.tracks[0]?.fileExtension, 'vtt');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
||||
await withFakeYtDlp(
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { YoutubeTrackOption } from '../../../types';
|
||||
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||
|
||||
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
@@ -111,7 +112,11 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
|
||||
export type { YoutubeTrackOption };
|
||||
|
||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||
'--dump-single-json',
|
||||
'--no-warnings',
|
||||
targetUrl,
|
||||
]);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||
|
||||
44
src/core/services/youtube/ytdlp-command.ts
Normal file
44
src/core/services/youtube/ytdlp-command.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
|
||||
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
|
||||
|
||||
function resolveFromPath(commandName: string): string | null {
|
||||
if (!process.env.PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchPaths = process.env.PATH.split(path.delimiter);
|
||||
for (const searchPath of searchPaths) {
|
||||
const candidate = path.join(searchPath, commandName);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getYoutubeYtDlpCommand(): string {
|
||||
const explicitCommand = process.env.SUBMINER_YTDLP_BIN?.trim();
|
||||
if (explicitCommand) {
|
||||
return explicitCommand;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return DEFAULT_YTDLP_COMMAND;
|
||||
}
|
||||
|
||||
for (const commandName of WINDOWS_YTDLP_COMMANDS) {
|
||||
const resolved = resolveFromPath(commandName);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_YTDLP_COMMAND;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
configureEarlyAppPaths,
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
normalizeStartupArgv,
|
||||
normalizeLaunchMpvTargets,
|
||||
sanitizeHelpEnv,
|
||||
@@ -70,6 +71,79 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
|
||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
||||
'C:\\a.mkv',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--sub-file',
|
||||
'track.srt',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['--sub-file', 'track.srt'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvTargets([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--sub-file',
|
||||
'track.srt',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['C:\\a.mkv'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--profile=subminer',
|
||||
'--pause=yes',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['--profile=subminer', '--pause=yes'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--input-ipc-server',
|
||||
'\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--alang',
|
||||
'ja,jpn',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
|
||||
['--fullscreen'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvTargets([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--input-ipc-server',
|
||||
'\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--alang',
|
||||
'ja,jpn',
|
||||
'C:\\a.mkv',
|
||||
'C:\\b.mkv',
|
||||
]),
|
||||
['C:\\a.mkv', 'C:\\b.mkv'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
|
||||
['C:\\a.mkv'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--msg-level',
|
||||
'all=warn',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['--msg-level', 'all=warn'],
|
||||
);
|
||||
});
|
||||
|
||||
test('stats-daemon entry helper detects internal daemon commands', () => {
|
||||
|
||||
@@ -8,6 +8,23 @@ const START_ARG = '--start';
|
||||
const PASSWORD_STORE_ARG = '--password-store';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
const APP_NAME = 'SubMiner';
|
||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||
'--alang',
|
||||
'--audio-file',
|
||||
'--input-ipc-server',
|
||||
'--log-file',
|
||||
'--msg-level',
|
||||
'--profile',
|
||||
'--script',
|
||||
'--script-opts',
|
||||
'--scripts',
|
||||
'--slang',
|
||||
'--sub-file',
|
||||
'--sub-file-paths',
|
||||
'--title',
|
||||
'--volume',
|
||||
'--ytdl-format',
|
||||
]);
|
||||
|
||||
type EarlyAppLike = {
|
||||
setName: (name: string) => void;
|
||||
@@ -53,6 +70,15 @@ function removePassiveStartupArgs(argv: string[]): string[] {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function consumesLaunchMpvValue(token: string): boolean {
|
||||
return (
|
||||
token.startsWith('--') &&
|
||||
token !== '--' &&
|
||||
!token.includes('=') &&
|
||||
MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES.has(token)
|
||||
);
|
||||
}
|
||||
|
||||
function parseCliArgs(argv: string[]): CliArgs {
|
||||
return parseArgs(argv);
|
||||
}
|
||||
@@ -121,7 +147,82 @@ export function shouldHandleStatsDaemonCommandAtEntry(
|
||||
}
|
||||
|
||||
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
|
||||
return parseCliArgs(argv).launchMpvTargets;
|
||||
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
|
||||
if (launchMpvIndex < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets: string[] = [];
|
||||
|
||||
let parsingTargets = false;
|
||||
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token) continue;
|
||||
|
||||
if (parsingTargets) {
|
||||
targets.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
parsingTargets = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('--')) {
|
||||
if (consumesLaunchMpvValue(token) && i + 1 < argv.length) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('-')) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsingTargets = true;
|
||||
targets.push(token);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function normalizeLaunchMpvExtraArgs(argv: string[]): string[] {
|
||||
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
|
||||
if (launchMpvIndex < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extraArgs: string[] = [];
|
||||
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token) continue;
|
||||
if (token === '--') {
|
||||
break;
|
||||
}
|
||||
if (token.startsWith('--')) {
|
||||
extraArgs.push(token);
|
||||
if (consumesLaunchMpvValue(token) && i + 1 < argv.length) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('-')) {
|
||||
extraArgs.push(value);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
extraArgs.push(token);
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith('-')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return extraArgs;
|
||||
}
|
||||
|
||||
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { app, dialog } from 'electron';
|
||||
import { printHelp } from './cli/help';
|
||||
import { loadRawConfigStrict } from './config/load';
|
||||
import {
|
||||
configureEarlyAppPaths,
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
normalizeLaunchMpvTargets,
|
||||
normalizeStartupArgv,
|
||||
sanitizeStartupEnv,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||
|
||||
@@ -32,9 +36,37 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readConfiguredWindowsMpvExecutablePath(configDir: string): string {
|
||||
const loadResult = loadRawConfigStrict({
|
||||
configDir,
|
||||
configFileJsonc: path.join(configDir, 'config.jsonc'),
|
||||
configFileJson: path.join(configDir, 'config.json'),
|
||||
});
|
||||
if (!loadResult.ok) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return typeof loadResult.config.mpv?.executablePath === 'string'
|
||||
? loadResult.config.mpv.executablePath.trim()
|
||||
: '';
|
||||
}
|
||||
|
||||
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
||||
const assets = resolvePackagedFirstRunPluginAssets({
|
||||
dirname: __dirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
});
|
||||
if (!assets) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return path.join(assets.pluginDirSource, 'main.lua');
|
||||
}
|
||||
|
||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||
configureEarlyAppPaths(app);
|
||||
const userDataPath = configureEarlyAppPaths(app);
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||
@@ -59,8 +91,8 @@ if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
void app.whenReady().then(() => {
|
||||
const result = launchWindowsMpv(
|
||||
void app.whenReady().then(async () => {
|
||||
const result = await launchWindowsMpv(
|
||||
normalizeLaunchMpvTargets(process.argv),
|
||||
createWindowsMpvLaunchDeps({
|
||||
getEnv: (name) => process.env[name],
|
||||
@@ -68,6 +100,10 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
dialog.showErrorBox(title, content);
|
||||
},
|
||||
}),
|
||||
normalizeLaunchMpvExtraArgs(process.argv),
|
||||
process.execPath,
|
||||
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||
readConfiguredWindowsMpvExecutablePath(userDataPath),
|
||||
);
|
||||
app.exit(result.ok ? 0 : 1);
|
||||
});
|
||||
|
||||
100
src/main.ts
100
src/main.ts
@@ -77,15 +77,15 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.setup),
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.setup),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,12 @@ import { AnkiIntegration } from './anki-integration';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
|
||||
import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger';
|
||||
import {
|
||||
createLogger,
|
||||
setLogLevel,
|
||||
resolveDefaultLogFilePath,
|
||||
type LogLevelSource,
|
||||
} from './logger';
|
||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||
import {
|
||||
commandNeedsOverlayStartupPrereqs,
|
||||
@@ -339,6 +344,7 @@ import { startStatsServer } from './core/services/stats-server';
|
||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
getFirstRunSetupCompletionMessage,
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
} from './main/runtime/first-run-setup-service';
|
||||
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||
@@ -348,6 +354,7 @@ import {
|
||||
createYoutubePrimarySubtitleNotificationRuntime,
|
||||
} from './main/runtime/youtube-primary-subtitle-notification';
|
||||
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
|
||||
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
|
||||
import {
|
||||
buildFirstRunSetupHtml,
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
@@ -365,7 +372,11 @@ import {
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './main/runtime/windows-mpv-shortcuts';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
import {
|
||||
createWindowsMpvLaunchDeps,
|
||||
getConfiguredWindowsMpvPathStatus,
|
||||
launchWindowsMpv,
|
||||
} from './main/runtime/windows-mpv-launch';
|
||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
||||
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
||||
@@ -490,7 +501,10 @@ import {
|
||||
} from './config';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
||||
import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
||||
import {
|
||||
createSubtitlePrefetchService,
|
||||
type SubtitlePrefetchService,
|
||||
} from './core/services/subtitle-prefetch';
|
||||
import {
|
||||
buildSubtitleSidebarSourceKey,
|
||||
resolveSubtitleSourcePath,
|
||||
@@ -1000,6 +1014,17 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelectionRuntime({
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearScheduled: (timer) => clearTimeout(timer),
|
||||
});
|
||||
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||
platform: process.platform,
|
||||
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
|
||||
@@ -1024,6 +1049,9 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||
}),
|
||||
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
|
||||
undefined,
|
||||
undefined,
|
||||
getResolvedConfig().mpv.executablePath,
|
||||
),
|
||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||
@@ -1392,8 +1420,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
subtitlePrefetchInitController,
|
||||
resolveActiveSubtitleSidebarSource: (input) =>
|
||||
resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
});
|
||||
|
||||
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||
@@ -1406,7 +1433,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||
const subtitlePrefetchRuntime = {
|
||||
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
||||
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath),
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
|
||||
refreshSubtitleSidebarFromSource(sourcePath),
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
||||
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
||||
@@ -1841,10 +1869,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
},
|
||||
})(),
|
||||
);
|
||||
const buildGetRuntimeOptionsStateMainDepsHandler =
|
||||
createBuildGetRuntimeOptionsStateMainDepsHandler({
|
||||
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
|
||||
{
|
||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||
});
|
||||
},
|
||||
);
|
||||
const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler();
|
||||
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler(
|
||||
getRuntimeOptionsStateMainDeps,
|
||||
@@ -2200,6 +2229,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
}),
|
||||
getSetupSnapshot: async () => {
|
||||
const snapshot = await firstRunSetupService.getSetupStatus();
|
||||
const mpvExecutablePath = getResolvedConfig().mpv.executablePath;
|
||||
return {
|
||||
configReady: snapshot.configReady,
|
||||
dictionaryCount: snapshot.dictionaryCount,
|
||||
@@ -2207,6 +2237,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||
pluginStatus: snapshot.pluginStatus,
|
||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||
mpvExecutablePath,
|
||||
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
|
||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||
message: firstRunSetupMessage,
|
||||
};
|
||||
@@ -2219,6 +2251,22 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'configure-mpv-executable-path') {
|
||||
const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? '';
|
||||
const pathStatus = getConfiguredWindowsMpvPathStatus(mpvExecutablePath);
|
||||
configService.patchRawConfig({
|
||||
mpv: {
|
||||
executablePath: mpvExecutablePath,
|
||||
},
|
||||
});
|
||||
firstRunSetupMessage =
|
||||
pathStatus === 'invalid'
|
||||
? `Saved mpv executable path, but the file was not found: ${mpvExecutablePath}`
|
||||
: mpvExecutablePath
|
||||
? `Saved mpv executable path: ${mpvExecutablePath}`
|
||||
: 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.';
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'configure-windows-mpv-shortcuts') {
|
||||
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
|
||||
startMenuEnabled: submission.startMenuEnabled === true,
|
||||
@@ -2238,18 +2286,15 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'skip-plugin') {
|
||||
await firstRunSetupService.skipPluginInstall();
|
||||
firstRunSetupMessage = 'mpv plugin installation skipped.';
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await firstRunSetupService.markSetupCompleted();
|
||||
if (snapshot.state.status === 'completed') {
|
||||
firstRunSetupMessage = null;
|
||||
return { closeWindow: true };
|
||||
}
|
||||
firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
firstRunSetupMessage =
|
||||
getFirstRunSetupCompletionMessage(snapshot) ??
|
||||
'Finish setup requires the mpv plugin and Yomitan dictionaries.';
|
||||
return;
|
||||
},
|
||||
markSetupInProgress: async () => {
|
||||
@@ -3006,7 +3051,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||
},
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(),
|
||||
ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||
ensureBackgroundStatsServerStarted: () =>
|
||||
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
|
||||
openExternal: (url: string) => shell.openExternal(url),
|
||||
writeResponse: (responsePath, payload) => {
|
||||
@@ -3222,8 +3268,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
shouldUseMinimalStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
createImmersionTracker: () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
@@ -3328,6 +3373,7 @@ const {
|
||||
updateCurrentMediaPath: (path) => {
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||
startupOsdSequencer.reset();
|
||||
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
||||
subtitlePrefetchRuntime.cancelPendingInit();
|
||||
@@ -3394,6 +3440,7 @@ const {
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
onSubtitleTrackListChange: (trackList) => {
|
||||
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
|
||||
scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
@@ -4135,7 +4182,10 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
|
||||
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient);
|
||||
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
|
||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
});
|
||||
|
||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: {
|
||||
|
||||
@@ -24,7 +24,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getModalWindow: () => null },
|
||||
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
|
||||
{
|
||||
inputState: boolean;
|
||||
getModalInputExclusive: () => boolean;
|
||||
handleModalInputStateChange: (isActive: boolean) => void;
|
||||
},
|
||||
{ measurementStore: boolean },
|
||||
{ modalRuntime: boolean },
|
||||
{ mpvSocketPath: string; texthookerPort: number },
|
||||
@@ -80,7 +84,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
createOverlayManager: () => ({
|
||||
getModalWindow: () => null,
|
||||
}),
|
||||
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
|
||||
createOverlayModalInputState: () => ({
|
||||
inputState: true,
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
@@ -106,8 +114,14 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
texthookerPort: 5174,
|
||||
});
|
||||
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
|
||||
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('ready', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('second-instance', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
assert.deepEqual(appOnCalls, ['ready']);
|
||||
assert.equal(secondInstanceHandlerRegistered, true);
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
|
||||
|
||||
@@ -56,9 +56,7 @@ export interface MainBootServicesParams<
|
||||
};
|
||||
shouldBypassSingleInstanceLock: () => boolean;
|
||||
requestSingleInstanceLockEarly: () => boolean;
|
||||
registerSecondInstanceHandlerEarly: (
|
||||
listener: (_event: unknown, argv: string[]) => void,
|
||||
) => void;
|
||||
registerSecondInstanceHandlerEarly: (listener: (_event: unknown, argv: string[]) => void) => void;
|
||||
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
||||
createConfigService: (configDir: string) => TConfigService;
|
||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||
@@ -87,10 +85,7 @@ export interface MainBootServicesParams<
|
||||
overlayModalInputState: TOverlayModalInputState;
|
||||
onModalStateChange: (isActive: boolean) => void;
|
||||
}) => TOverlayModalRuntime;
|
||||
createAppState: (input: {
|
||||
mpvSocketPath: string;
|
||||
texthookerPort: number;
|
||||
}) => TAppState;
|
||||
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
|
||||
}
|
||||
|
||||
export interface MainBootServicesResult<
|
||||
@@ -239,9 +234,7 @@ export function createMainBootServices<
|
||||
|
||||
const appLifecycleApp = {
|
||||
requestSingleInstanceLock: () =>
|
||||
params.shouldBypassSingleInstanceLock()
|
||||
? true
|
||||
: params.requestSingleInstanceLockEarly(),
|
||||
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
|
||||
quit: () => params.app.quit(),
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === 'second-instance') {
|
||||
|
||||
@@ -31,9 +31,9 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
|
||||
const extraLength = archive.readUInt16LE(cursor + 28);
|
||||
const fileNameStart = cursor + 30;
|
||||
const dataStart = fileNameStart + fileNameLength + extraLength;
|
||||
const fileName = archive.subarray(fileNameStart, fileNameStart + fileNameLength).toString(
|
||||
'utf8',
|
||||
);
|
||||
const fileName = archive
|
||||
.subarray(fileNameStart, fileNameStart + fileNameLength)
|
||||
.toString('utf8');
|
||||
const data = archive.subarray(dataStart, dataStart + compressedSize);
|
||||
entries.set(fileName, Buffer.from(data));
|
||||
cursor = dataStart + compressedSize;
|
||||
@@ -57,7 +57,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
||||
}) as typeof fs.writeFileSync;
|
||||
|
||||
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
|
||||
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
|
||||
throw new Error(
|
||||
`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`,
|
||||
);
|
||||
}) as typeof Buffer.concat;
|
||||
|
||||
const result = buildDictionaryZip(
|
||||
@@ -91,8 +93,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
||||
assert.equal(indexJson.revision, '2026-03-27');
|
||||
assert.equal(indexJson.format, 3);
|
||||
|
||||
const termBank = JSON.parse(entries.get('term_bank_1.json')!.toString('utf8')) as
|
||||
CharacterDictionaryTermEntry[];
|
||||
const termBank = JSON.parse(
|
||||
entries.get('term_bank_1.json')!.toString('utf8'),
|
||||
) as CharacterDictionaryTermEntry[];
|
||||
assert.equal(termBank.length, 1);
|
||||
assert.equal(termBank[0]?.[0], 'アルファ');
|
||||
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
||||
|
||||
@@ -138,7 +138,11 @@ function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
|
||||
return central;
|
||||
}
|
||||
|
||||
function createEndOfCentralDirectory(entriesLength: number, centralSize: number, centralStart: number): Buffer {
|
||||
function createEndOfCentralDirectory(
|
||||
entriesLength: number,
|
||||
centralSize: number,
|
||||
centralStart: number,
|
||||
): Buffer {
|
||||
const end = Buffer.alloc(22);
|
||||
let cursor = 0;
|
||||
writeUint32LE(end, 0x06054b50, cursor);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAutoplayReadyGate } from './autoplay-ready-gate';
|
||||
|
||||
test('autoplay ready gate suppresses duplicate media signals unless forced while paused', async () => {
|
||||
test('autoplay ready gate suppresses duplicate media signals for the same media', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
@@ -31,20 +31,19 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const firstScheduled = scheduled.shift();
|
||||
firstScheduled?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
assert.ok(
|
||||
commands.some(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
);
|
||||
assert.equal(scheduled.length > 0, true);
|
||||
@@ -85,14 +84,62 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length > 0,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let playbackPaused = true;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => playbackPaused,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => playbackPaused,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
|
||||
playbackPaused = false;
|
||||
}
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
playbackPaused = true;
|
||||
gate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '字幕その2', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,12 +40,8 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
}
|
||||
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() ||
|
||||
deps.getCurrentVideoPath()?.trim() ||
|
||||
'__unknown__';
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const allowDuplicateWhilePaused =
|
||||
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
@@ -87,7 +83,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!mpvClient?.connected) {
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
||||
deps.schedule(
|
||||
() => attemptRelease(playbackGeneration, attempt + 1),
|
||||
releaseRetryDelayMs,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -104,19 +103,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
})();
|
||||
};
|
||||
|
||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!duplicateMediaSignal) {
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
if (duplicateMediaSignal) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
|
||||
assert.equal(handled, false);
|
||||
|
||||
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
|
||||
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc');
|
||||
const handledProtocol = composed.handleAnilistSetupProtocolUrl(
|
||||
'subminer://anilist-setup?code=abc',
|
||||
);
|
||||
assert.equal(handledProtocol, true);
|
||||
});
|
||||
|
||||
@@ -36,8 +36,13 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
openJellyfinSetupWindow: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
||||
generateCharacterDictionary: async () =>
|
||||
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
|
||||
@@ -30,9 +30,7 @@ export type CliStartupComposerResult = ComposerOutputs<{
|
||||
export function composeCliStartupHandlers(
|
||||
options: CliStartupComposerOptions,
|
||||
): CliStartupComposerResult {
|
||||
const createCliCommandContext = createCliCommandContextFactory(
|
||||
options.cliCommandContextMainDeps,
|
||||
);
|
||||
const createCliCommandContext = createCliCommandContextFactory(options.cliCommandContextMainDeps);
|
||||
const handleCliCommand = createCliCommandRuntimeHandler({
|
||||
...options.cliCommandRuntimeHandlerMainDeps,
|
||||
createCliCommandContext: () => createCliCommandContext(),
|
||||
|
||||
@@ -8,28 +8,22 @@ type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDep
|
||||
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
|
||||
>;
|
||||
|
||||
export type HeadlessStartupComposerOptions<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps,
|
||||
> = ComposerInputs<{
|
||||
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps
|
||||
>;
|
||||
}>;
|
||||
export type HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
|
||||
ComposerInputs<{
|
||||
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps
|
||||
>;
|
||||
}>;
|
||||
|
||||
export type HeadlessStartupComposerResult<
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps,
|
||||
> = ComposerOutputs<
|
||||
Pick<
|
||||
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
|
||||
>
|
||||
>;
|
||||
export type HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
|
||||
ComposerOutputs<
|
||||
Pick<
|
||||
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
|
||||
>
|
||||
>;
|
||||
|
||||
export function composeHeadlessStartupHandlers<
|
||||
TCliArgs,
|
||||
|
||||
@@ -4,7 +4,13 @@ import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
|
||||
|
||||
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
|
||||
let lastProgressAt = 0;
|
||||
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null };
|
||||
let activePlayback: unknown = {
|
||||
itemId: 'item-1',
|
||||
mediaSourceId: 'src-1',
|
||||
playMethod: 'DirectPlay',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
const composed = composeJellyfinRemoteHandlers({
|
||||
|
||||
@@ -85,7 +85,10 @@ function createDefaultMpvFixture() {
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
getCurrentMetrics: () => BASE_METRICS,
|
||||
setCurrentMetrics: () => {},
|
||||
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({
|
||||
applyPatch: (
|
||||
current: MpvSubtitleRenderMetrics,
|
||||
patch: Partial<MpvSubtitleRenderMetrics>,
|
||||
) => ({
|
||||
next: { ...current, ...patch },
|
||||
changed: true,
|
||||
}),
|
||||
|
||||
@@ -58,7 +58,8 @@ export function composeOverlayVisibilityRuntime(
|
||||
options: OverlayVisibilityRuntimeComposerOptions,
|
||||
): OverlayVisibilityRuntimeComposerResult {
|
||||
return {
|
||||
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
|
||||
options.restorePreviousSecondarySubVisibilityMainDeps,
|
||||
|
||||
@@ -31,7 +31,10 @@ function requireUser(client: DiscordRpcRawClient): DiscordRpcClientUserLike {
|
||||
export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient {
|
||||
return {
|
||||
login: () => client.login(),
|
||||
setActivity: (activity) => requireUser(client).setActivity(activity).then(() => undefined),
|
||||
setActivity: (activity) =>
|
||||
requireUser(client)
|
||||
.setActivity(activity)
|
||||
.then(() => undefined),
|
||||
clearActivity: () => requireUser(client).clearActivity(),
|
||||
destroy: () => client.destroy(),
|
||||
};
|
||||
@@ -39,7 +42,12 @@ export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcCli
|
||||
|
||||
export function createDiscordRpcClient(
|
||||
clientId: string,
|
||||
deps?: { createClient?: (options: { clientId: string; transport: { type: 'ipc' } }) => DiscordRpcRawClient },
|
||||
deps?: {
|
||||
createClient?: (options: {
|
||||
clientId: string;
|
||||
transport: { type: 'ipc' };
|
||||
}) => DiscordRpcRawClient;
|
||||
},
|
||||
): DiscordRpcClient {
|
||||
const client =
|
||||
deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ??
|
||||
|
||||
@@ -173,7 +173,10 @@ test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(installPaths.pluginConfigPath, 'binary_path=\nsocket_path=/tmp/subminer-socket\n');
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
@@ -222,3 +225,72 @@ test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overr
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
|
||||
const pluginDir = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer');
|
||||
const pluginEntrypointPath = path.join(pluginDir, 'main.lua');
|
||||
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
|
||||
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
|
||||
|
||||
assert.equal(
|
||||
detectInstalledFirstRunPlugin(installPaths),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir, xdgConfigHome);
|
||||
const pluginDir = path.join(xdgConfigHome, 'mpv', 'scripts', '@plugin', 'subminer');
|
||||
const pluginEntrypointPath = path.join(pluginDir, 'main.lua');
|
||||
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
|
||||
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
|
||||
|
||||
assert.equal(
|
||||
detectInstalledFirstRunPlugin(installPaths),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
|
||||
const legacyLoaderPath = path.join(installPaths.scriptsDir, 'subminer.lua');
|
||||
|
||||
fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true });
|
||||
fs.writeFileSync(legacyLoaderPath, '-- plugin');
|
||||
|
||||
assert.equal(
|
||||
detectInstalledFirstRunPlugin(installPaths),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
|
||||
const pluginDir = path.join(installPaths.scriptsDir, 'subminer');
|
||||
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin');
|
||||
|
||||
assert.equal(
|
||||
detectInstalledFirstRunPlugin(installPaths),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,14 +12,6 @@ function backupExistingPath(targetPath: string): void {
|
||||
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
|
||||
}
|
||||
|
||||
function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string {
|
||||
return path.join(installPaths.scriptsDir, 'subminer.lua');
|
||||
}
|
||||
|
||||
function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string {
|
||||
return path.join(installPaths.scriptsDir, 'subminer-loader.lua');
|
||||
}
|
||||
|
||||
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
|
||||
@@ -99,14 +91,13 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
|
||||
export function detectInstalledFirstRunPlugin(
|
||||
installPaths: MpvInstallPaths,
|
||||
deps?: { existsSync?: (candidate: string) => boolean },
|
||||
deps?: {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
},
|
||||
): boolean {
|
||||
const existsSync = deps?.existsSync ?? fs.existsSync;
|
||||
return (
|
||||
existsSync(installPaths.pluginEntrypointPath) &&
|
||||
existsSync(installPaths.pluginDir) &&
|
||||
existsSync(installPaths.pluginConfigPath)
|
||||
);
|
||||
const pluginEntrypointPath = path.join(installPaths.scriptsDir, 'subminer', 'main.lua');
|
||||
return existsSync(pluginEntrypointPath);
|
||||
}
|
||||
|
||||
export function installFirstRunPluginToDefaultLocation(options: {
|
||||
@@ -148,8 +139,8 @@ export function installFirstRunPluginToDefaultLocation(options: {
|
||||
|
||||
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
|
||||
backupExistingPath(resolveLegacyPluginLoaderPath(installPaths));
|
||||
backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths));
|
||||
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer.lua'));
|
||||
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer-loader.lua'));
|
||||
backupExistingPath(installPaths.pluginDir);
|
||||
backupExistingPath(installPaths.pluginConfigPath);
|
||||
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
|
||||
@@ -187,7 +178,10 @@ export function syncInstalledFirstRunPluginBinaryPath(options: {
|
||||
return { updated: false, configPath: installPaths.pluginConfigPath };
|
||||
}
|
||||
|
||||
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
|
||||
const updated = rewriteInstalledPluginBinaryPath(
|
||||
installPaths.pluginConfigPath,
|
||||
options.binaryPath,
|
||||
);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ test('setup service auto-completes legacy installs with config and dictionaries'
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -106,17 +106,18 @@ test('setup service auto-completes legacy installs with config and dictionaries'
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => {
|
||||
test('setup service requires mpv plugin install before finish', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
let dictionaryCount = 0;
|
||||
let pluginInstalled = false;
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => pluginInstalled,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -130,13 +131,11 @@ test('setup service requires explicit finish for incomplete installs and support
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
assert.equal(initial.canFinish, false);
|
||||
|
||||
const skipped = await service.skipPluginInstall();
|
||||
assert.equal(skipped.state.pluginInstallStatus, 'skipped');
|
||||
|
||||
const installed = await service.installMpvPlugin();
|
||||
assert.equal(installed.state.pluginInstallStatus, 'installed');
|
||||
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
|
||||
|
||||
pluginInstalled = true;
|
||||
dictionaryCount = 1;
|
||||
const refreshed = await service.refreshStatus();
|
||||
assert.equal(refreshed.canFinish, true);
|
||||
@@ -158,7 +157,7 @@ test('setup service allows completion without internal dictionaries when externa
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -190,7 +189,7 @@ test('setup service does not probe internal dictionaries when external yomitan i
|
||||
throw new Error('should not probe internal dictionaries in external mode');
|
||||
},
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -218,7 +217,7 @@ test('setup service reopens when external-yomitan completion later has no extern
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -235,7 +234,7 @@ test('setup service reopens when external-yomitan completion later has no extern
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -252,6 +251,48 @@ test('setup service reopens when external-yomitan completion later has no extern
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const completedService = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
await completedService.ensureSetupStateInitialized();
|
||||
await completedService.markSetupCompleted();
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.state.status, 'incomplete');
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
assert.equal(snapshot.pluginStatus, 'required');
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
@@ -262,7 +303,7 @@ test('setup service keeps completed when external-yomitan completion later has i
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -279,7 +320,7 @@ test('setup service keeps completed when external-yomitan completion later has i
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -304,7 +345,7 @@ test('setup service marks cancelled when popup closes before completion', async
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -331,7 +372,7 @@ test('setup service reflects detected Windows mpv shortcuts before preferences a
|
||||
platform: 'win32',
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -364,7 +405,7 @@ test('setup service persists Windows mpv shortcut preferences and status with on
|
||||
platform: 'win32',
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface SetupStatusSnapshot {
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginStatus: 'installed' | 'required' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||
message: string | null;
|
||||
@@ -48,7 +48,6 @@ export interface FirstRunSetupService {
|
||||
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
||||
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
|
||||
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||
configureWindowsMpvShortcuts: (preferences: {
|
||||
startMenuEnabled: boolean;
|
||||
@@ -108,9 +107,8 @@ function getPluginStatus(
|
||||
pluginInstalled: boolean,
|
||||
): SetupStatusSnapshot['pluginStatus'] {
|
||||
if (pluginInstalled) return 'installed';
|
||||
if (state.pluginInstallStatus === 'skipped') return 'skipped';
|
||||
if (state.pluginInstallStatus === 'failed') return 'failed';
|
||||
return 'optional';
|
||||
return 'required';
|
||||
}
|
||||
|
||||
function getWindowsMpvShortcutStatus(
|
||||
@@ -151,6 +149,24 @@ function isYomitanSetupSatisfied(options: {
|
||||
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
|
||||
}
|
||||
|
||||
export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: SetupStatusSnapshot['pluginStatus'];
|
||||
}): string | null {
|
||||
if (!snapshot.configReady) {
|
||||
return 'Create or provide the config file before finishing setup.';
|
||||
}
|
||||
if (snapshot.pluginStatus !== 'installed') {
|
||||
return 'Install the mpv plugin before finishing setup.';
|
||||
}
|
||||
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveYomitanSetupStatus(deps: {
|
||||
configFilePaths: { jsoncPath: string; jsonPath: string };
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
@@ -230,11 +246,13 @@ export function createFirstRunSetupService(deps: {
|
||||
return {
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
}),
|
||||
canFinish:
|
||||
pluginInstalled &&
|
||||
isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
}),
|
||||
externalYomitanConfigured,
|
||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||
@@ -272,24 +290,20 @@ export function createFirstRunSetupService(deps: {
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
});
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
!(
|
||||
state.yomitanSetupMode === 'external' &&
|
||||
!externalYomitanConfigured &&
|
||||
!yomitanSetupSatisfied
|
||||
)
|
||||
) {
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
const canFinish =
|
||||
pluginInstalled &&
|
||||
isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
});
|
||||
if (isSetupCompleted(state) && canFinish) {
|
||||
completed = true;
|
||||
return refreshWithState(state);
|
||||
}
|
||||
|
||||
if (yomitanSetupSatisfied) {
|
||||
if (canFinish) {
|
||||
const completedState = writeState({
|
||||
...state,
|
||||
status: 'completed',
|
||||
@@ -347,8 +361,6 @@ export function createFirstRunSetupService(deps: {
|
||||
}),
|
||||
);
|
||||
},
|
||||
skipPluginInstall: async () =>
|
||||
refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })),
|
||||
installMpvPlugin: async () => {
|
||||
const result = await deps.installPlugin();
|
||||
return refreshWithState(
|
||||
|
||||
@@ -14,8 +14,10 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginStatus: 'required',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
@@ -29,6 +31,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
|
||||
assert.match(html, /SubMiner setup/);
|
||||
assert.match(html, /Install mpv plugin/);
|
||||
assert.match(html, /Required before SubMiner setup can finish/);
|
||||
assert.match(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Finish setup/);
|
||||
assert.match(html, /disabled/);
|
||||
@@ -42,6 +45,8 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
mpvExecutablePath: 'C:\\Program Files\\mpv\\mpv.exe',
|
||||
mpvExecutablePathStatus: 'configured',
|
||||
windowsMpvShortcuts: {
|
||||
supported: true,
|
||||
startMenuEnabled: true,
|
||||
@@ -54,6 +59,62 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
});
|
||||
|
||||
assert.match(html, /Reinstall mpv plugin/);
|
||||
assert.match(html, /mpv executable path/);
|
||||
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
|
||||
assert.match(html, /aria-label="Path to mpv\.exe"/);
|
||||
assert.match(
|
||||
html,
|
||||
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
canFinish: true,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
mpvExecutablePath: 'C:\\Broken\\mpv.exe',
|
||||
mpvExecutablePathStatus: 'invalid',
|
||||
windowsMpvShortcuts: {
|
||||
supported: true,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
message: null,
|
||||
});
|
||||
|
||||
assert.match(html, />Invalid</);
|
||||
assert.match(html, /Current: C:\\Broken\\mpv\.exe \(invalid; file not found\)/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml explains the config blocker when setup is missing config', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: false,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'required',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
message: null,
|
||||
});
|
||||
|
||||
assert.match(html, /Create or provide the config file before finishing setup\./);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
|
||||
@@ -62,8 +123,10 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
|
||||
dictionaryCount: 0,
|
||||
canFinish: true,
|
||||
externalYomitanConfigured: true,
|
||||
pluginStatus: 'optional',
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
@@ -83,9 +146,22 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
|
||||
});
|
||||
|
||||
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl(
|
||||
'subminer://first-run-setup?action=configure-mpv-executable-path&mpvExecutablePath=C%3A%5CApps%5Cmpv%5Cmpv.exe',
|
||||
),
|
||||
{
|
||||
action: 'configure-mpv-executable-path',
|
||||
mpvExecutablePath: 'C:\\Apps\\mpv\\mpv.exe',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
||||
action: 'refresh',
|
||||
});
|
||||
assert.equal(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
|
||||
});
|
||||
|
||||
@@ -121,6 +197,25 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
||||
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
|
||||
});
|
||||
|
||||
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
|
||||
const calls: string[] = [];
|
||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||
handleAction: async (submission) => {
|
||||
calls.push(submission.action);
|
||||
},
|
||||
logError: (message) => calls.push(message),
|
||||
});
|
||||
|
||||
const prevented = handleNavigation({
|
||||
url: 'subminer://first-run-setup?action=skip-plugin',
|
||||
preventDefault: () => calls.push('preventDefault'),
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(calls, ['preventDefault']);
|
||||
});
|
||||
|
||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
@@ -146,8 +241,10 @@ test('closing incomplete first-run setup quits app outside background mode', asy
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginStatus: 'required',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
|
||||
|
||||
type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
};
|
||||
@@ -15,15 +17,16 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
||||
};
|
||||
|
||||
export type FirstRunSetupAction =
|
||||
| 'configure-mpv-executable-path'
|
||||
| 'install-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'open-yomitan-settings'
|
||||
| 'refresh'
|
||||
| 'skip-plugin'
|
||||
| 'finish';
|
||||
|
||||
export interface FirstRunSetupSubmission {
|
||||
action: FirstRunSetupAction;
|
||||
mpvExecutablePath?: string;
|
||||
startMenuEnabled?: boolean;
|
||||
desktopEnabled?: boolean;
|
||||
}
|
||||
@@ -33,8 +36,10 @@ export interface FirstRunSetupHtmlModel {
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginStatus: 'installed' | 'required' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
mpvExecutablePath: string;
|
||||
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
|
||||
windowsMpvShortcuts: {
|
||||
supported: boolean;
|
||||
startMenuEnabled: boolean;
|
||||
@@ -64,19 +69,15 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
const pluginLabel =
|
||||
model.pluginStatus === 'installed'
|
||||
? 'Installed'
|
||||
: model.pluginStatus === 'skipped'
|
||||
? 'Skipped'
|
||||
: model.pluginStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Optional';
|
||||
: model.pluginStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Required';
|
||||
const pluginTone =
|
||||
model.pluginStatus === 'installed'
|
||||
? 'ready'
|
||||
: model.pluginStatus === 'failed'
|
||||
? 'danger'
|
||||
: model.pluginStatus === 'skipped'
|
||||
? 'muted'
|
||||
: 'warn';
|
||||
: 'warn';
|
||||
const windowsShortcutLabel =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'Installed'
|
||||
@@ -93,6 +94,50 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.windowsMpvShortcuts.status === 'skipped'
|
||||
? 'muted'
|
||||
: 'warn';
|
||||
const mpvExecutablePathLabel =
|
||||
model.mpvExecutablePathStatus === 'configured'
|
||||
? 'Configured'
|
||||
: model.mpvExecutablePathStatus === 'invalid'
|
||||
? 'Invalid'
|
||||
: 'Blank';
|
||||
const mpvExecutablePathTone =
|
||||
model.mpvExecutablePathStatus === 'configured'
|
||||
? 'ready'
|
||||
: model.mpvExecutablePathStatus === 'invalid'
|
||||
? 'danger'
|
||||
: 'muted';
|
||||
const mpvExecutablePathCurrent =
|
||||
model.mpvExecutablePathStatus === 'blank'
|
||||
? 'blank (PATH discovery)'
|
||||
: model.mpvExecutablePathStatus === 'invalid'
|
||||
? `${model.mpvExecutablePath} (invalid; file not found)`
|
||||
: model.mpvExecutablePath;
|
||||
const mpvExecutablePathCard = model.windowsMpvShortcuts.supported
|
||||
? `
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>mpv executable path</strong>
|
||||
<div class="meta">Leave blank to auto-discover mpv.exe from PATH.</div>
|
||||
<div class="meta">Current: ${escapeHtml(mpvExecutablePathCurrent)}</div>
|
||||
</div>
|
||||
${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)}
|
||||
</div>
|
||||
<form
|
||||
class="path-form"
|
||||
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-mpv-executable-path', mpvExecutablePath: document.getElementById('mpv-executable-path').value }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
|
||||
>
|
||||
<input
|
||||
id="mpv-executable-path"
|
||||
type="text"
|
||||
aria-label="Path to mpv.exe"
|
||||
value="${escapeHtml(model.mpvExecutablePath)}"
|
||||
placeholder="C:\\Program Files\\mpv\\mpv.exe"
|
||||
/>
|
||||
<button type="submit">Save mpv executable path</button>
|
||||
</form>
|
||||
</div>`
|
||||
: '';
|
||||
const windowsShortcutCard = model.windowsMpvShortcuts.supported
|
||||
? `
|
||||
<div class="card block">
|
||||
@@ -128,9 +173,14 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.dictionaryCount >= 1
|
||||
? 'ready'
|
||||
: 'warn';
|
||||
const footerMessage = model.externalYomitanConfigured
|
||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||
const blockerMessage = getFirstRunSetupCompletionMessage(model);
|
||||
const footerMessage = blockerMessage
|
||||
? blockerMessage
|
||||
: model.canFinish
|
||||
? model.externalYomitanConfigured
|
||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||
: 'Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary.'
|
||||
: 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -216,6 +266,24 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
.badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); }
|
||||
.badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); }
|
||||
.badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); }
|
||||
.path-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.path-form input[type='text'] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(202, 211, 245, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
color: var(--text);
|
||||
background: rgba(30, 32, 48, 0.72);
|
||||
font: inherit;
|
||||
}
|
||||
.path-form input[type='text']::placeholder {
|
||||
color: rgba(184, 192, 224, 0.65);
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -269,6 +337,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
<div>
|
||||
<strong>mpv plugin</strong>
|
||||
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
||||
<div class="meta">Required before SubMiner setup can finish.</div>
|
||||
</div>
|
||||
${renderStatusBadge(pluginLabel, pluginTone)}
|
||||
</div>
|
||||
@@ -279,12 +348,12 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
|
||||
</div>
|
||||
${mpvExecutablePathCard}
|
||||
${windowsShortcutCard}
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=skip-plugin'">Skip plugin</button>
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
|
||||
</div>
|
||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||
@@ -301,15 +370,21 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
const parsed = new URL(rawUrl);
|
||||
const action = parsed.searchParams.get('action');
|
||||
if (
|
||||
action !== 'configure-mpv-executable-path' &&
|
||||
action !== 'install-plugin' &&
|
||||
action !== 'configure-windows-mpv-shortcuts' &&
|
||||
action !== 'open-yomitan-settings' &&
|
||||
action !== 'refresh' &&
|
||||
action !== 'skip-plugin' &&
|
||||
action !== 'finish'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'configure-mpv-executable-path') {
|
||||
return {
|
||||
action,
|
||||
mpvExecutablePath: parsed.searchParams.get('mpvExecutablePath') ?? '',
|
||||
};
|
||||
}
|
||||
if (action === 'configure-windows-mpv-shortcuts') {
|
||||
return {
|
||||
action,
|
||||
@@ -337,9 +412,18 @@ export function createHandleFirstRunSetupNavigationHandler(deps: {
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return (params: { url: string; preventDefault: () => void }): boolean => {
|
||||
const submission = deps.parseSubmissionUrl(params.url);
|
||||
if (!submission) return false;
|
||||
if (!params.url.startsWith('subminer://first-run-setup')) {
|
||||
params.preventDefault();
|
||||
return true;
|
||||
}
|
||||
params.preventDefault();
|
||||
let submission: FirstRunSetupSubmission | null;
|
||||
try {
|
||||
submission = deps.parseSubmissionUrl(params.url);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (!submission) return true;
|
||||
void deps.handleAction(submission).catch((error) => {
|
||||
deps.logError('Failed handling first-run setup action', error);
|
||||
});
|
||||
|
||||
77
src/main/runtime/local-subtitle-selection.test.ts
Normal file
77
src/main/runtime/local-subtitle-selection.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createManagedLocalSubtitleSelectionRuntime,
|
||||
resolveManagedLocalSubtitleSelection,
|
||||
} from './local-subtitle-selection';
|
||||
|
||||
const mixedLanguageTrackList = [
|
||||
{ type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true },
|
||||
{ type: 'sub', id: 2, lang: 'pt', title: '[Moshi Moshi]', external: false },
|
||||
{ type: 'sub', id: 3, lang: 'en', title: '(Vivid)', external: false },
|
||||
{ type: 'sub', id: 9, lang: 'en', title: 'English(US)', external: false },
|
||||
{ type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true },
|
||||
{ type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true },
|
||||
];
|
||||
|
||||
test('resolveManagedLocalSubtitleSelection prefers default Japanese primary and English secondary tracks', () => {
|
||||
const result = resolveManagedLocalSubtitleSelection({
|
||||
trackList: mixedLanguageTrackList,
|
||||
primaryLanguages: [],
|
||||
secondaryLanguages: [],
|
||||
});
|
||||
|
||||
assert.equal(result.primaryTrackId, 12);
|
||||
assert.equal(result.secondaryTrackId, 11);
|
||||
});
|
||||
|
||||
test('resolveManagedLocalSubtitleSelection respects configured language overrides', () => {
|
||||
const result = resolveManagedLocalSubtitleSelection({
|
||||
trackList: mixedLanguageTrackList,
|
||||
primaryLanguages: ['pt'],
|
||||
secondaryLanguages: ['ja'],
|
||||
});
|
||||
|
||||
assert.equal(result.primaryTrackId, 1);
|
||||
assert.equal(result.secondaryTrackId, 12);
|
||||
});
|
||||
|
||||
test('managed local subtitle selection runtime applies preferred tracks once for a local media path', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'track-list') {
|
||||
return mixedLanguageTrackList;
|
||||
}
|
||||
throw new Error(`Unexpected property: ${name}`);
|
||||
},
|
||||
}) as never,
|
||||
getPrimarySubtitleLanguages: () => [],
|
||||
getSecondarySubtitleLanguages: () => [],
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
clearScheduled: () => {},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||
scheduled.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
runtime.handleSubtitleTrackListChange(mixedLanguageTrackList);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'sid', 12],
|
||||
['set_property', 'secondary-sid', 11],
|
||||
]);
|
||||
});
|
||||
263
src/main/runtime/local-subtitle-selection.ts
Normal file
263
src/main/runtime/local-subtitle-selection.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
|
||||
|
||||
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn'];
|
||||
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'enus', 'en-us'];
|
||||
const HEARING_IMPAIRED_PATTERN = /\b(hearing impaired|sdh|closed captions?|cc)\b/i;
|
||||
|
||||
type SubtitleTrackLike = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
lang?: unknown;
|
||||
title?: unknown;
|
||||
external?: unknown;
|
||||
selected?: unknown;
|
||||
};
|
||||
|
||||
type NormalizedSubtitleTrack = {
|
||||
id: number;
|
||||
lang: string;
|
||||
title: string;
|
||||
external: boolean;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type ManagedLocalSubtitleSelection = {
|
||||
primaryTrackId: number | null;
|
||||
secondaryTrackId: number | null;
|
||||
hasPrimaryMatch: boolean;
|
||||
hasSecondaryMatch: boolean;
|
||||
};
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTrack(entry: unknown): NormalizedSubtitleTrack | null {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const track = entry as SubtitleTrackLike;
|
||||
const id = parseTrackId(track.id);
|
||||
if (id === null || (track.type !== undefined && track.type !== 'sub')) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
lang: String(track.lang || '').trim(),
|
||||
title: String(track.title || '').trim(),
|
||||
external: track.external === true,
|
||||
selected: track.selected === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLanguageList(values: string[], fallback: string[]): string[] {
|
||||
const normalized = values
|
||||
.map((value) => normalizeYoutubeLangCode(value))
|
||||
.filter((value, index, items) => value.length > 0 && items.indexOf(value) === index);
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
return fallback
|
||||
.map((value) => normalizeYoutubeLangCode(value))
|
||||
.filter((value, index, items) => value.length > 0 && items.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function resolveLanguageRank(language: string, preferredLanguages: string[]): number {
|
||||
const normalized = normalizeYoutubeLangCode(language);
|
||||
if (!normalized) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
const directIndex = preferredLanguages.indexOf(normalized);
|
||||
if (directIndex >= 0) {
|
||||
return directIndex;
|
||||
}
|
||||
const base = normalized.split('-')[0] || normalized;
|
||||
const baseIndex = preferredLanguages.indexOf(base);
|
||||
return baseIndex >= 0 ? baseIndex : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function isLikelyHearingImpaired(title: string): boolean {
|
||||
return HEARING_IMPAIRED_PATTERN.test(title);
|
||||
}
|
||||
|
||||
function pickBestTrackId(
|
||||
tracks: NormalizedSubtitleTrack[],
|
||||
preferredLanguages: string[],
|
||||
excludeId: number | null = null,
|
||||
): { trackId: number | null; hasMatch: boolean } {
|
||||
const ranked = tracks
|
||||
.filter((track) => track.id !== excludeId)
|
||||
.map((track) => ({
|
||||
track,
|
||||
languageRank: resolveLanguageRank(track.lang, preferredLanguages),
|
||||
}))
|
||||
.filter(({ languageRank }) => Number.isFinite(languageRank))
|
||||
.sort((left, right) => {
|
||||
if (left.languageRank !== right.languageRank) {
|
||||
return left.languageRank - right.languageRank;
|
||||
}
|
||||
if (left.track.external !== right.track.external) {
|
||||
return left.track.external ? -1 : 1;
|
||||
}
|
||||
if (
|
||||
isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)
|
||||
) {
|
||||
return isLikelyHearingImpaired(left.track.title) ? 1 : -1;
|
||||
}
|
||||
if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) {
|
||||
return /\bdefault\b/i.test(left.track.title) ? -1 : 1;
|
||||
}
|
||||
return left.track.id - right.track.id;
|
||||
});
|
||||
|
||||
return {
|
||||
trackId: ranked[0]?.track.id ?? null,
|
||||
hasMatch: ranked.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveManagedLocalSubtitleSelection(input: {
|
||||
trackList: unknown[] | null;
|
||||
primaryLanguages: string[];
|
||||
secondaryLanguages: string[];
|
||||
}): ManagedLocalSubtitleSelection {
|
||||
const tracks = Array.isArray(input.trackList)
|
||||
? input.trackList
|
||||
.map(normalizeTrack)
|
||||
.filter((track): track is NormalizedSubtitleTrack => track !== null)
|
||||
: [];
|
||||
const preferredPrimaryLanguages = normalizeLanguageList(
|
||||
input.primaryLanguages,
|
||||
DEFAULT_PRIMARY_SUBTITLE_LANGUAGES,
|
||||
);
|
||||
const preferredSecondaryLanguages = normalizeLanguageList(
|
||||
input.secondaryLanguages,
|
||||
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
|
||||
);
|
||||
|
||||
const primary = pickBestTrackId(tracks, preferredPrimaryLanguages);
|
||||
const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primary.trackId);
|
||||
|
||||
return {
|
||||
primaryTrackId: primary.trackId,
|
||||
secondaryTrackId: secondary.trackId,
|
||||
hasPrimaryMatch: primary.hasMatch,
|
||||
hasSecondaryMatch: secondary.hasMatch,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null {
|
||||
if (typeof mediaPath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = mediaPath.trim();
|
||||
if (!trimmed || isRemoteMediaPath(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getMpvClient: () => {
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getPrimarySubtitleLanguages: () => string[];
|
||||
getSecondarySubtitleLanguages: () => string[];
|
||||
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? 400;
|
||||
let currentMediaPath: string | null = null;
|
||||
let appliedMediaPath: string | null = null;
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
if (!pendingTimer) {
|
||||
return;
|
||||
}
|
||||
deps.clearScheduled(pendingTimer);
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
trackList,
|
||||
primaryLanguages: deps.getPrimarySubtitleLanguages(),
|
||||
secondaryLanguages: deps.getSecondarySubtitleLanguages(),
|
||||
});
|
||||
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
|
||||
return;
|
||||
}
|
||||
if (selection.primaryTrackId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
|
||||
}
|
||||
if (selection.secondaryTrackId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||
}
|
||||
appliedMediaPath = currentMediaPath;
|
||||
clearPendingTimer();
|
||||
};
|
||||
|
||||
const refreshFromMpv = async (): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected || !client.requestProperty) {
|
||||
return;
|
||||
}
|
||||
const mediaPath = normalizeLocalMediaPath(deps.getCurrentMediaPath());
|
||||
if (!mediaPath || mediaPath !== currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const trackList = await client.requestProperty('track-list');
|
||||
maybeApplySelection(Array.isArray(trackList) ? trackList : null);
|
||||
} catch {
|
||||
// Skip selection when mpv track inspection fails.
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = (): void => {
|
||||
clearPendingTimer();
|
||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
pendingTimer = null;
|
||||
void refreshFromMpv();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
return {
|
||||
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
||||
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
||||
if (normalizedPath !== currentMediaPath) {
|
||||
appliedMediaPath = null;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
if (!currentMediaPath) {
|
||||
clearPendingTimer();
|
||||
return;
|
||||
}
|
||||
scheduleRefresh();
|
||||
},
|
||||
handleSubtitleTrackListChange: (trackList: unknown[] | null): void => {
|
||||
maybeApplySelection(trackList);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,15 +24,22 @@ export type PlaylistBrowserIpcRuntime = {
|
||||
|
||||
export function createPlaylistBrowserIpcRuntime(
|
||||
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
|
||||
options?: Pick<
|
||||
PlaylistBrowserRuntimeDeps,
|
||||
'getPrimarySubtitleLanguages' | 'getSecondarySubtitleLanguages'
|
||||
>,
|
||||
): PlaylistBrowserIpcRuntime {
|
||||
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
|
||||
getMpvClient,
|
||||
getPrimarySubtitleLanguages: options?.getPrimarySubtitleLanguages,
|
||||
getSecondarySubtitleLanguages: options?.getSecondarySubtitleLanguages,
|
||||
};
|
||||
|
||||
return {
|
||||
playlistBrowserRuntimeDeps,
|
||||
playlistBrowserMainDeps: {
|
||||
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
|
||||
getPlaylistBrowserSnapshot: () =>
|
||||
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
|
||||
appendPlaylistBrowserFile: (filePath: string) =>
|
||||
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
|
||||
playPlaylistBrowserIndex: (index: number) =>
|
||||
|
||||
@@ -102,7 +102,8 @@ function createFakeMpvClient(options: {
|
||||
if (removingCurrent) {
|
||||
syncFlags();
|
||||
this.currentVideoPath =
|
||||
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
|
||||
playlist.find((item) => item.current || item.playing)?.filename ??
|
||||
this.currentVideoPath;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -125,6 +126,17 @@ function createFakeMpvClient(options: {
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
@@ -265,8 +277,12 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
]);
|
||||
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
||||
assert.deepEqual(
|
||||
scheduled.map((entry) => entry.delayMs),
|
||||
[400],
|
||||
);
|
||||
scheduled[0]?.callback();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
@@ -370,10 +386,7 @@ test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', as
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true },
|
||||
{ filename: episode2 },
|
||||
],
|
||||
playlist: [{ filename: episode1, current: true }, { filename: episode2 }],
|
||||
});
|
||||
|
||||
const deps = {
|
||||
@@ -472,16 +485,130 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
|
||||
|
||||
scheduled[0]?.();
|
||||
scheduled[1]?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(
|
||||
mpvClient.getCommands().slice(-6),
|
||||
[
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 2],
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-6), [
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 1],
|
||||
['set_property', 'sub-auto', 'fuzzy'],
|
||||
['playlist-play-index', 2],
|
||||
['set_property', 'sid', 'auto'],
|
||||
['set_property', 'secondary-sid', 'auto'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('playPlaylistBrowserIndexRuntime aborts stale async subtitle rearm work', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
|
||||
const firstTrackList = createDeferred<unknown>();
|
||||
const secondTrackList = createDeferred<unknown>();
|
||||
let trackListRequestCount = 0;
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true, title: 'Episode 1' },
|
||||
{ filename: episode2, title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
|
||||
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
|
||||
if (name === 'track-list') {
|
||||
trackListRequestCount += 1;
|
||||
return trackListRequestCount === 1 ? firstTrackList.promise : secondTrackList.promise;
|
||||
}
|
||||
return requestProperty(name);
|
||||
};
|
||||
|
||||
const scheduled: Array<() => void> = [];
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
schedule: (callback: () => void) => {
|
||||
scheduled.push(callback);
|
||||
},
|
||||
};
|
||||
|
||||
const firstPlay = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||
assert.equal(firstPlay.ok, true);
|
||||
scheduled[0]?.();
|
||||
|
||||
const secondPlay = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||
assert.equal(secondPlay.ok, true);
|
||||
scheduled[1]?.();
|
||||
|
||||
secondTrackList.resolve([
|
||||
{ type: 'sub', id: 21, lang: 'ja', title: 'Japanese', external: false, selected: true },
|
||||
{ type: 'sub', id: 22, lang: 'en', title: 'English', external: false },
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
firstTrackList.resolve([
|
||||
{ type: 'sub', id: 11, lang: 'ja', title: 'Japanese', external: false, selected: true },
|
||||
{ type: 'sub', id: 12, lang: 'en', title: 'English', external: false },
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const subtitleCommands = mpvClient
|
||||
.getCommands()
|
||||
.filter(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && (command[1] === 'sid' || command[1] === 'secondary-sid'),
|
||||
);
|
||||
|
||||
assert.deepEqual(subtitleCommands, [
|
||||
['set_property', 'sid', 21],
|
||||
['set_property', 'secondary-sid', 22],
|
||||
]);
|
||||
});
|
||||
|
||||
test('playlist-browser playback reapplies configured preferred subtitle tracks when track metadata is available', async (t) => {
|
||||
const dir = createTempVideoDir(t);
|
||||
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||
fs.writeFileSync(episode1, '');
|
||||
fs.writeFileSync(episode2, '');
|
||||
|
||||
const mpvClient = createFakeMpvClient({
|
||||
currentVideoPath: episode1,
|
||||
playlist: [
|
||||
{ filename: episode1, current: true, title: 'Episode 1' },
|
||||
{ filename: episode2, title: 'Episode 2' },
|
||||
],
|
||||
});
|
||||
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
|
||||
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true },
|
||||
{ type: 'sub', id: 3, lang: 'en', title: 'English', external: false },
|
||||
{ type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true },
|
||||
{ type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true },
|
||||
];
|
||||
}
|
||||
return requestProperty(name);
|
||||
};
|
||||
|
||||
const scheduled: Array<() => void> = [];
|
||||
const deps = {
|
||||
getMpvClient: () => mpvClient,
|
||||
getPrimarySubtitleLanguages: () => [],
|
||||
getSecondarySubtitleLanguages: () => [],
|
||||
schedule: (callback: () => void) => {
|
||||
scheduled.push(callback);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
scheduled[0]?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||
['set_property', 'sid', 12],
|
||||
['set_property', 'secondary-sid', 11],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '../../types';
|
||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||
import { hasVideoExtension } from '../../shared/video-extensions';
|
||||
import { resolveManagedLocalSubtitleSelection } from './local-subtitle-selection';
|
||||
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
||||
|
||||
type PlaylistLike = {
|
||||
@@ -28,6 +29,8 @@ type MpvPlaylistBrowserClientLike = {
|
||||
export type PlaylistBrowserRuntimeDeps = {
|
||||
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
|
||||
schedule?: (callback: () => void, delayMs: number) => void;
|
||||
getPrimarySubtitleLanguages?: () => string[];
|
||||
getSecondarySubtitleLanguages?: () => string[];
|
||||
};
|
||||
|
||||
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
|
||||
@@ -60,7 +63,10 @@ async function resolveCurrentFilePath(
|
||||
|
||||
function resolveDirectorySnapshot(
|
||||
currentFilePath: string | null,
|
||||
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
|
||||
): Pick<
|
||||
PlaylistBrowserSnapshot,
|
||||
'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'
|
||||
> {
|
||||
if (!currentFilePath) {
|
||||
return {
|
||||
directoryAvailable: false,
|
||||
@@ -229,9 +235,30 @@ async function buildMutationResult(
|
||||
};
|
||||
}
|
||||
|
||||
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
|
||||
client.send({ command: ['set_property', 'sid', 'auto'] });
|
||||
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
|
||||
async function rearmLocalSubtitleSelection(
|
||||
client: MpvPlaylistBrowserClientLike,
|
||||
deps: PlaylistBrowserRuntimeDeps,
|
||||
expectedPath: string,
|
||||
token: number,
|
||||
): Promise<void> {
|
||||
const trackList = await readProperty(client, 'track-list');
|
||||
if (pendingLocalSubtitleSelectionRearms.get(client) !== token) {
|
||||
return;
|
||||
}
|
||||
const currentPath = trimToNull(client.currentVideoPath);
|
||||
if (currentPath && path.resolve(currentPath) !== expectedPath) {
|
||||
return;
|
||||
}
|
||||
pendingLocalSubtitleSelectionRearms.delete(client);
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
trackList: Array.isArray(trackList) ? trackList : null,
|
||||
primaryLanguages: deps.getPrimarySubtitleLanguages?.() ?? [],
|
||||
secondaryLanguages: deps.getSecondarySubtitleLanguages?.() ?? [],
|
||||
});
|
||||
client.send({ command: ['set_property', 'sid', selection.primaryTrackId ?? 'auto'] });
|
||||
client.send({
|
||||
command: ['set_property', 'secondary-sid', selection.secondaryTrackId ?? 'auto'],
|
||||
});
|
||||
}
|
||||
|
||||
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
|
||||
@@ -253,12 +280,7 @@ function scheduleLocalSubtitleSelectionRearm(
|
||||
pendingLocalSubtitleSelectionRearms.set(client, nextToken);
|
||||
(deps.schedule ?? setTimeout)(() => {
|
||||
if (pendingLocalSubtitleSelectionRearms.get(client) !== nextToken) return;
|
||||
pendingLocalSubtitleSelectionRearms.delete(client);
|
||||
const currentPath = trimToNull(client.currentVideoPath);
|
||||
if (currentPath && path.resolve(currentPath) !== expectedPath) {
|
||||
return;
|
||||
}
|
||||
rearmLocalSubtitleSelection(client);
|
||||
void rearmLocalSubtitleSelection(client, deps, expectedPath, nextToken);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function createDeps(overrides: Partial<WindowsMpvLaunchDeps> = {}): WindowsMpvLa
|
||||
getEnv: () => undefined,
|
||||
runWhere: () => ({ status: 1, stdout: '' }),
|
||||
fileExists: () => false,
|
||||
spawnDetached: () => undefined,
|
||||
spawnDetached: async () => undefined,
|
||||
showError: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
@@ -29,6 +29,19 @@ test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
|
||||
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath prefers configured executable path before PATH', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
getEnv: () => undefined,
|
||||
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
}),
|
||||
' C:\\mpv\\mpv.exe ',
|
||||
);
|
||||
|
||||
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
@@ -40,18 +53,118 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
||||
assert.equal(resolved, 'C:\\tools\\mpv.exe');
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
|
||||
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--profile=subminer',
|
||||
'C:\\a.mkv',
|
||||
'C:\\b.mkv',
|
||||
]);
|
||||
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
|
||||
assert.deepEqual(
|
||||
buildWindowsMpvLaunchArgs(
|
||||
['C:\\a.mkv', 'C:\\b.mkv'],
|
||||
[],
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
),
|
||||
[
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--force-window=immediate',
|
||||
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--sub-auto=fuzzy',
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||
'C:\\a.mkv',
|
||||
'C:\\b.mkv',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports missing mpv path', () => {
|
||||
test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () => {
|
||||
assert.deepEqual(
|
||||
buildWindowsMpvLaunchArgs(
|
||||
[],
|
||||
[],
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
),
|
||||
[
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--force-window=immediate',
|
||||
'--idle=yes',
|
||||
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--sub-auto=fuzzy',
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script opts', () => {
|
||||
assert.deepEqual(
|
||||
buildWindowsMpvLaunchArgs(
|
||||
['C:\\video.mkv'],
|
||||
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
),
|
||||
[
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--force-window=immediate',
|
||||
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
'--input-ipc-server=\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--sub-auto=fuzzy',
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--input-ipc-server',
|
||||
'\\\\.\\pipe\\custom-subminer-socket',
|
||||
'C:\\video.mkv',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoint is present without binary path', () => {
|
||||
assert.deepEqual(
|
||||
buildWindowsMpvLaunchArgs(
|
||||
['C:\\video.mkv'],
|
||||
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
|
||||
undefined,
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
),
|
||||
[
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--force-window=immediate',
|
||||
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
'--input-ipc-server=\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--sub-auto=fuzzy',
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--script-opts=subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--input-ipc-server',
|
||||
'\\\\.\\pipe\\custom-subminer-socket',
|
||||
'C:\\video.mkv',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports missing mpv path', async () => {
|
||||
const errors: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
const result = await launchWindowsMpv(
|
||||
[],
|
||||
createDeps({
|
||||
showError: (_title, content) => errors.push(content),
|
||||
@@ -60,39 +173,42 @@ test('launchWindowsMpv reports missing mpv path', () => {
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.mpvPath, '');
|
||||
assert.match(errors[0] ?? '', /Could not find mpv\.exe/i);
|
||||
assert.match(errors[0] ?? '', /mpv\.executablePath/i);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv spawns detached mpv with targets', () => {
|
||||
test('launchWindowsMpv spawns detached mpv with targets', async () => {
|
||||
const calls: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
const result = await launchWindowsMpv(
|
||||
['C:\\video.mkv'],
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
spawnDetached: (command, args) => {
|
||||
spawnDetached: async (command, args) => {
|
||||
calls.push(command);
|
||||
calls.push(args.join('|'));
|
||||
},
|
||||
}),
|
||||
[],
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||
assert.deepEqual(calls, [
|
||||
'C:\\mpv\\mpv.exe',
|
||||
'--player-operation-mode=pseudo-gui|--profile=subminer|C:\\video.mkv',
|
||||
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports spawn failures with path context', () => {
|
||||
test('launchWindowsMpv reports spawn failures with path context', async () => {
|
||||
const errors: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
const result = await launchWindowsMpv(
|
||||
[],
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
spawnDetached: () => {
|
||||
spawnDetached: async () => {
|
||||
throw new Error('spawn failed');
|
||||
},
|
||||
showError: (_title, content) => errors.push(content),
|
||||
@@ -104,3 +220,21 @@ test('launchWindowsMpv reports spawn failures with path context', () => {
|
||||
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
|
||||
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports async spawn failures with path context', async () => {
|
||||
const errors: string[] = [];
|
||||
const result = await launchWindowsMpv(
|
||||
[],
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
spawnDetached: () => Promise.reject(new Error('async spawn failed')),
|
||||
showError: (_title, content) => errors.push(content),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
|
||||
assert.match(errors[0] ?? '', /async spawn failed/i);
|
||||
});
|
||||
|
||||
@@ -5,15 +5,45 @@ export interface WindowsMpvLaunchDeps {
|
||||
getEnv: (name: string) => string | undefined;
|
||||
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||
fileExists: (candidate: string) => boolean;
|
||||
spawnDetached: (command: string, args: string[]) => void;
|
||||
spawnDetached: (command: string, args: string[]) => Promise<void>;
|
||||
showError: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
|
||||
|
||||
function normalizeCandidate(candidate: string | undefined): string {
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
||||
function defaultWindowsMpvFileExists(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfiguredWindowsMpvPathStatus(
|
||||
configuredMpvPath = '',
|
||||
fileExists: (candidate: string) => boolean = defaultWindowsMpvFileExists,
|
||||
): ConfiguredWindowsMpvPathStatus {
|
||||
const configPath = normalizeCandidate(configuredMpvPath);
|
||||
if (!configPath) {
|
||||
return 'blank';
|
||||
}
|
||||
return fileExists(configPath) ? 'configured' : 'invalid';
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvPath = ''): string {
|
||||
const configPath = normalizeCandidate(configuredMpvPath);
|
||||
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
|
||||
if (configuredPathStatus === 'configured') {
|
||||
return configPath;
|
||||
}
|
||||
if (configuredPathStatus === 'invalid') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
|
||||
if (envPath && deps.fileExists(envPath)) {
|
||||
return envPath;
|
||||
@@ -33,26 +63,92 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] {
|
||||
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
|
||||
const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
|
||||
|
||||
function readExtraArgValue(extraArgs: string[], flag: string): string | undefined {
|
||||
let value: string | undefined;
|
||||
for (let i = 0; i < extraArgs.length; i += 1) {
|
||||
const arg = extraArgs[i];
|
||||
if (arg === flag) {
|
||||
const next = extraArgs[i + 1];
|
||||
if (next && !next.startsWith('-')) {
|
||||
value = next;
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg?.startsWith(`${flag}=`)) {
|
||||
value = arg.slice(flag.length + 1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function launchWindowsMpv(
|
||||
export function buildWindowsMpvLaunchArgs(
|
||||
targets: string[],
|
||||
extraArgs: string[] = [],
|
||||
binaryPath?: string,
|
||||
pluginEntrypointPath?: string,
|
||||
): string[] {
|
||||
const launchIdle = targets.length === 0;
|
||||
const inputIpcServer =
|
||||
readExtraArgValue(extraArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET;
|
||||
const scriptEntrypoint =
|
||||
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
|
||||
? `--script=${pluginEntrypointPath.trim()}`
|
||||
: null;
|
||||
const scriptOptPairs = scriptEntrypoint
|
||||
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
|
||||
: [];
|
||||
if (scriptEntrypoint && typeof binaryPath === 'string' && binaryPath.trim().length > 0) {
|
||||
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
|
||||
}
|
||||
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
|
||||
|
||||
return [
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--force-window=immediate',
|
||||
...(launchIdle ? ['--idle=yes'] : []),
|
||||
...(scriptEntrypoint ? [scriptEntrypoint] : []),
|
||||
`--input-ipc-server=${inputIpcServer}`,
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--sub-auto=fuzzy',
|
||||
'--sub-file-paths=subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--secondary-sub-visibility=no',
|
||||
...(scriptOpts ? [scriptOpts] : []),
|
||||
...extraArgs,
|
||||
...targets,
|
||||
];
|
||||
}
|
||||
|
||||
export async function launchWindowsMpv(
|
||||
targets: string[],
|
||||
deps: WindowsMpvLaunchDeps,
|
||||
extraArgs: string[] = [],
|
||||
): { ok: boolean; mpvPath: string } {
|
||||
const mpvPath = resolveWindowsMpvPath(deps);
|
||||
binaryPath?: string,
|
||||
pluginEntrypointPath?: string,
|
||||
configuredMpvPath?: string,
|
||||
): Promise<{ ok: boolean; mpvPath: string }> {
|
||||
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
|
||||
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
|
||||
if (!mpvPath) {
|
||||
deps.showError(
|
||||
'SubMiner mpv launcher',
|
||||
'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.',
|
||||
normalizedConfiguredPath
|
||||
? `Configured mpv.executablePath was not found: ${normalizedConfiguredPath}`
|
||||
: 'Could not find mpv.exe. Set mpv.executablePath, set SUBMINER_MPV_PATH, or add mpv.exe to PATH.',
|
||||
);
|
||||
return { ok: false, mpvPath: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
|
||||
await deps.spawnDetached(
|
||||
mpvPath,
|
||||
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath),
|
||||
);
|
||||
return { ok: true, mpvPath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -79,23 +175,31 @@ export function createWindowsMpvLaunchDeps(options: {
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
},
|
||||
fileExists:
|
||||
options.fileExists ??
|
||||
((candidate) => {
|
||||
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
|
||||
spawnDetached: (command, args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
let settled = false;
|
||||
child.once('error', (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
});
|
||||
child.once('spawn', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.unref();
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}),
|
||||
spawnDetached: (command, args) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
},
|
||||
showError: options.showError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', ()
|
||||
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
args: '--launch-mpv',
|
||||
cwd: 'C:\\Apps\\SubMiner',
|
||||
description: 'Launch mpv with the SubMiner profile',
|
||||
description: 'Launch mpv with SubMiner defaults',
|
||||
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
iconIndex: 0,
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcut
|
||||
target: exePath,
|
||||
args: '--launch-mpv',
|
||||
cwd: path.win32.dirname(exePath),
|
||||
description: 'Launch mpv with the SubMiner profile',
|
||||
description: 'Launch mpv with SubMiner defaults',
|
||||
icon: exePath,
|
||||
iconIndex: 0,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
|
||||
resolveYoutubePlaybackUrl: async () => {
|
||||
throw new Error('linux path should not resolve direct playback url');
|
||||
},
|
||||
launchWindowsMpv: () => ({ ok: false }),
|
||||
launchWindowsMpv: async () => ({ ok: false }),
|
||||
waitForYoutubeMpvConnected: async (timeoutMs) => {
|
||||
calls.push(`wait-connected:${timeoutMs}`);
|
||||
return true;
|
||||
@@ -105,7 +105,7 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
calls.push(`resolve:${url}:${format}`);
|
||||
return 'https://example.com/direct';
|
||||
},
|
||||
launchWindowsMpv: (_playbackUrl, args) => {
|
||||
launchWindowsMpv: async (_playbackUrl, args) => {
|
||||
calls.push(`launch:${args.join(' ')}`);
|
||||
return { ok: true, mpvPath: '/usr/bin/mpv' };
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
||||
setAppOwnedFlowInFlight: (next: boolean) => void;
|
||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
||||
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
|
||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => LaunchResult;
|
||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
@@ -77,7 +77,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
|
||||
if (deps.platform === 'win32' && !deps.getMpvConnected()) {
|
||||
const socketPath = deps.getSocketPath();
|
||||
const launchResult = deps.launchWindowsMpv(playbackUrl, [
|
||||
const launchResult = await deps.launchWindowsMpv(playbackUrl, [
|
||||
'--pause=yes',
|
||||
'--ytdl=yes',
|
||||
`--ytdl-format=${deps.mpvYtdlFormat}`,
|
||||
@@ -92,7 +92,9 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
]);
|
||||
launchedWindowsMpv = launchResult.ok;
|
||||
if (launchResult.ok && launchResult.mpvPath) {
|
||||
deps.logInfo(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
|
||||
deps.logInfo(
|
||||
`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`,
|
||||
);
|
||||
}
|
||||
if (!launchResult.ok) {
|
||||
deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createKeyboardHandlers(
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||
let lookupScanFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -358,12 +359,10 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function isSubtitleSeekCommand(command: (string | number)[] | undefined): command is [string, number] {
|
||||
return (
|
||||
Array.isArray(command) &&
|
||||
command[0] === 'sub-seek' &&
|
||||
typeof command[1] === 'number'
|
||||
);
|
||||
function isSubtitleSeekCommand(
|
||||
command: (string | number)[] | undefined,
|
||||
): command is [string, number] {
|
||||
return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number';
|
||||
}
|
||||
|
||||
function dispatchConfiguredMpvCommand(command: (string | number)[]): void {
|
||||
@@ -485,7 +484,9 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
// Fallback only if the explicit scan path did not open popup quickly.
|
||||
setTimeout(() => {
|
||||
if (lookupScanFallbackTimer !== null) clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = setTimeout(() => {
|
||||
lookupScanFallbackTimer = null;
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
return;
|
||||
}
|
||||
@@ -525,6 +526,10 @@ export function createKeyboardHandlers(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lookupScanFallbackTimer !== null) {
|
||||
clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = null;
|
||||
}
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
dispatchYomitanFrontendClearActiveTextSource();
|
||||
clearNativeSubtitleSelection();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
PlaylistBrowserDirectoryItem,
|
||||
PlaylistBrowserQueueItem,
|
||||
} from '../../types';
|
||||
import type { PlaylistBrowserDirectoryItem, PlaylistBrowserQueueItem } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
|
||||
type PlaylistBrowserRowRenderActions = {
|
||||
@@ -55,7 +52,7 @@ export function renderPlaylistBrowserDirectoryRow(
|
||||
? item.episodeLabel
|
||||
? `${item.episodeLabel} · Current file`
|
||||
: 'Current file'
|
||||
: item.episodeLabel ?? 'Video file';
|
||||
: (item.episodeLabel ?? 'Video file');
|
||||
main.append(label, meta);
|
||||
|
||||
const trailing = document.createElement('div');
|
||||
|
||||
@@ -236,9 +236,17 @@ function createPlaylistBrowserElectronApi(overrides?: Partial<ElectronAPI>): Ele
|
||||
notifyOverlayModalClosed: () => {},
|
||||
focusMainWindow: async () => {},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
|
||||
appendPlaylistBrowserFile: async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
snapshot: createSnapshot(),
|
||||
}),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
|
||||
removePlaylistBrowserIndex: async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
snapshot: createSnapshot(),
|
||||
}),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
|
||||
...overrides,
|
||||
} as ElectronAPI;
|
||||
@@ -348,15 +356,13 @@ test('playlist browser modal action buttons stop double-click propagation', asyn
|
||||
|
||||
await modal.openPlaylistBrowserModal();
|
||||
|
||||
const row =
|
||||
env.dom.playlistBrowserDirectoryList.children[0] as
|
||||
| ReturnType<typeof createPlaylistRow>
|
||||
| undefined;
|
||||
const row = env.dom.playlistBrowserDirectoryList.children[0] as
|
||||
| ReturnType<typeof createPlaylistRow>
|
||||
| undefined;
|
||||
const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined;
|
||||
const button =
|
||||
trailing?.children?.at(-1) as
|
||||
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
|
||||
| undefined;
|
||||
const button = trailing?.children?.at(-1) as
|
||||
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
|
||||
| undefined;
|
||||
const dblclickHandler = button?.listeners?.get('dblclick')?.[0];
|
||||
|
||||
assert.equal(typeof dblclickHandler, 'function');
|
||||
|
||||
@@ -31,7 +31,8 @@ function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): n
|
||||
|
||||
function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
|
||||
const playlistIndex =
|
||||
snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing);
|
||||
snapshot.playingIndex ??
|
||||
snapshot.playlistItems.findIndex((item) => item.current || item.playing);
|
||||
return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length);
|
||||
}
|
||||
|
||||
@@ -225,7 +226,10 @@ export function createPlaylistBrowserModal(
|
||||
}
|
||||
|
||||
async function removePlaylistItem(index: number): Promise<void> {
|
||||
await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item');
|
||||
await handleMutation(
|
||||
window.electronAPI.removePlaylistBrowserIndex(index),
|
||||
'Removed queue item',
|
||||
);
|
||||
}
|
||||
|
||||
async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> {
|
||||
|
||||
@@ -453,8 +453,7 @@ body {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(110, 115, 141, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6));
|
||||
background: linear-gradient(180deg, rgba(54, 58, 79, 0.55), rgba(36, 39, 58, 0.6));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
@@ -496,8 +495,12 @@ body {
|
||||
}
|
||||
|
||||
.playlist-browser-row.current {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(138, 173, 244, 0.12), rgba(138, 173, 244, 0.03) 28%, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(138, 173, 244, 0.12),
|
||||
rgba(138, 173, 244, 0.03) 28%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 3px 0 0 #8aadf4;
|
||||
}
|
||||
|
||||
|
||||
@@ -222,8 +222,12 @@ export function resolveRendererDom(): RendererDom {
|
||||
playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'),
|
||||
playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'),
|
||||
playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'),
|
||||
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>('playlistBrowserDirectoryList'),
|
||||
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>('playlistBrowserPlaylistList'),
|
||||
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>(
|
||||
'playlistBrowserDirectoryList',
|
||||
),
|
||||
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>(
|
||||
'playlistBrowserPlaylistList',
|
||||
),
|
||||
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,13 +47,10 @@ test('RuntimeOptionsManager returns detached effective Anki config copies', () =
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new RuntimeOptionsManager(
|
||||
() => structuredClone(baseConfig),
|
||||
{
|
||||
applyAnkiPatch: () => undefined,
|
||||
onOptionsChanged: () => undefined,
|
||||
},
|
||||
);
|
||||
const manager = new RuntimeOptionsManager(() => structuredClone(baseConfig), {
|
||||
applyAnkiPatch: () => undefined,
|
||||
onOptionsChanged: () => undefined,
|
||||
});
|
||||
|
||||
const effective = manager.getEffectiveAnkiConnectConfig();
|
||||
effective.tags!.push('mutated');
|
||||
|
||||
@@ -191,36 +191,21 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
|
||||
const macHomeDir = path.join(path.sep, 'Users', 'tester');
|
||||
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', macHomeDir, undefined), {
|
||||
supported: true,
|
||||
mpvConfigDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv'),
|
||||
scriptsDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'),
|
||||
scriptOptsDir: path.posix.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'mpv',
|
||||
'script-opts',
|
||||
),
|
||||
mpvConfigDir: path.posix.join(macHomeDir, '.config', 'mpv'),
|
||||
scriptsDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts'),
|
||||
scriptOptsDir: path.posix.join(macHomeDir, '.config', 'mpv', 'script-opts'),
|
||||
pluginEntrypointPath: path.posix.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'.config',
|
||||
'mpv',
|
||||
'scripts',
|
||||
'subminer',
|
||||
'main.lua',
|
||||
),
|
||||
pluginDir: path.posix.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'mpv',
|
||||
'scripts',
|
||||
'subminer',
|
||||
),
|
||||
pluginDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts', 'subminer'),
|
||||
pluginConfigPath: path.posix.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'.config',
|
||||
'mpv',
|
||||
'script-opts',
|
||||
'subminer.conf',
|
||||
|
||||
@@ -241,9 +241,7 @@ export function resolveDefaultMpvInstallPaths(
|
||||
): MpvInstallPaths {
|
||||
const platformPath = getPlatformPath(platform);
|
||||
const mpvConfigDir =
|
||||
platform === 'darwin'
|
||||
? platformPath.join(homeDir, 'Library', 'Application Support', 'mpv')
|
||||
: platform === 'linux'
|
||||
platform === 'linux' || platform === 'darwin'
|
||||
? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv')
|
||||
: platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv');
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface TexthookerConfig {
|
||||
openBrowser?: boolean;
|
||||
}
|
||||
|
||||
export interface MpvConfig {
|
||||
executablePath?: string;
|
||||
}
|
||||
|
||||
export type SubsyncMode = 'auto' | 'manual';
|
||||
|
||||
export interface SubsyncConfig {
|
||||
@@ -90,6 +94,7 @@ export interface Config {
|
||||
websocket?: WebSocketConfig;
|
||||
annotationWebsocket?: AnnotationWebSocketConfig;
|
||||
texthooker?: TexthookerConfig;
|
||||
mpv?: MpvConfig;
|
||||
controller?: ControllerConfig;
|
||||
ankiConnect?: AnkiConnectConfig;
|
||||
shortcuts?: ShortcutsConfig;
|
||||
@@ -122,6 +127,9 @@ export interface ResolvedConfig {
|
||||
websocket: Required<WebSocketConfig>;
|
||||
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
||||
texthooker: Required<TexthookerConfig>;
|
||||
mpv: {
|
||||
executablePath: string;
|
||||
};
|
||||
controller: {
|
||||
enabled: boolean;
|
||||
preferredGamepadId: string;
|
||||
|
||||
Reference in New Issue
Block a user