mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-03 06:12:07 -07:00
Fix Windows mpv shortcut launch and subtitle dedupe
This commit is contained in:
@@ -21,6 +21,8 @@ Look up words with Yomitan, export to Anki in one key, track your immersion —
|
|||||||
|
|
||||||
SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect.
|
SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect.
|
||||||
|
|
||||||
|
On Windows, the recommended playback entry point is the optional `SubMiner mpv` shortcut created during setup. It launches `mpv` with SubMiner's defaults directly, so you do not need an `mpv.conf` profile just to use the shortcut.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Dictionary Lookups
|
### Dictionary Lookups
|
||||||
|
|||||||
5
changes/269-windows-mpv-shortcut-idle-overlay.md
Normal file
5
changes/269-windows-mpv-shortcut-idle-overlay.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file.
|
||||||
|
- Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path.
|
||||||
@@ -171,8 +171,8 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re
|
|||||||
|
|
||||||
### Windows Usage Notes
|
### Windows Usage Notes
|
||||||
|
|
||||||
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts.
|
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts. On Windows, that shortcut is the recommended way to launch mpv playback with SubMiner defaults.
|
||||||
- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly; they do not require an `mpv.conf` profile named `subminer`.
|
- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly, including the Windows-safe subtitle search paths that skip the extra current-directory scan; they do not require an `mpv.conf` profile named `subminer`.
|
||||||
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
|
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
|
||||||
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.
|
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.
|
||||||
- Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
|
- Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ SubMiner.AppImage --help # Show all options
|
|||||||
|
|
||||||
### Windows mpv Shortcut
|
### Windows mpv Shortcut
|
||||||
|
|
||||||
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. It runs `SubMiner.exe --launch-mpv`, which starts `mpv.exe` with SubMiner's default launch args directly.
|
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
|
||||||
|
|
||||||
You can use it three ways:
|
You can use it three ways:
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
|
|||||||
|
|
||||||
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
||||||
|
|
||||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p <profile> ...`):
|
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p <profile> ...`):
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[subminer]
|
[subminer]
|
||||||
|
|||||||
@@ -29,13 +29,25 @@ function M.create(ctx)
|
|||||||
return options_helper.coerce_bool(raw_auto_start, false)
|
return options_helper.coerce_bool(raw_auto_start, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function rearm_managed_subtitle_defaults()
|
||||||
|
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
mp.set_property_native("sub-auto", "fuzzy")
|
||||||
|
mp.set_property_native("sid", "auto")
|
||||||
|
mp.set_property_native("secondary-sid", "auto")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local function on_file_loaded()
|
local function on_file_loaded()
|
||||||
aniskip.clear_aniskip_state()
|
aniskip.clear_aniskip_state()
|
||||||
process.disarm_auto_play_ready_gate()
|
process.disarm_auto_play_ready_gate()
|
||||||
|
local has_matching_socket = rearm_managed_subtitle_defaults()
|
||||||
|
|
||||||
local should_auto_start = resolve_auto_start_enabled()
|
local should_auto_start = resolve_auto_start_enabled()
|
||||||
if should_auto_start then
|
if should_auto_start then
|
||||||
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
if not has_matching_socket then
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"info",
|
"info",
|
||||||
"lifecycle",
|
"lifecycle",
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ local function run_plugin_scenario(config)
|
|||||||
value = value,
|
value = value,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
function mp.set_property(name, value)
|
||||||
|
recorded.property_sets[#recorded.property_sets + 1] = {
|
||||||
|
name = name,
|
||||||
|
value = value,
|
||||||
|
}
|
||||||
|
end
|
||||||
function mp.get_script_name()
|
function mp.get_script_name()
|
||||||
return "subminer"
|
return "subminer"
|
||||||
end
|
end
|
||||||
@@ -531,6 +537,38 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "no",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
|
||||||
|
"managed file-loaded should rearm sub-auto for idle mpv sessions"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
has_property_set(recorded.property_sets, "sid", "auto"),
|
||||||
|
"managed file-loaded should rearm primary subtitle selection for idle mpv sessions"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
has_property_set(recorded.property_sets, "secondary-sid", "auto"),
|
||||||
|
"managed file-loaded should rearm secondary subtitle selection for idle mpv sessions"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
@@ -1037,6 +1075,10 @@ do
|
|||||||
start_call == nil,
|
start_call == nil,
|
||||||
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
||||||
)
|
)
|
||||||
|
assert_true(
|
||||||
|
not has_property_set(recorded.property_sets, "sid", "auto"),
|
||||||
|
"subtitle rearm should not run when mpv input-ipc-server does not match configured socket_path"
|
||||||
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not has_property_set(recorded.property_sets, "pause", true),
|
not has_property_set(recorded.property_sets, "pause", true),
|
||||||
"pause-until-ready gate should not arm when socket_path does not match"
|
"pause-until-ready gate should not arm when socket_path does not match"
|
||||||
|
|||||||
@@ -184,6 +184,39 @@ test('dispatchMpvProtocolMessage sets secondary subtitle track based on track li
|
|||||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
|
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 () => {
|
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
||||||
const { deps, state } = createDeps();
|
const { deps, state } = createDeps();
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,97 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
restorePreviousSecondarySubVisibility: () => void;
|
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(
|
export function splitMpvMessagesFromBuffer(
|
||||||
buffer: string,
|
buffer: string,
|
||||||
onMessage?: MpvMessageParser,
|
onMessage?: MpvMessageParser,
|
||||||
@@ -283,15 +374,11 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
if (Array.isArray(tracks)) {
|
if (Array.isArray(tracks)) {
|
||||||
const config = deps.getResolvedConfig();
|
const config = deps.getResolvedConfig();
|
||||||
const languages = config.secondarySub?.secondarySubLanguages || [];
|
const languages = config.secondarySub?.secondarySubLanguages || [];
|
||||||
const subTracks = tracks.filter((track) => track.type === 'sub');
|
const secondaryTrackId = pickSecondarySubtitleTrackId(tracks, languages);
|
||||||
for (const language of languages) {
|
if (secondaryTrackId !== null) {
|
||||||
const match = subTracks.find((track) => track.lang === language);
|
|
||||||
if (match) {
|
|
||||||
deps.sendCommand({
|
deps.sendCommand({
|
||||||
command: ['set_property', 'secondary-sid', match.id],
|
command: ['set_property', 'secondary-sid', secondaryTrackId],
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
||||||
|
|||||||
@@ -92,6 +92,52 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
|
|||||||
assert.equal(inProgressState, false);
|
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 () => {
|
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
||||||
const osd: string[] = [];
|
const osd: string[] = [];
|
||||||
await triggerSubsyncFromConfig(
|
await triggerSubsyncFromConfig(
|
||||||
|
|||||||
@@ -76,6 +76,31 @@ 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 {
|
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
||||||
isSubsyncInProgress: () => boolean;
|
isSubsyncInProgress: () => boolean;
|
||||||
setSubsyncInProgress: (inProgress: boolean) => void;
|
setSubsyncInProgress: (inProgress: boolean) => void;
|
||||||
@@ -123,12 +148,13 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
|||||||
const filename = track['external-filename'];
|
const filename = track['external-filename'];
|
||||||
return typeof filename === 'string' && filename.length > 0;
|
return typeof filename === 'string' && filename.length > 0;
|
||||||
});
|
});
|
||||||
|
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
videoPath,
|
videoPath,
|
||||||
primaryTrack,
|
primaryTrack,
|
||||||
secondaryTrack,
|
secondaryTrack,
|
||||||
sourceTracks,
|
sourceTracks: uniqueSourceTracks,
|
||||||
audioStreamIndex: client.currentAudioStreamIndex,
|
audioStreamIndex: client.currentAudioStreamIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
sanitizeHelpEnv,
|
sanitizeHelpEnv,
|
||||||
@@ -70,6 +71,41 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
|
|||||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
||||||
'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(
|
||||||
|
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'],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats-daemon entry helper detects internal daemon commands', () => {
|
test('stats-daemon entry helper detects internal daemon commands', () => {
|
||||||
|
|||||||
@@ -121,7 +121,94 @@ export function shouldHandleStatsDaemonCommandAtEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
|
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[] = [];
|
||||||
|
const flagValueArgs = new Set([
|
||||||
|
'--alang',
|
||||||
|
'--input-ipc-server',
|
||||||
|
'--log-file',
|
||||||
|
'--profile',
|
||||||
|
'--script',
|
||||||
|
'--script-opts',
|
||||||
|
'--scripts',
|
||||||
|
'--slang',
|
||||||
|
'--sub-file-paths',
|
||||||
|
'--ytdl-format',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 (!token.includes('=') && flagValueArgs.has(token)) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
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 flagValueArgs = new Set([
|
||||||
|
'--alang',
|
||||||
|
'--input-ipc-server',
|
||||||
|
'--log-file',
|
||||||
|
'--profile',
|
||||||
|
'--script',
|
||||||
|
'--script-opts',
|
||||||
|
'--scripts',
|
||||||
|
'--slang',
|
||||||
|
'--sub-file-paths',
|
||||||
|
'--ytdl-format',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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('-')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
extraArgs.push(token);
|
||||||
|
if (!token.includes('=') && flagValueArgs.has(token)) {
|
||||||
|
const value = argv[i + 1];
|
||||||
|
if (value && value !== '--') {
|
||||||
|
extraArgs.push(value);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extraArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { app, dialog } from 'electron';
|
import { app, dialog } from 'electron';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
sanitizeStartupEnv,
|
sanitizeStartupEnv,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
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 { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||||
|
|
||||||
@@ -32,6 +35,19 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||||
configureEarlyAppPaths(app);
|
configureEarlyAppPaths(app);
|
||||||
@@ -68,6 +84,9 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
dialog.showErrorBox(title, content);
|
dialog.showErrorBox(title, content);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
normalizeLaunchMpvExtraArgs(process.argv),
|
||||||
|
process.execPath,
|
||||||
|
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||||
);
|
);
|
||||||
app.exit(result.ok ? 0 : 1);
|
app.exit(result.ok ? 0 : 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,21 +41,57 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
|
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
|
||||||
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
|
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',
|
'--player-operation-mode=pseudo-gui',
|
||||||
|
'--force-window=immediate',
|
||||||
|
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
||||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--slang=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-auto=fuzzy',
|
||||||
'--sub-file-paths=.;subs;subtitles',
|
'--sub-file-paths=subs;subtitles',
|
||||||
'--sid=auto',
|
'--sid=auto',
|
||||||
'--secondary-sid=auto',
|
'--secondary-sid=auto',
|
||||||
'--secondary-sub-visibility=no',
|
'--secondary-sub-visibility=no',
|
||||||
|
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||||
'C:\\a.mkv',
|
'C:\\a.mkv',
|
||||||
'C:\\b.mkv',
|
'C:\\b.mkv',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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('launchWindowsMpv reports missing mpv path', () => {
|
test('launchWindowsMpv reports missing mpv path', () => {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const result = launchWindowsMpv(
|
const result = launchWindowsMpv(
|
||||||
@@ -82,13 +118,16 @@ test('launchWindowsMpv spawns detached mpv with targets', () => {
|
|||||||
calls.push(args.join('|'));
|
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.ok, true);
|
||||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'C:\\mpv\\mpv.exe',
|
'C:\\mpv\\mpv.exe',
|
||||||
'--player-operation-mode=pseudo-gui|--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|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',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,17 +33,36 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] {
|
export function buildWindowsMpvLaunchArgs(
|
||||||
|
targets: string[],
|
||||||
|
extraArgs: string[] = [],
|
||||||
|
binaryPath?: string,
|
||||||
|
pluginEntrypointPath?: string,
|
||||||
|
): string[] {
|
||||||
|
const launchIdle = targets.length === 0;
|
||||||
|
const scriptOpts =
|
||||||
|
typeof binaryPath === 'string' && binaryPath.trim().length > 0
|
||||||
|
? `--script-opts=subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')},subminer-socket_path=\\\\.\\pipe\\subminer-socket`
|
||||||
|
: null;
|
||||||
|
const scriptEntrypoint =
|
||||||
|
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
|
||||||
|
? `--script=${pluginEntrypointPath.trim()}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'--player-operation-mode=pseudo-gui',
|
'--player-operation-mode=pseudo-gui',
|
||||||
|
'--force-window=immediate',
|
||||||
|
...(launchIdle ? ['--idle=yes'] : []),
|
||||||
|
...(scriptEntrypoint ? [scriptEntrypoint] : []),
|
||||||
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
||||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--slang=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-auto=fuzzy',
|
||||||
'--sub-file-paths=.;subs;subtitles',
|
'--sub-file-paths=subs;subtitles',
|
||||||
'--sid=auto',
|
'--sid=auto',
|
||||||
'--secondary-sid=auto',
|
'--secondary-sid=auto',
|
||||||
'--secondary-sub-visibility=no',
|
'--secondary-sub-visibility=no',
|
||||||
|
...(scriptOpts ? [scriptOpts] : []),
|
||||||
...extraArgs,
|
...extraArgs,
|
||||||
...targets,
|
...targets,
|
||||||
];
|
];
|
||||||
@@ -53,6 +72,8 @@ export function launchWindowsMpv(
|
|||||||
targets: string[],
|
targets: string[],
|
||||||
deps: WindowsMpvLaunchDeps,
|
deps: WindowsMpvLaunchDeps,
|
||||||
extraArgs: string[] = [],
|
extraArgs: string[] = [],
|
||||||
|
binaryPath?: string,
|
||||||
|
pluginEntrypointPath?: string,
|
||||||
): { ok: boolean; mpvPath: string } {
|
): { ok: boolean; mpvPath: string } {
|
||||||
const mpvPath = resolveWindowsMpvPath(deps);
|
const mpvPath = resolveWindowsMpvPath(deps);
|
||||||
if (!mpvPath) {
|
if (!mpvPath) {
|
||||||
@@ -64,7 +85,10 @@ export function launchWindowsMpv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
|
deps.spawnDetached(
|
||||||
|
mpvPath,
|
||||||
|
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath),
|
||||||
|
);
|
||||||
return { ok: true, mpvPath };
|
return { ok: true, mpvPath };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
Reference in New Issue
Block a user