mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
Compare commits
7 Commits
v0.17.1
...
73af1451b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
73af1451b7
|
|||
| 36a3704815 | |||
| 359cb0a301 | |||
|
4b10e85053
|
|||
| c942a2cf2d | |||
| f65afa6046 | |||
|
389d8e06e0
|
@@ -1,5 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/package-lock.json
|
||||
|
||||
# Superpowers brainstorming
|
||||
.superpowers/
|
||||
@@ -16,6 +17,8 @@ coverage/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
/subminer
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
### Fixed
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
|
||||
@@ -559,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
|
||||
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
**Fixed**
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 0,
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -1001,6 +1002,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
@@ -1538,7 +1540,7 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
||||
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
|
||||
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
|
||||
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
|
||||
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
|
||||
@@ -74,23 +74,27 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
|
||||
- Select different primary and secondary tracks
|
||||
- Retry track loading if the auto-load failed or picked the wrong track
|
||||
|
||||
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
|
||||
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
|
||||
card to a success notification after the selected tracks load.
|
||||
|
||||
The picker displays each track with its language, kind (manual/auto), and title when available.
|
||||
|
||||
## Subtitle Format Handling
|
||||
|
||||
SubMiner handles several YouTube subtitle formats transparently:
|
||||
|
||||
| Format | Handling |
|
||||
| ------ | -------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| Format | Handling |
|
||||
| ---------------------- | -------------------------------------------------------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
|
||||
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
|
||||
|
||||
## Card Media Cache
|
||||
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
|
||||
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
|
||||
|
||||
@@ -101,13 +105,13 @@ Background cache downloads use IPv4 and retry flags to reduce YouTube throttling
|
||||
```jsonc
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
}
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Option | Type | Description |
|
||||
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
|
||||
|
||||
### Secondary Subtitle Languages
|
||||
@@ -119,16 +123,16 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover"
|
||||
}
|
||||
"defaultMode": "hover",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
| Option | Type | Description |
|
||||
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
|
||||
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const electron_1 = require("electron");
|
||||
const help_1 = require("./cli/help");
|
||||
const main_entry_runtime_1 = require("./main-entry-runtime");
|
||||
const early_single_instance_1 = require("./main/early-single-instance");
|
||||
const main_entry_launch_config_1 = require("./main-entry-launch-config");
|
||||
const app_control_client_1 = require("./shared/app-control-client");
|
||||
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
|
||||
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
|
||||
const stats_daemon_entry_1 = require("./stats-daemon-entry");
|
||||
const fatal_error_1 = require("./main/fatal-error");
|
||||
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
|
||||
const log_files_1 = require("./shared/log-files");
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
function appendWindowsMpvLaunchLog(message, logRotation) {
|
||||
if (!(0, log_files_1.isLogFileEnabled)('app')) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
|
||||
}
|
||||
function applySanitizedEnv(sanitizedEnv) {
|
||||
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
}
|
||||
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
else {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
function resolveBundledWindowsMpvPluginEntrypoint() {
|
||||
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
|
||||
dirname: __dirname,
|
||||
appPath: electron_1.app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
}) ?? undefined);
|
||||
}
|
||||
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
|
||||
return [
|
||||
'SubMiner detected an installed mpv plugin at:',
|
||||
pathValue,
|
||||
'',
|
||||
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||
].join('\n');
|
||||
}
|
||||
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
|
||||
const response = await electron_1.dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
|
||||
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
if (response.response === 2) {
|
||||
return 'cancel';
|
||||
}
|
||||
if (response.response === 1) {
|
||||
return 'continue';
|
||||
}
|
||||
const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
});
|
||||
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
|
||||
candidates,
|
||||
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
|
||||
});
|
||||
if (result.ok) {
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Legacy mpv plugin removed',
|
||||
message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||
});
|
||||
return 'removed';
|
||||
}
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Could not remove legacy mpv plugin',
|
||||
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||
});
|
||||
return 'cancel';
|
||||
}
|
||||
function createWindowsRuntimePluginPolicy() {
|
||||
return {
|
||||
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
}),
|
||||
notifyInstalledPluginDetected: (detection) => {
|
||||
if (!detection.installed || !detection.path)
|
||||
return;
|
||||
electron_1.dialog.showMessageBoxSync({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
|
||||
});
|
||||
},
|
||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
|
||||
};
|
||||
}
|
||||
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
|
||||
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
|
||||
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
|
||||
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
|
||||
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
|
||||
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
|
||||
consoleError: (message, error) => console.error(message, error),
|
||||
});
|
||||
(0, fatal_error_1.registerFatalErrorHandlers)({
|
||||
reportFatalError,
|
||||
exit: (code) => electron_1.app.exit(code),
|
||||
});
|
||||
function startMainProcess() {
|
||||
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
electron_1.app.exit(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
}
|
||||
catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
});
|
||||
electron_1.app.exit(1);
|
||||
}
|
||||
}
|
||||
async function forwardStartupArgvViaAppControlIfAvailable() {
|
||||
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
|
||||
return false;
|
||||
}
|
||||
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
if (result.ok) {
|
||||
electron_1.app.exit(0);
|
||||
return true;
|
||||
}
|
||||
if (!result.unavailable) {
|
||||
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||
electron_1.app.exit(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function runEntryProcess() {
|
||||
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
await electron_1.app.whenReady();
|
||||
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
|
||||
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
|
||||
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
|
||||
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
|
||||
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
|
||||
: '';
|
||||
if (mpvLogPath) {
|
||||
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
|
||||
}
|
||||
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
|
||||
getEnv: (name) => process.env[name],
|
||||
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 350,
|
||||
}),
|
||||
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
showError: (title, content) => {
|
||||
electron_1.dialog.showErrorBox(title, content);
|
||||
},
|
||||
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
|
||||
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
|
||||
electron_1.app.exit(result.ok ? 0 : 1);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
|
||||
await electron_1.app.whenReady();
|
||||
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
|
||||
electron_1.app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
|
||||
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
|
||||
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
startMainProcess();
|
||||
}
|
||||
void runEntryProcess().catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
//# sourceMappingURL=main-entry.js.map
|
||||
Generated
-4095
File diff suppressed because it is too large
Load Diff
+3
-3
File diff suppressed because one or more lines are too long
@@ -1,35 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **YouTube Media Cache Mode:** New `youtube.mediaCache.mode` setting lets you choose between `direct` (the existing behavior) and `background` modes for extracting audio and images when creating cards from YouTube videos.
|
||||
- Background mode uses a separate yt-dlp download to cache the video file locally, which can rescue media extraction when direct stream URLs are unreliable or expire quickly.
|
||||
- While the cache downloads, SubMiner creates a text-only card immediately, then fills in audio and image once the file is ready — no need to re-mine the sentence.
|
||||
- Cache downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`) to keep file sizes reasonable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Log Export:** Log filenames now use your local date, so exporting logs around UTC midnight correctly captures today's file instead of yesterday's.
|
||||
- Log exports also now redact a broader set of sensitive data — IP addresses, email addresses, auth and cookie headers, yt-dlp cookie arguments, URL credentials, API keys, and signed YouTube media URLs.
|
||||
|
||||
- **YouTube Card Media Generation:** Extraction is more reliable and resilient across a wider range of stream conditions.
|
||||
- Stale or cached stream maps are skipped automatically, and ffmpeg requests use safer options for resolved streams to reduce failures.
|
||||
- Background cache downloads retry on network issues (IPv4 and extractor fallbacks), notify you on failure instead of silently hanging, and clean up stale cache files on startup and before each new download.
|
||||
- Switching back to direct mode now cancels any in-progress background downloads cleanly.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(youtube): add mediaCache mode and safer stream media extraction by @ksyasuda in #130
|
||||
- fix(logs): use local date for log filenames and expand export redaction by @ksyasuda in #131
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -108,15 +108,11 @@ function renderPrereleaseBaseVersionMarker(version: string): string {
|
||||
}
|
||||
|
||||
function extractPrereleaseBaseVersionMarker(notes: string): string | null {
|
||||
return (
|
||||
/<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null
|
||||
);
|
||||
return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function stripPrereleaseMetadata(notes: string): string {
|
||||
return notes
|
||||
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
|
||||
.trim();
|
||||
return notes.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim();
|
||||
}
|
||||
|
||||
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
|
||||
|
||||
@@ -768,13 +768,110 @@ test('AnkiIntegration reports partial queued YouTube media updates separately fr
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
|
||||
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
||||
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
} as never,
|
||||
() => undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
async () => null,
|
||||
() => true,
|
||||
() => 'https://www.youtube.com/watch?v=abc123',
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
client: {
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||
storeMediaFile: (filename: string) => Promise<void>;
|
||||
};
|
||||
mediaGenerator: {
|
||||
generateAudio: () => Promise<Buffer>;
|
||||
generateScreenshot: () => Promise<Buffer>;
|
||||
};
|
||||
queuePendingYoutubeMediaUpdateForNote: (job: {
|
||||
noteId: number;
|
||||
noteInfo: { noteId: number; fields: Record<string, { value: string }> };
|
||||
label: string | number;
|
||||
}) => Promise<boolean>;
|
||||
showNotification: () => Promise<void>;
|
||||
};
|
||||
internals.client = {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updatedNotes.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => Buffer.from('image'),
|
||||
};
|
||||
internals.showNotification = async () => undefined;
|
||||
|
||||
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
|
||||
noteId: 404,
|
||||
noteInfo: {
|
||||
noteId: 404,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
label: 'resolved source',
|
||||
});
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(queued, true);
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.equal(updatedNotes[0]?.noteId, 404);
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
});
|
||||
|
||||
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
||||
const audioCalls: Array<{
|
||||
path: string;
|
||||
audioStreamIndex?: number;
|
||||
normalizeAudio?: boolean;
|
||||
}> = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
media: {
|
||||
audioPadding: 0,
|
||||
normalizeAudio: false,
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
@@ -804,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
) => Promise<Buffer>;
|
||||
};
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
||||
audioCalls.push({ path: path.path, audioStreamIndex });
|
||||
generateAudio: async (
|
||||
path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
) => {
|
||||
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
};
|
||||
@@ -821,6 +926,7 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
normalizeAudio: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
+18
-1
@@ -239,6 +239,9 @@ export class AnkiIntegration {
|
||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||
null;
|
||||
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
|
||||
private getYoutubeMediaSourceUrl:
|
||||
| (() => Promise<string | null | undefined> | string | null | undefined)
|
||||
| null = null;
|
||||
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
|
||||
|
||||
constructor(
|
||||
@@ -257,6 +260,7 @@ export class AnkiIntegration {
|
||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
||||
shouldRequireRemoteMediaCache?: () => boolean,
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -271,6 +275,7 @@ export class AnkiIntegration {
|
||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
||||
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
|
||||
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
|
||||
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
this.pollingRunner = this.createPollingRunner();
|
||||
@@ -343,6 +348,7 @@ export class AnkiIntegration {
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -356,7 +362,7 @@ export class AnkiIntegration {
|
||||
),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
getCurrentVideoPath: () => this.mpvClient.currentVideoPath,
|
||||
getCurrentVideoPath: () => this.getCurrentYoutubeMediaSourceUrl(),
|
||||
getCachedMediaPath: this.getCachedMediaPath,
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
|
||||
@@ -474,6 +480,7 @@ export class AnkiIntegration {
|
||||
getMpvClient: () => this.mpvClient,
|
||||
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getYoutubeMediaSourceUrl: () => this.getCurrentYoutubeMediaSourceUrl(),
|
||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
|
||||
getDeck: () => this.config.deck,
|
||||
client: {
|
||||
@@ -496,6 +503,7 @@ export class AnkiIntegration {
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -945,6 +953,14 @@ export class AnkiIntegration {
|
||||
return this.pendingYoutubeMediaQueue.queueFromNote(job);
|
||||
}
|
||||
|
||||
private async getCurrentYoutubeMediaSourceUrl(): Promise<string> {
|
||||
return (
|
||||
trimToNonEmptyString(await this.getYoutubeMediaSourceUrl?.()) ??
|
||||
trimToNonEmptyString(this.mpvClient.currentVideoPath) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
async handleYoutubeMediaCacheReady(
|
||||
sourceUrl: string,
|
||||
cachedPath: string,
|
||||
@@ -982,6 +998,7 @@ export class AnkiIntegration {
|
||||
endTime,
|
||||
this.config.media?.audioPadding,
|
||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
@@ -269,9 +270,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
|
||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
@@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
});
|
||||
|
||||
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
||||
|
||||
@@ -28,6 +28,14 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateImage !== false;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export interface CardCreationNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
@@ -57,6 +65,7 @@ interface CardCreationMediaGenerator {
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: MediaInput,
|
||||
@@ -90,6 +99,7 @@ interface CardCreationDeps {
|
||||
getMpvClient: () => MpvClient;
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
client: CardCreationClient;
|
||||
@@ -705,7 +715,9 @@ export class CardCreationService {
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
if (shouldQueuePendingYoutubeMedia) {
|
||||
this.deps.queuePendingYoutubeMediaUpdate?.({
|
||||
sourceUrl: mpvClient.currentVideoPath,
|
||||
sourceUrl:
|
||||
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
|
||||
mpvClient.currentVideoPath,
|
||||
noteId,
|
||||
startTime,
|
||||
endTime,
|
||||
@@ -831,6 +843,7 @@ export class CardCreationService {
|
||||
videoPath,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
),
|
||||
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import * as mediaSource from './media-source';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
const { resolveMediaGenerationInputPath } = mediaSource;
|
||||
|
||||
@@ -53,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
||||
assert.equal(audioResult, audioUrl);
|
||||
assert.equal(videoResult, videoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const signedVideoUrl =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const edlSource = [
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const result = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(result, signedVideoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const audioUrl = 'https://audio.example/stream';
|
||||
const videoUrl = 'https://video.example/stream';
|
||||
const edlSource = [
|
||||
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
|
||||
'!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
toMpvEdlValue(videoUrl),
|
||||
].join(';');
|
||||
|
||||
const audioResult = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, audioUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
||||
@@ -97,9 +144,11 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
].join(';');
|
||||
|
||||
const result = await resolver!(
|
||||
@@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(result?.path, audioUrl);
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.reconnect, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||
import type { MpvClient } from '../types/runtime';
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
|
||||
export type MediaGenerationKind = 'audio' | 'video';
|
||||
export type MediaGenerationInputSource =
|
||||
@@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null {
|
||||
}
|
||||
|
||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||
return [...matches]
|
||||
.map((match) => trimToNonEmptyString(match[1]))
|
||||
return extractFileUrlsFromMpvEdlSource(source)
|
||||
.map((value) => trimToNonEmptyString(value))
|
||||
.filter((value): value is string => value !== null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function toMpvEdlValue(value: string): string {
|
||||
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
|
||||
const url =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
|
||||
fileUrl,
|
||||
)},length=42`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = [
|
||||
'edl://!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
`file=${toMpvEdlValue(videoUrl)},length=50`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
const EDL_URI_PREFIX = 'edl://';
|
||||
|
||||
const BYTE_COMMA = ','.charCodeAt(0);
|
||||
const BYTE_CR = '\r'.charCodeAt(0);
|
||||
const BYTE_EQUALS = '='.charCodeAt(0);
|
||||
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
|
||||
const BYTE_LF = '\n'.charCodeAt(0);
|
||||
const BYTE_PERCENT = '%'.charCodeAt(0);
|
||||
const BYTE_SEMICOLON = ';'.charCodeAt(0);
|
||||
|
||||
function isDigitByte(value: number | undefined): value is number {
|
||||
return value !== undefined && value >= 48 && value <= 57;
|
||||
}
|
||||
|
||||
function isEntrySeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
|
||||
}
|
||||
|
||||
function isParamSeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_COMMA || isEntrySeparator(value);
|
||||
}
|
||||
|
||||
function decodeBytes(buffer: Buffer, start: number, end: number): string {
|
||||
return buffer.subarray(start, end).toString('utf8');
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function toEdlDataBuffer(source: string): Buffer {
|
||||
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
|
||||
return Buffer.from(data, 'utf8');
|
||||
}
|
||||
|
||||
function parseLengthGuardedValue(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { value: string; end: number } | null {
|
||||
if (buffer[position] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = position + 1;
|
||||
if (!isDigitByte(buffer[cursor])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let byteLength = 0;
|
||||
while (true) {
|
||||
const digit = buffer[cursor];
|
||||
if (!isDigitByte(digit)) {
|
||||
break;
|
||||
}
|
||||
byteLength = byteLength * 10 + (digit - 48);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (buffer[cursor] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueStart = cursor + 1;
|
||||
const valueEnd = valueStart + byteLength;
|
||||
if (valueEnd > buffer.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: decodeBytes(buffer, valueStart, valueEnd),
|
||||
end: valueEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function skipEntrySeparators(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function skipEntry(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length) {
|
||||
const guardedValue = parseLengthGuardedValue(buffer, cursor);
|
||||
if (guardedValue) {
|
||||
cursor = guardedValue.end;
|
||||
continue;
|
||||
}
|
||||
if (isEntrySeparator(buffer[cursor])) {
|
||||
break;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
cursor += 1;
|
||||
}
|
||||
return {
|
||||
value: decodeBytes(buffer, position, cursor),
|
||||
end: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
|
||||
}
|
||||
|
||||
function parseOptionalParamName(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { name: string | null; valueStart: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_PERCENT &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
if (buffer[cursor] === BYTE_EQUALS) {
|
||||
return {
|
||||
name: decodeBytes(buffer, position, cursor),
|
||||
valueStart: cursor + 1,
|
||||
};
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return { name: null, valueStart: position };
|
||||
}
|
||||
|
||||
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
|
||||
const urls: string[] = [];
|
||||
let cursor = position;
|
||||
let unnamedParamIndex = 0;
|
||||
|
||||
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
|
||||
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
|
||||
const value = parseParamValue(buffer, valueStart);
|
||||
const lowerName = name?.toLowerCase() ?? null;
|
||||
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
|
||||
|
||||
if (isFileParam && isHttpUrl(value.value)) {
|
||||
urls.push(value.value);
|
||||
}
|
||||
|
||||
if (lowerName === null) {
|
||||
unnamedParamIndex += 1;
|
||||
}
|
||||
|
||||
cursor = value.end;
|
||||
if (buffer[cursor] === BYTE_COMMA) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isEntrySeparator(buffer[cursor])) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
return { urls, end: cursor };
|
||||
}
|
||||
|
||||
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const buffer = toEdlDataBuffer(source);
|
||||
const urls: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
cursor = skipEntrySeparators(buffer, cursor);
|
||||
if (cursor >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (buffer[cursor] === BYTE_EXCLAMATION) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
continue;
|
||||
}
|
||||
|
||||
const segment = parseSegmentEntry(buffer, cursor);
|
||||
urls.push(...segment.urls);
|
||||
cursor = segment.end;
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
@@ -2,7 +2,10 @@ export interface NoteFieldValueInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null {
|
||||
export function getNoteFieldValue(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
preferredName: string,
|
||||
): string | null {
|
||||
const resolvedFieldName = Object.keys(noteInfo.fields).find(
|
||||
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface PendingYoutubeMediaQueueDeps {
|
||||
'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage'
|
||||
>;
|
||||
getConfig: () => AnkiConnectConfig;
|
||||
getCurrentVideoPath: () => string | undefined;
|
||||
getCurrentVideoPath: () => Promise<string | undefined> | string | undefined;
|
||||
getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null;
|
||||
shouldRequireRemoteMediaCache: () => boolean;
|
||||
getSubtitleMediaRange: (context?: SubtitleMiningContext) => {
|
||||
@@ -104,7 +104,7 @@ export class PendingYoutubeMediaQueue {
|
||||
context?: SubtitleMiningContext;
|
||||
label: string | number;
|
||||
}): Promise<boolean> {
|
||||
const sourceUrl = trimToNonEmptyString(this.deps.getCurrentVideoPath());
|
||||
const sourceUrl = trimToNonEmptyString(await this.deps.getCurrentVideoPath());
|
||||
const getCachedMediaPath = this.deps.getCachedMediaPath;
|
||||
if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) {
|
||||
return false;
|
||||
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
|
||||
job.endTime,
|
||||
config.media?.audioPadding,
|
||||
undefined,
|
||||
config.media?.normalizeAudio !== false,
|
||||
);
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
|
||||
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
|
||||
model: '',
|
||||
systemPrompt: '',
|
||||
});
|
||||
assert.equal(config.ankiConnect.media.normalizeAudio, true);
|
||||
assert.equal(config.startupWarmups.lowPowerMode, false);
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
|
||||
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
animatedMaxHeight: 0,
|
||||
animatedCrf: 35,
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
normalizeAudio: true,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
|
||||
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
|
||||
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||
description: 'Generate sentence audio for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.normalizeAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
|
||||
description: 'Normalize generated sentence audio loudness during media extraction.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
@@ -114,6 +115,7 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
options.showOverlayNotification,
|
||||
options.getCachedMediaPath,
|
||||
options.shouldRequireRemoteMediaCache,
|
||||
options.getYoutubeMediaSourceUrl,
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -30,6 +30,7 @@ type CreateAnkiIntegrationArgs = {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
};
|
||||
|
||||
export type OverlayWindowTrackerOptions = {
|
||||
@@ -72,6 +73,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
args.showOverlayNotification,
|
||||
args.getCachedMediaPath,
|
||||
args.shouldRequireRemoteMediaCache,
|
||||
args.getYoutubeMediaSourceUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,6 +146,7 @@ export function initializeOverlayRuntime(
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
backendOverride: string | null;
|
||||
@@ -183,6 +186,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): boolean {
|
||||
@@ -221,6 +225,9 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
...(options.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(options.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: options.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
});
|
||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||
integration.start();
|
||||
|
||||
@@ -1206,6 +1206,7 @@ export function createStatsApp(
|
||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
|
||||
const startSec = startMs / 1000;
|
||||
@@ -1228,7 +1229,14 @@ export function createStatsApp(
|
||||
|
||||
const audioPromise = generateAudio
|
||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
||||
mediaGen.generateAudio(
|
||||
sourcePath,
|
||||
startSec,
|
||||
clampedEndSec,
|
||||
audioPadding,
|
||||
null,
|
||||
normalizeAudio,
|
||||
),
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
|
||||
|
||||
+50
-7
@@ -348,6 +348,7 @@ import {
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
} from './main/runtime/first-run-setup-service';
|
||||
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||
import { createYoutubeMediaCachePlaybackRuntime } from './main/runtime/youtube-media-cache-playback';
|
||||
import { createYoutubePlaybackRuntime } from './main/runtime/youtube-playback-runtime';
|
||||
import {
|
||||
clearYoutubePrimarySubtitleNotificationTimer,
|
||||
@@ -533,7 +534,6 @@ import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runti
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
@@ -1220,6 +1220,18 @@ const youtubeMediaCache = createYoutubeMediaCacheService({
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
const youtubeMediaCachePlaybackRuntime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => getResolvedConfig().youtube.mediaCache,
|
||||
requestMpvProperty: async (name) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return null;
|
||||
return await client.requestProperty(name);
|
||||
},
|
||||
startYoutubeMediaCache: (url, options) => {
|
||||
youtubeMediaCache.start(url, options);
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
now: () => Date.now(),
|
||||
@@ -1683,10 +1695,35 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
|
||||
});
|
||||
|
||||
function isYoutubePlaybackActiveNow(): boolean {
|
||||
return isYoutubePlaybackActive(
|
||||
appState.currentMediaPath,
|
||||
appState.mpvClient?.currentVideoPath ?? null,
|
||||
);
|
||||
return Boolean(getCurrentYoutubeMediaCacheSourceUrlSnapshot());
|
||||
}
|
||||
|
||||
function getCurrentYoutubeMediaCacheSourceUrlSnapshot(): string | null {
|
||||
const currentMediaPath = appState.currentMediaPath?.trim() || null;
|
||||
if (isYoutubeMediaPath(currentMediaPath)) {
|
||||
return currentMediaPath;
|
||||
}
|
||||
|
||||
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
if (isYoutubeMediaPath(currentVideoPath)) {
|
||||
return currentVideoPath;
|
||||
}
|
||||
|
||||
return youtubeMediaCachePlaybackRuntime.getActiveYoutubeSourceUrlSnapshot();
|
||||
}
|
||||
|
||||
async function getCurrentYoutubeMediaCacheSourceUrl(): Promise<string | null> {
|
||||
const currentMediaPath = appState.currentMediaPath?.trim() || null;
|
||||
if (isYoutubeMediaPath(currentMediaPath)) {
|
||||
return currentMediaPath;
|
||||
}
|
||||
|
||||
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
if (isYoutubeMediaPath(currentVideoPath)) {
|
||||
return currentVideoPath;
|
||||
}
|
||||
|
||||
return await youtubeMediaCachePlaybackRuntime.getActiveYoutubeSourceUrl();
|
||||
}
|
||||
|
||||
function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean {
|
||||
@@ -1702,13 +1739,16 @@ async function getCachedYoutubeMediaPathForCurrentPlayback(
|
||||
if (getResolvedConfig().youtube.mediaCache.mode !== 'background') {
|
||||
return null;
|
||||
}
|
||||
if (!isYoutubePlaybackActiveNow()) {
|
||||
const cacheSourceUrl = isYoutubeMediaPath(currentVideoPath)
|
||||
? currentVideoPath
|
||||
: await getCurrentYoutubeMediaCacheSourceUrl();
|
||||
if (!cacheSourceUrl) {
|
||||
return null;
|
||||
}
|
||||
// mpv can expose the resolved stream URL here while the cache key uses the original page URL.
|
||||
// Keep the active-cache fallback so current playback can still resolve the ready cached file.
|
||||
return (
|
||||
(await youtubeMediaCache.getCachedMediaPath(currentVideoPath)) ??
|
||||
(await youtubeMediaCache.getCachedMediaPath(cacheSourceUrl)) ??
|
||||
(await youtubeMediaCache.getActiveCachedMediaPath())
|
||||
);
|
||||
}
|
||||
@@ -4442,6 +4482,7 @@ const {
|
||||
subtitlePrefetchRuntime.cancelPendingInit();
|
||||
}
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
|
||||
@@ -5815,6 +5856,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
|
||||
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
|
||||
showDesktopNotification,
|
||||
showOverlayNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
@@ -6294,6 +6336,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
|
||||
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
|
||||
shouldStartAnkiIntegration: () =>
|
||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
},
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
||||
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache'];
|
||||
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
|
||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||
@@ -323,6 +324,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
...(params.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(params.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: params.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
showDesktopNotification: params.showDesktopNotification,
|
||||
showOverlayNotification: params.showOverlayNotification,
|
||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||
|
||||
@@ -50,6 +50,16 @@ test('media path changes clear rendered subtitle state without clearing same-you
|
||||
);
|
||||
});
|
||||
|
||||
test('media path changes start the YouTube media cache coordinator', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /youtubeMediaCachePlaybackRuntime\.handleMediaPathChange\(path\);/);
|
||||
});
|
||||
|
||||
test('same media path updates do not reset autoplay ready fallback state', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
getYoutubeFlowStatusNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
} from './configured-status-notification';
|
||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||
@@ -226,6 +227,36 @@ test('playback feedback options reuse subtitle mode notification ids', () => {
|
||||
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {});
|
||||
});
|
||||
|
||||
test('youtube flow status options route picker opening as one-shot configured status', () => {
|
||||
assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Opening YouTube subtitle picker...'), {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: 'info',
|
||||
persistent: false,
|
||||
desktop: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('youtube flow status options route loaded messages as transient success', () => {
|
||||
assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Subtitles loaded.'), {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: 'success',
|
||||
persistent: false,
|
||||
desktop: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
getYoutubeFlowStatusNotificationOptions('Primary and secondary subtitles loaded.'),
|
||||
{
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: 'success',
|
||||
persistent: false,
|
||||
desktop: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -31,6 +31,25 @@ export function getPlaybackFeedbackNotificationOptions(
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getYoutubeFlowStatusNotificationOptions(
|
||||
message: string,
|
||||
): ConfiguredStatusNotificationOptions {
|
||||
const success =
|
||||
message === 'Subtitles loaded.' || message === 'Primary and secondary subtitles loaded.';
|
||||
const progress =
|
||||
message.startsWith('Downloading subtitles') ||
|
||||
message.startsWith('Loading subtitles') ||
|
||||
message.startsWith('Getting subtitles') ||
|
||||
message === 'Opening YouTube video';
|
||||
return {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: success ? 'success' : progress ? 'progress' : 'info',
|
||||
persistent: progress,
|
||||
desktop: !progress,
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyConfiguredStatus(
|
||||
message: string,
|
||||
deps: ConfiguredStatusNotificationDeps,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
|
||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
getYoutubeFlowStatusNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
type ConfiguredStatusNotificationOptions,
|
||||
} from './configured-status-notification';
|
||||
@@ -206,18 +207,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
||||
}
|
||||
|
||||
function showYoutubeFlowStatusNotification(message: string): void {
|
||||
const progress =
|
||||
message.startsWith('Downloading subtitles') ||
|
||||
message.startsWith('Loading subtitles') ||
|
||||
message.startsWith('Getting subtitles') ||
|
||||
message === 'Opening YouTube video';
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: progress ? 'progress' : 'info',
|
||||
persistent: progress,
|
||||
desktop: !progress,
|
||||
});
|
||||
showConfiguredStatusNotification(message, getYoutubeFlowStatusNotificationOptions(message));
|
||||
}
|
||||
|
||||
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||
|
||||
@@ -43,6 +43,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache'];
|
||||
getYoutubeMediaSourceUrl?: OverlayRuntimeOptionsMainDeps['getYoutubeMediaSourceUrl'];
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
@@ -83,6 +84,9 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
...(deps.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(deps.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
|
||||
@@ -42,6 +42,7 @@ type OverlayRuntimeOptions = {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
@@ -81,6 +82,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
@@ -111,6 +113,9 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
...(deps.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(deps.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||
import type { YoutubeTrackProbeResult } from '../../core/services/youtube/track-probe';
|
||||
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||
|
||||
const primaryTrack: YoutubeTrackOption = {
|
||||
@@ -20,6 +21,66 @@ const secondaryTrack: YoutubeTrackOption = {
|
||||
label: 'English (manual)',
|
||||
};
|
||||
|
||||
test('youtube flow announces manual picker opening before probing tracks', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
let resolveProbe: (probe: YoutubeTrackProbeResult) => void = () => {};
|
||||
const probePromise = new Promise<YoutubeTrackProbeResult>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => await probePromise,
|
||||
acquireYoutubeSubtitleTracks: async () => new Map(),
|
||||
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }),
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'continue-without-subtitles',
|
||||
primaryTrackId: null,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
requestMpvProperty: async () => null,
|
||||
refreshCurrentSubtitle: () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: (text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
const pending = runtime.openManualPicker({ url: 'https://example.com' });
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(osdMessages, ['Opening YouTube subtitle picker...']);
|
||||
|
||||
resolveProbe({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [],
|
||||
});
|
||||
await pending;
|
||||
});
|
||||
|
||||
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const focusOverlayCalls: string[] = [];
|
||||
@@ -126,6 +187,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
|
||||
assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id);
|
||||
assert.ok(waits.includes(150));
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Opening YouTube subtitle picker...',
|
||||
'Getting subtitles...',
|
||||
'Downloading subtitles...',
|
||||
'Loading subtitles...',
|
||||
|
||||
@@ -755,6 +755,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
url: string;
|
||||
mode?: YoutubeFlowMode;
|
||||
}): Promise<void> => {
|
||||
deps.showMpvOsd('Opening YouTube subtitle picker...');
|
||||
|
||||
let probe: YoutubeTrackProbeResult;
|
||||
try {
|
||||
probe = await deps.probeYoutubeTracks(input.url);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createYoutubeMediaCachePlaybackRuntime } from './youtube-media-cache-playback';
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('youtube media cache starts when mpv reports a youtube path in background mode', async () => {
|
||||
const starts: Array<{ url: string; maxHeight: number }> = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 480 }),
|
||||
startYoutubeMediaCache: (url, options) => {
|
||||
starts.push({ url, maxHeight: options.maxHeight ?? 0 });
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc123');
|
||||
|
||||
assert.deepEqual(starts, [
|
||||
{
|
||||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
maxHeight: 480,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube media cache starts from original playlist url when mpv path is a resolved stream', async () => {
|
||||
const starts: string[] = [];
|
||||
const propertyRequests: string[] = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
propertyRequests.push(name);
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return 0;
|
||||
}
|
||||
if (name === 'playlist') {
|
||||
return [{ filename: 'https://www.youtube.com/watch?v=abc123' }];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange(
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
);
|
||||
|
||||
assert.deepEqual(propertyRequests, ['playlist-playing-pos', 'playlist']);
|
||||
assert.deepEqual(starts, ['https://www.youtube.com/watch?v=abc123']);
|
||||
assert.equal(await runtime.getActiveYoutubeSourceUrl(), 'https://www.youtube.com/watch?v=abc123');
|
||||
});
|
||||
|
||||
test('youtube media cache can recover playlist source from current playlist marker', async () => {
|
||||
const starts: string[] = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return -1;
|
||||
}
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{
|
||||
filename: 'https://example.com/other.mp4',
|
||||
},
|
||||
{
|
||||
filename: 'https://youtu.be/abc123',
|
||||
current: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange(
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
);
|
||||
|
||||
assert.deepEqual(starts, ['https://youtu.be/abc123']);
|
||||
assert.equal(await runtime.getActiveYoutubeSourceUrl(), 'https://youtu.be/abc123');
|
||||
});
|
||||
|
||||
test('youtube media source getter awaits in-flight playlist recovery', async () => {
|
||||
const starts: string[] = [];
|
||||
const playlist = createDeferred<unknown>();
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return 0;
|
||||
}
|
||||
if (name === 'playlist') {
|
||||
return await playlist.promise;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const pathChange = runtime.handleMediaPathChange(
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
);
|
||||
const sourceUrl = runtime.getActiveYoutubeSourceUrl();
|
||||
|
||||
playlist.resolve([{ filename: 'https://www.youtube.com/watch?v=abc123' }]);
|
||||
|
||||
assert.equal(await sourceUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||
await pathChange;
|
||||
assert.deepEqual(starts, ['https://www.youtube.com/watch?v=abc123']);
|
||||
});
|
||||
|
||||
test('youtube media cache ignores non-youtube paths and direct mode', async () => {
|
||||
const starts: string[] = [];
|
||||
const propertyRequests: string[] = [];
|
||||
let mode: 'direct' | 'background' = 'background';
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode, maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
propertyRequests.push(name);
|
||||
return [
|
||||
{
|
||||
filename: 'https://www.youtube.com/watch?v=abc123',
|
||||
current: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange('/tmp/video.mkv');
|
||||
mode = 'direct';
|
||||
await runtime.handleMediaPathChange('https://youtu.be/abc123');
|
||||
|
||||
assert.deepEqual(propertyRequests, []);
|
||||
assert.deepEqual(starts, []);
|
||||
});
|
||||
|
||||
test('youtube media cache logs synchronous start failures from path changes', async () => {
|
||||
const warnings: string[] = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
startYoutubeMediaCache: () => {
|
||||
throw new Error('yt-dlp missing');
|
||||
},
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange('https://youtu.be/abc123');
|
||||
|
||||
assert.deepEqual(warnings, ['Failed to start YouTube media cache: yt-dlp missing']);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { YoutubeMediaCacheMode } from '../../types/integrations';
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type PlaylistEntry = {
|
||||
filename: string;
|
||||
current: boolean;
|
||||
playing: boolean;
|
||||
};
|
||||
|
||||
export interface YoutubeMediaCachePlaybackRuntimeDeps {
|
||||
getMediaCacheConfig: () => {
|
||||
mode: YoutubeMediaCacheMode;
|
||||
maxHeight?: number;
|
||||
};
|
||||
requestMpvProperty?: (name: string) => Promise<unknown>;
|
||||
startYoutubeMediaCache: (
|
||||
url: string,
|
||||
options: {
|
||||
mode: YoutubeMediaCacheMode;
|
||||
maxHeight?: number;
|
||||
},
|
||||
) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizePlaylistIndex(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function isGoogleVideoStreamPath(mediaPath: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return host === 'googlevideo.com' || host.endsWith('.googlevideo.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaylistEntries(raw: unknown): PlaylistEntry[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.map((entry) => {
|
||||
const item = (entry ?? {}) as {
|
||||
filename?: unknown;
|
||||
current?: unknown;
|
||||
playing?: unknown;
|
||||
};
|
||||
return {
|
||||
filename: trimToNonEmptyString(item.filename) ?? '',
|
||||
current: item.current === true,
|
||||
playing: item.playing === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlaylistEntry(
|
||||
playlist: PlaylistEntry[],
|
||||
playingPosValue: unknown,
|
||||
): PlaylistEntry | null {
|
||||
if (playlist.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playingPos = normalizePlaylistIndex(playingPosValue);
|
||||
if (playingPos !== null && playingPos < playlist.length) {
|
||||
return playlist[playingPos] ?? null;
|
||||
}
|
||||
|
||||
return playlist.find((entry) => entry.current || entry.playing) ?? null;
|
||||
}
|
||||
|
||||
async function requestMpvPropertySafely(
|
||||
deps: YoutubeMediaCachePlaybackRuntimeDeps,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!deps.requestMpvProperty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await deps.requestMpvProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveYoutubeSourceFromPlaylist(
|
||||
deps: YoutubeMediaCachePlaybackRuntimeDeps,
|
||||
): Promise<string | null> {
|
||||
const [playingPosValue, playlistValue] = await Promise.all([
|
||||
requestMpvPropertySafely(deps, 'playlist-playing-pos'),
|
||||
requestMpvPropertySafely(deps, 'playlist'),
|
||||
]);
|
||||
const playlistEntry = resolvePlaylistEntry(
|
||||
normalizePlaylistEntries(playlistValue),
|
||||
playingPosValue,
|
||||
);
|
||||
return playlistEntry?.filename && isYoutubeMediaPath(playlistEntry.filename)
|
||||
? playlistEntry.filename
|
||||
: null;
|
||||
}
|
||||
|
||||
async function resolveYoutubeSourceUrl(
|
||||
deps: YoutubeMediaCachePlaybackRuntimeDeps,
|
||||
mediaPath: string,
|
||||
): Promise<string | null> {
|
||||
const directPath = trimToNonEmptyString(mediaPath);
|
||||
if (directPath && isYoutubeMediaPath(directPath)) {
|
||||
return directPath;
|
||||
}
|
||||
if (!directPath || !isGoogleVideoStreamPath(directPath)) {
|
||||
return null;
|
||||
}
|
||||
return await resolveYoutubeSourceFromPlaylist(deps);
|
||||
}
|
||||
|
||||
export function createYoutubeMediaCachePlaybackRuntime(deps: YoutubeMediaCachePlaybackRuntimeDeps) {
|
||||
let activeYoutubeSourceUrl: string | null = null;
|
||||
let activeYoutubeSourceUrlPromise: Promise<string | null> | null = null;
|
||||
let generation = 0;
|
||||
|
||||
const handleMediaPathChange = async (mediaPath: string): Promise<void> => {
|
||||
const currentGeneration = ++generation;
|
||||
const config = deps.getMediaCacheConfig();
|
||||
if (config.mode !== 'background') {
|
||||
activeYoutubeSourceUrl = null;
|
||||
activeYoutubeSourceUrlPromise = Promise.resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isYoutubeMediaPath(mediaPath)) {
|
||||
activeYoutubeSourceUrl = null;
|
||||
}
|
||||
|
||||
const sourceUrlPromise = resolveYoutubeSourceUrl(deps, mediaPath);
|
||||
activeYoutubeSourceUrlPromise = sourceUrlPromise.then((sourceUrl) =>
|
||||
currentGeneration === generation ? sourceUrl : null,
|
||||
);
|
||||
|
||||
const sourceUrl = await sourceUrlPromise;
|
||||
if (currentGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeYoutubeSourceUrl = sourceUrl;
|
||||
if (!sourceUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.startYoutubeMediaCache(sourceUrl, {
|
||||
mode: config.mode,
|
||||
maxHeight: config.maxHeight,
|
||||
});
|
||||
} catch (error) {
|
||||
deps.logWarn(
|
||||
`Failed to start YouTube media cache: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getActiveYoutubeSourceUrl: async (): Promise<string | null> =>
|
||||
activeYoutubeSourceUrlPromise ?? activeYoutubeSourceUrl,
|
||||
getActiveYoutubeSourceUrlSnapshot: (): string | null => activeYoutubeSourceUrl,
|
||||
handleMediaPathChange,
|
||||
};
|
||||
}
|
||||
@@ -130,14 +130,13 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
if (deps.startYoutubeMediaCache) {
|
||||
void new Promise<void>((resolve) => {
|
||||
resolve(deps.startYoutubeMediaCache?.(request.url));
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.logWarn(
|
||||
`Failed to start YouTube media cache: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}).catch((error) => {
|
||||
deps.logWarn(
|
||||
`Failed to start YouTube media cache: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await deps.runYoutubePlaybackFlow({
|
||||
|
||||
@@ -163,6 +163,24 @@ test('generateAudio defaults to unpadded sentence timing', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio normalizes sentence audio by default', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-af') + 1], 'loudnorm=I=-23:TP=-2:LRA=11');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio can preserve raw sentence audio loudness', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, null, false);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args.includes('-af'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createLogger } from './logger';
|
||||
import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||
|
||||
const log = createLogger('media');
|
||||
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
||||
|
||||
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||
|
||||
@@ -264,6 +265,7 @@ export class MediaGenerator {
|
||||
endTime: number,
|
||||
padding: number = 0,
|
||||
audioStreamIndex: number | null = null,
|
||||
normalizeAudio = true,
|
||||
): Promise<Buffer> {
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
@@ -293,7 +295,11 @@ export class MediaGenerator {
|
||||
args.push('-map', `0:${audioStreamIndex}`);
|
||||
}
|
||||
|
||||
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||
args.push('-vn');
|
||||
if (normalizeAudio) {
|
||||
args.push('-af', AUDIO_NORMALIZATION_FILTER);
|
||||
}
|
||||
args.push('-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||
|
||||
this.logMediaDebug(
|
||||
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
||||
|
||||
@@ -384,6 +384,92 @@ test('overlay notification renderer updates same-id progress without replacing t
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay notification renderer auto-dismisses same-id terminal update after persistent progress', () => {
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const stack = createFakeElement();
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map<number, { callback: () => void; delayMs: number }>();
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
createElement: (tagName: string) => createFakeElement(tagName),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
clearTimeout: (id: number) => {
|
||||
timers.delete(id);
|
||||
},
|
||||
setTimeout: (callback: () => void, delayMs: number) => {
|
||||
const id = nextTimerId;
|
||||
nextTimerId += 1;
|
||||
timers.set(id, { callback, delayMs });
|
||||
return id;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const renderer = createOverlayNotificationRenderer({
|
||||
dom: {
|
||||
overlayNotificationStack: stack,
|
||||
},
|
||||
state: {
|
||||
isOverOverlayNotification: false,
|
||||
},
|
||||
} as never);
|
||||
|
||||
renderer.show({
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
body: 'Downloading subtitles...',
|
||||
variant: 'progress',
|
||||
persistent: true,
|
||||
});
|
||||
const card = stack.children[0];
|
||||
if (!card) {
|
||||
assert.fail('Expected overlay notification card.');
|
||||
}
|
||||
|
||||
renderer.show({
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
body: 'Subtitles loaded.',
|
||||
variant: 'success',
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
const autoDismissTimer = [...timers.values()].find((timer) => timer.delayMs === 3000);
|
||||
assert.ok(autoDismissTimer, 'Expected terminal update to schedule an auto-dismiss timer.');
|
||||
assert.equal(stack.children[0], card);
|
||||
assert.equal(
|
||||
findChildByClass(card, 'overlay-notification-body')?.textContent,
|
||||
'Subtitles loaded.',
|
||||
);
|
||||
assert.equal(card.classList.contains('success'), true);
|
||||
|
||||
autoDismissTimer.callback();
|
||||
|
||||
assert.equal(card.classList.contains('leaving'), true);
|
||||
} finally {
|
||||
if (originalDocument) {
|
||||
Object.defineProperty(globalThis, 'document', originalDocument);
|
||||
} else {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
}
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay notification cards use larger display dimensions', () => {
|
||||
assert.match(
|
||||
overlayNotificationCss,
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AnkiConnectConfig {
|
||||
animatedMaxHeight?: number;
|
||||
animatedCrf?: number;
|
||||
syncAnimatedImageToWordAudio?: boolean;
|
||||
normalizeAudio?: boolean;
|
||||
audioPadding?: number;
|
||||
fallbackDuration?: number;
|
||||
maxMediaDuration?: number;
|
||||
|
||||
@@ -235,6 +235,7 @@ export interface ResolvedConfig {
|
||||
animatedMaxHeight?: number;
|
||||
animatedCrf: number;
|
||||
syncAnimatedImageToWordAudio: boolean;
|
||||
normalizeAudio: boolean;
|
||||
audioPadding: number;
|
||||
fallbackDuration: number;
|
||||
maxMediaDuration: number;
|
||||
|
||||
Reference in New Issue
Block a user