mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Apply remaining working-tree updates
This commit is contained in:
@@ -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." } };
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -142,6 +142,7 @@ export function runOverlayShortcutLocalFallback(
|
||||
run: () => {
|
||||
handlers.openJimaku();
|
||||
},
|
||||
allowWhenRegistered: true,
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.markAudioCard,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"), "");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user