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

@@ -152,7 +152,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
toggleSecondarySub: "CommandOrControl+Shift+V",
markAudioCard: "CommandOrControl+Shift+A",
openRuntimeOptions: "CommandOrControl+Shift+O",
openJimaku: "Ctrl+Alt+J",
openJimaku: "Ctrl+Shift+J",
},
secondarySub: {
secondarySubLanguages: [],

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,

View File

@@ -223,7 +223,8 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
};
}
const filename = path.basename(mediaPath);
const normalizedMediaPath = normalizeMediaPathForJimaku(mediaPath);
const filename = path.basename(normalizedMediaPath);
let name = filename.replace(/\.[^/.]+$/, "");
name = name.replace(/\[[^\]]*]/g, " ");
name = name.replace(/\(\d{4}\)/g, " ");
@@ -237,7 +238,7 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
titlePart = name.slice(0, parsed.index);
}
const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath);
const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath);
const title = cleanupTitle(titlePart || name);
return {
@@ -250,6 +251,37 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
};
}
function normalizeMediaPathForJimaku(mediaPath: string): string {
const trimmed = mediaPath.trim();
if (!trimmed || !/^https?:\/\/.*/.test(trimmed)) {
return trimmed;
}
try {
const parsedUrl = new URL(trimmed);
const titleParam =
parsedUrl.searchParams.get("title") ||
parsedUrl.searchParams.get("name") ||
parsedUrl.searchParams.get("q");
if (titleParam && titleParam.trim()) return titleParam.trim();
const pathParts = parsedUrl.pathname.split("/").filter(Boolean).reverse();
const candidate = pathParts.find((part) => {
const decoded = decodeURIComponent(part || "").replace(/\.[^/.]+$/, "");
const lowered = decoded.toLowerCase();
return (
lowered.length > 2 &&
!/^[0-9.]+$/.test(lowered) &&
!/^[a-f0-9]{16,}$/i.test(lowered)
);
});
return decodeURIComponent(candidate || parsedUrl.hostname.replace(/^www\./, ""));
} catch {
return trimmed;
}
}
function formatLangScore(name: string, pref: JimakuLanguagePreference): number {
if (pref === "none") return 0;
const upper = name.toUpperCase();

View File

@@ -265,7 +265,7 @@ const electronAPI: ElectronAPI = {
callback();
});
},
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => {
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync" | "jimaku") => {
ipcRenderer.send("overlay:modal-closed", modal);
},
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {

View File

@@ -11,6 +11,7 @@ export function createJimakuModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setJimakuStatus(message: string, isError = false): void {
@@ -252,6 +253,7 @@ export function createJimakuModal(
if (ctx.state.jimakuModalOpen) return;
ctx.state.jimakuModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add("interactive");
ctx.dom.jimakuModal.classList.remove("hidden");
ctx.dom.jimakuModal.setAttribute("aria-hidden", "false");
@@ -284,8 +286,10 @@ export function createJimakuModal(
if (!ctx.state.jimakuModalOpen) return;
ctx.state.jimakuModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.jimakuModal.classList.add("hidden");
ctx.dom.jimakuModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("jimaku");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove("interactive");

View File

@@ -48,7 +48,8 @@ function isAnySettingsModalOpen(): boolean {
return (
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen
ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen
);
}
@@ -89,6 +90,7 @@ const kikuModal = createKikuModal(ctx, {
});
const jimakuModal = createJimakuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,

View File

@@ -59,6 +59,10 @@ body {
z-index: 1000;
}
#jimakuModal {
z-index: 1100;
}
.modal.hidden {
display: none;
}

View File

@@ -1,34 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { getSubsyncConfig, runCommand } from "./utils";
import { codecToExtension } from "./utils";
test("getSubsyncConfig applies fallback executable paths for blank values", () => {
const config = getSubsyncConfig({
defaultMode: "manual",
alass_path: " ",
ffsubsync_path: "",
ffmpeg_path: undefined,
});
assert.equal(config.defaultMode, "manual");
assert.equal(config.alassPath, "/usr/bin/alass");
assert.equal(config.ffsubsyncPath, "/usr/bin/ffsubsync");
assert.equal(config.ffmpegPath, "/usr/bin/ffmpeg");
test("codecToExtension maps stream/web formats to ffmpeg extractable extensions", () => {
assert.equal(codecToExtension("subrip"), "srt");
assert.equal(codecToExtension("webvtt"), "vtt");
assert.equal(codecToExtension("vtt"), "vtt");
assert.equal(codecToExtension("ttml"), "ttml");
});
test("runCommand returns failure on timeout", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-utils-"));
const sleeperPath = path.join(tmpDir, "sleeper.sh");
fs.writeFileSync(sleeperPath, "#!/bin/sh\nsleep 2\n", {
encoding: "utf8",
mode: 0o755,
});
fs.chmodSync(sleeperPath, 0o755);
const result = await runCommand(sleeperPath, [], 50);
assert.equal(result.ok, false);
test("codecToExtension returns null for unsupported codecs", () => {
assert.equal(codecToExtension("unsupported-codec"), null);
});

View File

@@ -101,8 +101,16 @@ export function getTrackById(
export function codecToExtension(codec: string | undefined): string | null {
if (!codec) return null;
const normalized = codec.toLowerCase();
if (normalized === "subrip" || normalized === "srt") return "srt";
if (
normalized === "subrip" ||
normalized === "srt" ||
normalized === "text" ||
normalized === "mov_text"
)
return "srt";
if (normalized === "ass" || normalized === "ssa") return "ass";
if (normalized === "webvtt" || normalized === "vtt") return "vtt";
if (normalized === "ttml") return "ttml";
return null;
}

View File

@@ -629,7 +629,7 @@ export interface ElectronAPI {
) => void;
onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void;
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => void;
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync" | "jimaku") => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
}