Apply remaining working-tree updates

This commit is contained in:
2026-02-14 00:36:01 -08:00
parent cb9a599b23
commit a1209ca69f
40 changed files with 1001 additions and 607 deletions

View File

@@ -1,6 +1,7 @@
import { ipcMain, IpcMainEvent } from "electron";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import {
JimakuApiResponse,
JimakuDownloadQuery,
@@ -115,14 +116,9 @@ export function registerAnkiJimakuIpcHandlers(
return { ok: false, error: { error: "No media file loaded in MPV." } };
}
if (deps.isRemoteMediaPath(currentMediaPath)) {
return {
ok: false,
error: { error: "Cannot download subtitles for remote media paths." },
};
}
const mediaDir = path.dirname(path.resolve(currentMediaPath));
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), "subminer-jimaku-"))
: path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(query.name);
if (!safeName) {
return { ok: false, error: { error: "Invalid subtitle filename." } };

View File

@@ -25,6 +25,11 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
sendToVisibleOverlay?: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
) => boolean;
}
export function createFieldGroupingOverlayRuntimeService<T extends string>(
@@ -44,6 +49,9 @@ export function createFieldGroupingOverlayRuntimeService<T extends string>(
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
): boolean => {
if (options.sendToVisibleOverlay) {
return options.sendToVisibleOverlay(channel, payload, runtimeOptions);
}
return sendToVisibleOverlayRuntimeService({
mainWindow: options.getMainWindow() as never,
visibleOverlayVisible: options.getVisibleOverlayVisible(),

View File

@@ -68,6 +68,7 @@ export interface MpvIpcClientDeps {
getPreviousSecondarySubVisibility: () => boolean | null;
setPreviousSecondarySubVisibility: (value: boolean | null) => void;
showMpvOsd: (text: string) => void;
updateCurrentMediaTitle?: (mediaTitle: unknown) => void;
}
export class MpvIpcClient implements MpvClient {
@@ -285,6 +286,8 @@ export class MpvIpcClient implements MpvClient {
this.pauseAtTime = null;
this.send({ command: ["set_property", "pause", true] });
}
} else if (msg.name === "media-title") {
this.deps.updateCurrentMediaTitle?.(msg.data);
} else if (msg.name === "path") {
this.currentVideoPath = (msg.data as string) || "";
this.deps.updateCurrentMediaPath(msg.data);
@@ -653,6 +656,7 @@ export class MpvIpcClient implements MpvClient {
this.send({ command: ["observe_property", 22, "sub-shadow-offset"] });
this.send({ command: ["observe_property", 23, "sub-ass-override"] });
this.send({ command: ["observe_property", 24, "sub-use-margins"] });
this.send({ command: ["observe_property", 25, "media-title"] });
}
private getInitialState(): void {
@@ -668,6 +672,9 @@ export class MpvIpcClient implements MpvClient {
command: ["get_property", "path"],
request_id: MPV_REQUEST_ID_PATH,
});
this.send({
command: ["get_property", "media-title"],
});
this.send({
command: ["get_property", "secondary-sub-text"],
request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT,

View File

@@ -22,11 +22,26 @@ export function sendToVisibleOverlayRuntimeService<T extends string>(options: {
if (!wasVisible && options.restoreOnModalClose) {
options.restoreVisibleOverlayOnModalClose.add(options.restoreOnModalClose);
}
if (options.payload === undefined) {
options.mainWindow.webContents.send(options.channel);
} else {
options.mainWindow.webContents.send(options.channel, options.payload);
const sendNow = (): void => {
if (options.payload === undefined) {
options.mainWindow!.webContents.send(options.channel);
} else {
options.mainWindow!.webContents.send(options.channel, options.payload);
}
};
if (options.mainWindow.webContents.isLoading()) {
options.mainWindow.webContents.once("did-finish-load", () => {
if (
options.mainWindow &&
!options.mainWindow.isDestroyed() &&
!options.mainWindow.webContents.isLoading()
) {
sendNow();
}
});
return true;
}
sendNow();
return true;
}

View File

@@ -204,6 +204,41 @@ test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]);
});
test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut", () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
openJimaku: "Ctrl+J",
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+J";
},
{
openRuntimeOptions: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
toggleSecondarySub: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
},
);
assert.equal(result, true);
assert.deepEqual(matched, [{ accelerator: "Ctrl+J", allowWhenRegistered: true }]);
});
test("runOverlayShortcutLocalFallback returns false when no action matches", () => {
const shortcuts = makeShortcuts({
copySubtitle: "Ctrl+C",

View File

@@ -142,6 +142,7 @@ export function runOverlayShortcutLocalFallback(
run: () => {
handlers.openJimaku();
},
allowWhenRegistered: true,
},
{
accelerator: shortcuts.markAudioCard,

View File

@@ -66,7 +66,7 @@ export function shortcutMatchesInputForLocalFallback(
} else {
if (process.platform === "darwin") {
if (input.meta || input.control) return false;
} else if (input.control) {
} else if (!expectedControl && input.control) {
return false;
}
}

View File

@@ -3,6 +3,7 @@ import { BrowserWindow, globalShortcut } from "electron";
export interface GlobalShortcutConfig {
toggleVisibleOverlayGlobal: string | null | undefined;
toggleInvisibleOverlayGlobal: string | null | undefined;
openJimaku?: string | null | undefined;
}
export interface RegisterGlobalShortcutsServiceOptions {
@@ -10,6 +11,7 @@ export interface RegisterGlobalShortcutsServiceOptions {
onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void;
onOpenJimaku?: () => void;
isDev: boolean;
getMainWindow: () => BrowserWindow | null;
}
@@ -23,6 +25,10 @@ export function registerGlobalShortcutsService(
const normalizedInvisible = invisibleShortcut
?.replace(/\s+/g, "")
.toLowerCase();
const normalizedJimaku = options.shortcuts.openJimaku
?.replace(/\s+/g, "")
.toLowerCase();
const normalizedSettings = "alt+shift+y";
if (visibleShortcut) {
const toggleVisibleRegistered = globalShortcut.register(
@@ -64,6 +70,31 @@ export function registerGlobalShortcutsService(
);
}
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
if (
normalizedJimaku &&
(normalizedJimaku === normalizedVisible ||
normalizedJimaku === normalizedInvisible ||
normalizedJimaku === normalizedSettings)
) {
console.warn(
"Skipped registering openJimaku because it collides with another global shortcut",
);
} else {
const openJimakuRegistered = globalShortcut.register(
options.shortcuts.openJimaku,
() => {
options.onOpenJimaku?.();
},
);
if (!openJimakuRegistered) {
console.warn(
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
);
}
}
}
const settingsRegistered = globalShortcut.register("Alt+Shift+Y", () => {
options.onOpenYomitanSettings();
});

View File

@@ -290,11 +290,69 @@ test("runSubsyncManualService constructs alass command and returns failure on no
deps,
);
assert.deepEqual(result, {
ok: false,
message: "alass synchronization failed",
});
assert.equal(result.ok, false);
assert.equal(typeof result.message, "string");
assert.equal(result.message.startsWith("alass synchronization failed"), true);
const alassArgs = fs.readFileSync(alassLogPath, "utf8").trim().split("\n");
assert.equal(alassArgs[0], sourcePath);
assert.equal(alassArgs[1], primaryPath);
});
test("runSubsyncManualService resolves string sid values from mpv stream properties", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-stream-sid-"));
const ffsubsyncPath = path.join(tmpDir, "ffsubsync.sh");
const ffmpegPath = path.join(tmpDir, "ffmpeg.sh");
const alassPath = path.join(tmpDir, "alass.sh");
const videoPath = path.join(tmpDir, "video.mkv");
const primaryPath = path.join(tmpDir, "primary.srt");
const syncOutputPath = path.join(tmpDir, "synced.srt");
fs.writeFileSync(videoPath, "video");
fs.writeFileSync(primaryPath, "subtitle");
writeExecutableScript(ffmpegPath, "#!/bin/sh\nexit 0\n");
writeExecutableScript(alassPath, "#!/bin/sh\nexit 0\n");
writeExecutableScript(
ffsubsyncPath,
`#!/bin/sh\nmkdir -p "${tmpDir}"\nprev=""; for arg in "$@"; do if [ "$prev" = "--reference-stream" ]; then :; fi; if [ "$prev" = "-o" ]; then echo "$arg" > "${syncOutputPath}"; fi; prev="$arg"; done`,
);
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === "path") return videoPath;
if (name === "sid") return "1";
if (name === "secondary-sid") return "2";
if (name === "track-list") {
return [
{
id: "1",
type: "sub",
selected: true,
external: true,
"external-filename": primaryPath,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
defaultMode: "manual",
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManualService(
{ engine: "ffsubsync", sourceTrackId: null },
deps,
);
assert.equal(result.ok, true);
assert.equal(result.message, "Subtitle synchronized with ffsubsync");
assert.equal(fs.readFileSync(syncOutputPath, "utf8"), "");
});

View File

@@ -18,12 +18,27 @@ import {
SubsyncContext,
SubsyncResolvedConfig,
} from "../../subsync/utils";
import { isRemoteMediaPath } from "../../jimaku/utils";
interface FileExtractionResult {
path: string;
temporary: boolean;
}
function summarizeCommandFailure(command: string, result: CommandResult): string {
const parts = [
`code=${result.code ?? "n/a"}`,
result.stderr ? `stderr: ${result.stderr}` : "",
result.stdout ? `stdout: ${result.stdout}` : "",
result.error ? `error: ${result.error}` : "",
]
.map((value) => value.trim())
.filter(Boolean);
if (parts.length === 0) return `command failed (${command})`;
return `command failed (${command}) ${parts.join(" | ")}`;
}
interface MpvClientLike {
connected: boolean;
currentAudioStreamIndex: number | null;
@@ -36,6 +51,32 @@ interface SubsyncCoreDeps {
getResolvedConfig: () => SubsyncResolvedConfig;
}
function parseTrackId(value: unknown): number | null {
if (typeof value === "number") {
return Number.isInteger(value) ? value : null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.length) return null;
const parsed = Number(trimmed);
return Number.isInteger(parsed) && String(parsed) === trimmed ? parsed : null;
}
return null;
}
function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
return tracks.map((track) => {
if (!track || typeof track !== "object") return track as MpvTrack;
const typed = track as MpvTrack & { id?: unknown };
const parsedId = parseTrackId(typed.id);
if (parsedId === null) {
const { id: _ignored, ...rest } = typed;
return rest as MpvTrack;
}
return { ...typed, id: parsedId };
});
}
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
@@ -69,12 +110,11 @@ async function gatherSubsyncContext(
}
const tracks = Array.isArray(trackListRaw)
? (trackListRaw as MpvTrack[])
? normalizeTrackIds(trackListRaw as MpvTrack[])
: [];
const subtitleTracks = tracks.filter((track) => track.type === "sub");
const sid = typeof sidRaw === "number" ? sidRaw : null;
const secondarySid =
typeof secondarySidRaw === "number" ? secondarySidRaw : null;
const sid = parseTrackId(sidRaw);
const secondarySid = parseTrackId(secondarySidRaw);
const primaryTrack = subtitleTracks.find((track) => track.id === sid);
if (!primaryTrack) {
@@ -147,7 +187,7 @@ async function extractSubtitleTrackToFile(
"-nostdin",
"-y",
"-loglevel",
"quiet",
"error",
"-an",
"-vn",
"-i",
@@ -160,7 +200,12 @@ async function extractSubtitleTrackToFile(
]);
if (!extraction.ok || !fileExists(outputPath)) {
throw new Error("Failed to extract internal subtitle track with ffmpeg");
throw new Error(
`Failed to extract internal subtitle track with ffmpeg: ${summarizeCommandFailure(
"ffmpeg",
extraction,
)}`,
);
}
return { path: outputPath, temporary: true };
@@ -264,9 +309,10 @@ async function subsyncToReference(
}
if (!result.ok || !fileExists(outputPath)) {
const details = summarizeCommandFailure(engine, result);
return {
ok: false,
message: `${engine} synchronization failed`,
message: `${engine} synchronization failed: ${details}`,
};
}
@@ -280,6 +326,14 @@ async function subsyncToReference(
}
}
function validateFfsubsyncReference(videoPath: string): void {
if (isRemoteMediaPath(videoPath)) {
throw new Error(
"FFsubsync cannot reliably sync stream URLs because it needs direct reference media access. Use Alass with a secondary subtitle source or play a local file.",
);
}
}
async function runSubsyncAutoInternal(
deps: SubsyncCoreDeps,
): Promise<SubsyncResult> {
@@ -325,6 +379,14 @@ async function runSubsyncAutoInternal(
message: "No secondary subtitle for alass and ffsubsync not configured",
};
}
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference(
"ffsubsync",
context.videoPath,
@@ -343,6 +405,11 @@ export async function runSubsyncManualService(
const resolved = deps.getResolvedConfig();
if (request.engine === "ffsubsync") {
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return { ok: false, message: `ffsubsync synchronization failed: ${(error as Error).message}` };
}
return subsyncToReference(
"ffsubsync",
context.videoPath,