mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-20 03:13:31 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
236f22662c
|
|||
|
d199376364
|
@@ -1,5 +1,40 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.17.0 (2026-06-15)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||||
|
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||||
|
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||||
|
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||||
|
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||||
|
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||||
|
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||||
|
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||||
|
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||||
|
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||||
|
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||||
|
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||||
|
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.16.0 (2026-06-10)
|
## v0.16.0 (2026-06-10)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Fixed Highlight Word not bolding the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence field did not already contain bold markup.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: notifications
|
|
||||||
|
|
||||||
- Restored the SubMiner app icon on system notifications that do not provide a custom notification image.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Fixed Lapis/Kiku word cards enriched through SubMiner missing the word-and-sentence marker, which could hide sentence context on the card front.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: updates
|
|
||||||
|
|
||||||
- Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: updates
|
|
||||||
|
|
||||||
- Fixed Linux updates so the managed support-asset install now creates and refreshes both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
|
|
||||||
- Fixed Linux support-asset refreshes so unrelated SubMiner data directories are left alone and plugin copies are staged before replacing the live runtime plugin.
|
|
||||||
- Fixed first Linux launcher playback on fresh installs by auto-installing the managed runtime plugin copy and rofi theme from the bundled app before mpv starts when either support asset is missing.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle.
|
|
||||||
- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed auto-paused Linux visible overlay startup so the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv `sub-text` refreshes parsed cues before synthetic warm readiness can resume playback.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Kept the visible overlay active while mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
|
|
||||||
- Scoped prerelease note reuse to the same base version so a new beta line starts from current fragments instead of stale notes from older prereleases.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: runtime
|
|
||||||
|
|
||||||
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
|
||||||
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: updates
|
|
||||||
|
|
||||||
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Fixed known-word cache refreshes without a configured deck by using AnkiConnect's valid all-notes query instead of `is:note`.
|
|
||||||
- Fixed Windows media generation after background launches by recreating missing FFmpeg temp output directories before clipping audio or images.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: dictionary
|
|
||||||
|
|
||||||
- Fixed Windows `SubMiner mpv` shortcut launches so character dictionary auto-sync can fall back to mpv's current video path when app media state is not ready yet.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed shaky Windows subtitle-bar hover/click interaction when a video attaches to an already-running background SubMiner app.
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: youtube
|
||||||
|
|
||||||
|
- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps.
|
||||||
|
- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable.
|
||||||
@@ -618,7 +618,10 @@
|
|||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
|
"mediaCache": {
|
||||||
|
"mode": "direct" // How YouTube card audio/images are extracted. Values: direct | background
|
||||||
|
} // Media cache setting.
|
||||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
+43
-3
@@ -1,6 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.16.0 (2026-06-10)
|
## v0.17.0 (2026-06-15)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||||
|
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||||
|
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||||
|
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||||
|
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||||
|
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||||
|
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||||
|
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||||
|
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||||
|
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||||
|
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||||
|
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
|
||||||
|
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
|
||||||
|
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||||
|
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Previous Versions
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.16.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.16.0 (2026-06-10)</h2>
|
||||||
|
|
||||||
**Breaking Changes**
|
**Breaking Changes**
|
||||||
|
|
||||||
@@ -24,7 +64,7 @@
|
|||||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||||
- **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
|
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
|
||||||
|
|
||||||
**Fixed**
|
**Fixed**
|
||||||
|
|
||||||
@@ -48,7 +88,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Previous Versions
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.15.x</summary>
|
<summary>v0.15.x</summary>
|
||||||
|
|||||||
@@ -1520,14 +1520,20 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": ["ja", "jpn"]
|
"primarySubLanguages": ["ja", "jpn"],
|
||||||
|
"mediaCache": {
|
||||||
|
"mode": "direct"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
|
| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||||
|
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||||
|
|
||||||
|
`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; card media generation uses the cached file once it is ready and otherwise falls back to direct stream extraction.
|
||||||
|
|
||||||
Current launcher behavior:
|
Current launcher behavior:
|
||||||
|
|
||||||
|
|||||||
@@ -618,7 +618,10 @@
|
|||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
|
"mediaCache": {
|
||||||
|
"mode": "direct" // How YouTube card audio/images are extracted. Values: direct | background
|
||||||
|
} // Media cache setting.
|
||||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.17.0-beta.2",
|
"version": "0.17.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
+28
-3
@@ -61,7 +61,11 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
|||||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||||
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
||||||
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||||
import { resolveMediaGenerationInputPath } from './anki-integration/media-source';
|
import {
|
||||||
|
resolveMediaGenerationInput,
|
||||||
|
resolveMediaGenerationInputPath,
|
||||||
|
type MediaGenerationInputResolverOptions,
|
||||||
|
} from './anki-integration/media-source';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration');
|
const log = createLogger('anki').child('integration');
|
||||||
|
|
||||||
@@ -225,6 +229,8 @@ export class AnkiIntegration {
|
|||||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||||
private noteIdRedirects = new Map<number, number>();
|
private noteIdRedirects = new Map<number, number>();
|
||||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||||
|
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||||
|
null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: AnkiConnectConfig,
|
config: AnkiConnectConfig,
|
||||||
@@ -240,6 +246,7 @@ export class AnkiIntegration {
|
|||||||
aiConfig: AiConfig = {},
|
aiConfig: AiConfig = {},
|
||||||
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||||
|
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
||||||
) {
|
) {
|
||||||
this.config = normalizeAnkiIntegrationConfig(config);
|
this.config = normalizeAnkiIntegrationConfig(config);
|
||||||
this.aiConfig = { ...aiConfig };
|
this.aiConfig = { ...aiConfig };
|
||||||
@@ -252,6 +259,7 @@ export class AnkiIntegration {
|
|||||||
this.overlayNotificationCallback = overlayNotificationCallback || null;
|
this.overlayNotificationCallback = overlayNotificationCallback || null;
|
||||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||||
|
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
||||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||||
this.pollingRunner = this.createPollingRunner();
|
this.pollingRunner = this.createPollingRunner();
|
||||||
this.cardCreationService = this.createCardCreationService();
|
this.cardCreationService = this.createCardCreationService();
|
||||||
@@ -295,6 +303,14 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||||
|
const options: MediaGenerationInputResolverOptions = {};
|
||||||
|
if (this.getCachedMediaPath) {
|
||||||
|
options.getCachedMediaPath = this.getCachedMediaPath;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
|
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
|
||||||
return new KnownWordCacheManager({
|
return new KnownWordCacheManager({
|
||||||
client: {
|
client: {
|
||||||
@@ -377,6 +393,7 @@ export class AnkiIntegration {
|
|||||||
getAiConfig: () => this.aiConfig,
|
getAiConfig: () => this.aiConfig,
|
||||||
getTimingTracker: () => this.timingTracker,
|
getTimingTracker: () => this.timingTracker,
|
||||||
getMpvClient: () => this.mpvClient,
|
getMpvClient: () => this.mpvClient,
|
||||||
|
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
|
||||||
getDeck: () => this.config.deck,
|
getDeck: () => this.config.deck,
|
||||||
client: {
|
client: {
|
||||||
addNote: (deck, modelName, fields, tags) =>
|
addNote: (deck, modelName, fields, tags) =>
|
||||||
@@ -839,7 +856,11 @@ export class AnkiIntegration {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
const videoPath = await resolveMediaGenerationInput(
|
||||||
|
mpvClient,
|
||||||
|
'audio',
|
||||||
|
this.getMediaResolverOptions(),
|
||||||
|
);
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -862,7 +883,11 @@ export class AnkiIntegration {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
const videoPath = await resolveMediaGenerationInput(
|
||||||
|
this.mpvClient,
|
||||||
|
'video',
|
||||||
|
this.getMediaResolverOptions(),
|
||||||
|
);
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
@@ -266,6 +267,8 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
|||||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||||
const audioPaths: string[] = [];
|
const audioPaths: string[] = [];
|
||||||
const imagePaths: string[] = [];
|
const imagePaths: string[] = [];
|
||||||
|
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||||
|
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||||
const edlSource = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
'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',
|
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||||
@@ -338,11 +341,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
|||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
audioPaths.push(path);
|
audioPaths.push(recordMediaPath(path));
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
generateScreenshot: async (path) => {
|
generateScreenshot: async (path) => {
|
||||||
imagePaths.push(path);
|
imagePaths.push(recordMediaPath(path));
|
||||||
return Buffer.from('image');
|
return Buffer.from('image');
|
||||||
},
|
},
|
||||||
generateAnimatedImage: async () => null,
|
generateAnimatedImage: async () => null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
|
||||||
test('CardCreationService counts locally created sentence cards', async () => {
|
test('CardCreationService counts locally created sentence cards', async () => {
|
||||||
@@ -287,6 +288,8 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
|
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
|
||||||
const audioPaths: string[] = [];
|
const audioPaths: string[] = [];
|
||||||
const imagePaths: string[] = [];
|
const imagePaths: string[] = [];
|
||||||
|
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||||
|
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||||
const edlSource = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
'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',
|
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||||
@@ -345,11 +348,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
audioPaths.push(path);
|
audioPaths.push(recordMediaPath(path));
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
generateScreenshot: async (path) => {
|
generateScreenshot: async (path) => {
|
||||||
imagePaths.push(path);
|
imagePaths.push(recordMediaPath(path));
|
||||||
return Buffer.from('image');
|
return Buffer.from('image');
|
||||||
},
|
},
|
||||||
generateAnimatedImage: async () => null,
|
generateAnimatedImage: async () => null,
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import {
|
|||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
import { AiConfig } from '../types/integrations';
|
import { AiConfig } from '../types/integrations';
|
||||||
import { MpvClient } from '../types/runtime';
|
import { MpvClient } from '../types/runtime';
|
||||||
import { resolveSentenceBackText } from './ai';
|
import { resolveSentenceBackText } from './ai';
|
||||||
import { resolveMediaGenerationInputPath } from './media-source';
|
import {
|
||||||
|
resolveMediaGenerationInput,
|
||||||
|
type MediaGenerationInputResolverOptions,
|
||||||
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.card-creation');
|
const log = createLogger('anki').child('integration.card-creation');
|
||||||
@@ -38,14 +42,14 @@ interface CardCreationClient {
|
|||||||
|
|
||||||
interface CardCreationMediaGenerator {
|
interface CardCreationMediaGenerator {
|
||||||
generateAudio(
|
generateAudio(
|
||||||
path: string,
|
path: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
): Promise<Buffer | null>;
|
): Promise<Buffer | null>;
|
||||||
generateScreenshot(
|
generateScreenshot(
|
||||||
path: string,
|
path: MediaInput,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
options: {
|
options: {
|
||||||
format: 'jpg' | 'png' | 'webp';
|
format: 'jpg' | 'png' | 'webp';
|
||||||
@@ -55,7 +59,7 @@ interface CardCreationMediaGenerator {
|
|||||||
},
|
},
|
||||||
): Promise<Buffer | null>;
|
): Promise<Buffer | null>;
|
||||||
generateAnimatedImage(
|
generateAnimatedImage(
|
||||||
path: string,
|
path: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
@@ -74,6 +78,7 @@ interface CardCreationDeps {
|
|||||||
getAiConfig: () => AiConfig;
|
getAiConfig: () => AiConfig;
|
||||||
getTimingTracker: () => SubtitleTimingTracker;
|
getTimingTracker: () => SubtitleTimingTracker;
|
||||||
getMpvClient: () => MpvClient;
|
getMpvClient: () => MpvClient;
|
||||||
|
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
||||||
getDeck?: () => string | undefined;
|
getDeck?: () => string | undefined;
|
||||||
client: CardCreationClient;
|
client: CardCreationClient;
|
||||||
mediaGenerator: CardCreationMediaGenerator;
|
mediaGenerator: CardCreationMediaGenerator;
|
||||||
@@ -121,6 +126,14 @@ interface CardCreationDeps {
|
|||||||
export class CardCreationService {
|
export class CardCreationService {
|
||||||
constructor(private readonly deps: CardCreationDeps) {}
|
constructor(private readonly deps: CardCreationDeps) {}
|
||||||
|
|
||||||
|
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||||
|
const options: MediaGenerationInputResolverOptions = {};
|
||||||
|
if (this.deps.getCachedMediaPath) {
|
||||||
|
options.getCachedMediaPath = this.deps.getCachedMediaPath;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
private getConfiguredAnkiTags(): string[] {
|
private getConfiguredAnkiTags(): string[] {
|
||||||
const tags = this.deps.getConfig().tags;
|
const tags = this.deps.getConfig().tags;
|
||||||
if (!Array.isArray(tags)) {
|
if (!Array.isArray(tags)) {
|
||||||
@@ -246,11 +259,12 @@ export class CardCreationService {
|
|||||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||||
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
||||||
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
|
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||||
: null;
|
: null;
|
||||||
const videoPath = this.deps.getConfig().media?.generateImage
|
const videoPath = this.deps.getConfig().media?.generateImage
|
||||||
? await resolveMediaGenerationInputPath(mpvClient, 'video')
|
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (this.deps.getConfig().media?.generateAudio) {
|
if (this.deps.getConfig().media?.generateAudio) {
|
||||||
@@ -522,8 +536,17 @@ export class CardCreationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||||
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
const videoPath = await resolveMediaGenerationInput(
|
||||||
|
mpvClient,
|
||||||
|
'video',
|
||||||
|
mediaResolverOptions,
|
||||||
|
);
|
||||||
|
const audioSourcePath = await resolveMediaGenerationInput(
|
||||||
|
mpvClient,
|
||||||
|
'audio',
|
||||||
|
mediaResolverOptions,
|
||||||
|
);
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
this.deps.showOsdNotification('No video loaded');
|
this.deps.showOsdNotification('No video loaded');
|
||||||
return false;
|
return false;
|
||||||
@@ -740,7 +763,7 @@ export class CardCreationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async mediaGenerateAudio(
|
private async mediaGenerateAudio(
|
||||||
videoPath: string,
|
videoPath: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
@@ -759,7 +782,7 @@ export class CardCreationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async generateImageBuffer(
|
private async generateImageBuffer(
|
||||||
videoPath: string,
|
videoPath: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
animatedLeadInSeconds = 0,
|
animatedLeadInSeconds = 0,
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { resolveMediaGenerationInputPath } from './media-source';
|
import * as mediaSource from './media-source';
|
||||||
|
|
||||||
|
const { resolveMediaGenerationInputPath } = mediaSource;
|
||||||
|
|
||||||
|
type StructuredMediaInput = {
|
||||||
|
path: string;
|
||||||
|
source: string;
|
||||||
|
singleResolvedStream: boolean;
|
||||||
|
inputOptions?: {
|
||||||
|
reconnect?: boolean;
|
||||||
|
userAgent?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type StructuredMediaResolver = (
|
||||||
|
mpvClient: Parameters<typeof resolveMediaGenerationInputPath>[0],
|
||||||
|
kind?: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||||
|
options?: {
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||||
|
) => Promise<string | null>;
|
||||||
|
},
|
||||||
|
) => Promise<StructuredMediaInput | null>;
|
||||||
|
|
||||||
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
|
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
|
||||||
const result = await resolveMediaGenerationInputPath({
|
const result = await resolveMediaGenerationInputPath({
|
||||||
@@ -62,3 +86,103 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream
|
|||||||
|
|
||||||
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
|
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URLs', async () => {
|
||||||
|
const resolver = (
|
||||||
|
mediaSource as typeof mediaSource & {
|
||||||
|
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||||
|
}
|
||||||
|
).resolveMediaGenerationInput;
|
||||||
|
assert.equal(typeof resolver, 'function');
|
||||||
|
|
||||||
|
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',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
const result = await resolver!(
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'stream-open-filename') return edlSource;
|
||||||
|
if (name === 'user-agent') return 'Mozilla/5.0';
|
||||||
|
if (name === 'http-header-fields') {
|
||||||
|
return ['Cookie: SID=secret', 'Referer: https://www.youtube.com/', 'X-Test: ok'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'audio',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||||
|
assert.equal(result?.singleResolvedStream, true);
|
||||||
|
assert.equal(result?.inputOptions?.reconnect, true);
|
||||||
|
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||||
|
assert.deepEqual(result?.inputOptions?.headers, {
|
||||||
|
Referer: 'https://www.youtube.com/',
|
||||||
|
'X-Test': 'ok',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInput reads file-local mpv request options', async () => {
|
||||||
|
const resolver = (
|
||||||
|
mediaSource as typeof mediaSource & {
|
||||||
|
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||||
|
}
|
||||||
|
).resolveMediaGenerationInput;
|
||||||
|
assert.equal(typeof resolver, 'function');
|
||||||
|
|
||||||
|
const result = await resolver!(
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'stream-open-filename') {
|
||||||
|
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||||
|
}
|
||||||
|
if (name === 'file-local-options/user-agent') return 'SubMiner Test Agent';
|
||||||
|
if (name === 'options/http-header-fields') return ['X-Shared: ok'];
|
||||||
|
if (name === 'file-local-options/http-header-fields') {
|
||||||
|
return ['Cookie: SID=secret', 'Referer: https://m.youtube.com/', 'X-Local: yes'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'video',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||||
|
assert.equal(result?.singleResolvedStream, true);
|
||||||
|
assert.equal(result?.inputOptions?.userAgent, 'SubMiner Test Agent');
|
||||||
|
assert.deepEqual(result?.inputOptions?.headers, {
|
||||||
|
'X-Shared': 'ok',
|
||||||
|
Referer: 'https://m.youtube.com/',
|
||||||
|
'X-Local': 'yes',
|
||||||
|
Origin: 'https://www.youtube.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => {
|
||||||
|
const resolver = (
|
||||||
|
mediaSource as typeof mediaSource & {
|
||||||
|
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||||
|
}
|
||||||
|
).resolveMediaGenerationInput;
|
||||||
|
assert.equal(typeof resolver, 'function');
|
||||||
|
|
||||||
|
const result = await resolver!(
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||||
|
},
|
||||||
|
'video',
|
||||||
|
{
|
||||||
|
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result?.path, '/tmp/subminer-youtube-media-cache/abc123/media.mkv');
|
||||||
|
assert.equal(result?.source, 'youtube-cache');
|
||||||
|
assert.equal(result?.singleResolvedStream, false);
|
||||||
|
assert.equal(result?.inputOptions, undefined);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,45 @@
|
|||||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||||
|
import type { MediaInputOptions } from '../media-input';
|
||||||
import type { MpvClient } from '../types/runtime';
|
import type { MpvClient } from '../types/runtime';
|
||||||
|
|
||||||
export type MediaGenerationKind = 'audio' | 'video';
|
export type MediaGenerationKind = 'audio' | 'video';
|
||||||
|
export type MediaGenerationInputSource =
|
||||||
|
| 'current-path'
|
||||||
|
| 'stream-open-filename'
|
||||||
|
| 'edl-stream'
|
||||||
|
| 'youtube-cache';
|
||||||
|
|
||||||
|
export interface ResolvedMediaGenerationInput {
|
||||||
|
path: string;
|
||||||
|
kind: MediaGenerationKind;
|
||||||
|
source: MediaGenerationInputSource;
|
||||||
|
singleResolvedStream: boolean;
|
||||||
|
inputOptions?: MediaInputOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaGenerationInputResolverOptions {
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: MediaGenerationKind,
|
||||||
|
) => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||||
|
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
|
||||||
|
'http-header-fields',
|
||||||
|
'options/http-header-fields',
|
||||||
|
'file-local-options/http-header-fields',
|
||||||
|
] as const;
|
||||||
|
const USER_AGENT_PROPERTY_NAMES = [
|
||||||
|
'file-local-options/user-agent',
|
||||||
|
'options/user-agent',
|
||||||
|
'user-agent',
|
||||||
|
] as const;
|
||||||
|
const REFERRER_PROPERTY_NAMES = [
|
||||||
|
'file-local-options/referrer',
|
||||||
|
'options/referrer',
|
||||||
|
'referrer',
|
||||||
|
] as const;
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
@@ -11,6 +49,17 @@ function trimToNonEmptyString(value: unknown): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderName(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||||
return [...matches]
|
return [...matches]
|
||||||
@@ -53,6 +102,213 @@ function resolvePreferredUrlFromMpvEdlSource(
|
|||||||
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
|
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHostname(value: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(value).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesHost(hostname: string, expectedHost: string): boolean {
|
||||||
|
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGoogleVideoMediaPath(value: string): boolean {
|
||||||
|
const host = getHostname(value);
|
||||||
|
return Boolean(host && matchesHost(host, 'googlevideo.com'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMpvHeaderField(value: string): [string, string] | null {
|
||||||
|
const separatorIndex = value.indexOf(':');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = normalizeHeaderName(value.slice(0, separatorIndex));
|
||||||
|
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
|
||||||
|
if (!name || !headerValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHeaderFields(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOptionalMpvProperty(
|
||||||
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||||
|
name: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (!mpvClient.requestProperty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await mpvClient.requestProperty(name);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestFirstNonEmptyStringProperty(
|
||||||
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||||
|
names: readonly string[],
|
||||||
|
): Promise<string | null> {
|
||||||
|
for (const name of names) {
|
||||||
|
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRemoteInputOptions(
|
||||||
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||||
|
resolvedPath: string,
|
||||||
|
): Promise<MediaInputOptions | undefined> {
|
||||||
|
if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
||||||
|
const mpvHeaderFields = toHeaderFields(
|
||||||
|
await requestOptionalMpvProperty(mpvClient, propertyName),
|
||||||
|
);
|
||||||
|
for (const field of mpvHeaderFields) {
|
||||||
|
const parsed = parseMpvHeaderField(field);
|
||||||
|
if (parsed) {
|
||||||
|
headers[parsed[0]] = parsed[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
|
||||||
|
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
|
||||||
|
if (referrer) {
|
||||||
|
setHeaderIfMissing(headers, 'Referer', referrer);
|
||||||
|
}
|
||||||
|
if (isGoogleVideoMediaPath(resolvedPath)) {
|
||||||
|
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
||||||
|
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reconnect: true,
|
||||||
|
...(userAgent ? { userAgent } : {}),
|
||||||
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toResolvedMediaGenerationInput(
|
||||||
|
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||||
|
path: string,
|
||||||
|
kind: MediaGenerationKind,
|
||||||
|
source: MediaGenerationInputSource,
|
||||||
|
singleResolvedStream: boolean,
|
||||||
|
): Promise<ResolvedMediaGenerationInput> {
|
||||||
|
const inputOptions = await resolveRemoteInputOptions(mpvClient, path);
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
singleResolvedStream,
|
||||||
|
...(inputOptions ? { inputOptions } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMediaGenerationInput(
|
||||||
|
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||||
|
kind: MediaGenerationKind = 'video',
|
||||||
|
options: MediaGenerationInputResolverOptions = {},
|
||||||
|
): Promise<ResolvedMediaGenerationInput | null> {
|
||||||
|
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
||||||
|
if (!currentVideoPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRemoteMediaPath(currentVideoPath)) {
|
||||||
|
return {
|
||||||
|
path: currentVideoPath,
|
||||||
|
kind,
|
||||||
|
source: 'current-path',
|
||||||
|
singleResolvedStream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedPath = options.getCachedMediaPath
|
||||||
|
? trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind))
|
||||||
|
: null;
|
||||||
|
if (cachedPath) {
|
||||||
|
return {
|
||||||
|
path: cachedPath,
|
||||||
|
kind,
|
||||||
|
source: 'youtube-cache',
|
||||||
|
singleResolvedStream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mpvClient?.requestProperty) {
|
||||||
|
return {
|
||||||
|
path: currentVideoPath,
|
||||||
|
kind,
|
||||||
|
source: 'current-path',
|
||||||
|
singleResolvedStream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamOpenFilename = trimToNonEmptyString(
|
||||||
|
await mpvClient.requestProperty('stream-open-filename'),
|
||||||
|
);
|
||||||
|
if (streamOpenFilename?.startsWith('edl://')) {
|
||||||
|
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
|
||||||
|
if (preferredUrl) {
|
||||||
|
return toResolvedMediaGenerationInput(mpvClient, preferredUrl, kind, 'edl-stream', true);
|
||||||
|
}
|
||||||
|
return toResolvedMediaGenerationInput(
|
||||||
|
mpvClient,
|
||||||
|
streamOpenFilename,
|
||||||
|
kind,
|
||||||
|
'stream-open-filename',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (streamOpenFilename) {
|
||||||
|
return toResolvedMediaGenerationInput(
|
||||||
|
mpvClient,
|
||||||
|
streamOpenFilename,
|
||||||
|
kind,
|
||||||
|
'stream-open-filename',
|
||||||
|
isRemoteMediaPath(streamOpenFilename),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResolvedMediaGenerationInput(mpvClient, currentVideoPath, kind, 'current-path', false);
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveMediaGenerationInputPath(
|
export async function resolveMediaGenerationInputPath(
|
||||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||||
kind: MediaGenerationKind = 'video',
|
kind: MediaGenerationKind = 'video',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||||
assert.equal('deviceId' in config.jellyfin, false);
|
assert.equal('deviceId' in config.jellyfin, false);
|
||||||
assert.equal('clientVersion' in config.jellyfin, false);
|
assert.equal('clientVersion' in config.jellyfin, false);
|
||||||
|
assert.equal(config.youtube.mediaCache.mode, 'direct');
|
||||||
assert.equal(config.ai.enabled, false);
|
assert.equal(config.ai.enabled, false);
|
||||||
assert.equal(config.ai.apiKeyCommand, '');
|
assert.equal(config.ai.apiKeyCommand, '');
|
||||||
assert.equal(config.texthooker.openBrowser, false);
|
assert.equal(config.texthooker.openBrowser, false);
|
||||||
@@ -1750,6 +1751,46 @@ test('parses global shortcuts and startup settings', () => {
|
|||||||
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses YouTube media cache config and warns on invalid values', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"youtube": {
|
||||||
|
"mediaCache": {
|
||||||
|
"mode": "background"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
assert.equal(validService.getConfig().youtube.mediaCache.mode, 'background');
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"youtube": {
|
||||||
|
"mediaCache": {
|
||||||
|
"mode": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(
|
||||||
|
invalidService.getConfig().youtube.mediaCache.mode,
|
||||||
|
DEFAULT_CONFIG.youtube.mediaCache.mode,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.mode'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parses controller settings with logical bindings and tuning knobs', () => {
|
test('parses controller settings with logical bindings and tuning knobs', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
youtube: {
|
youtube: {
|
||||||
primarySubLanguages: ['ja', 'jpn'],
|
primarySubLanguages: ['ja', 'jpn'],
|
||||||
|
mediaCache: {
|
||||||
|
mode: 'direct',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subsync: {
|
subsync: {
|
||||||
alass_path: '',
|
alass_path: '',
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
|
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'youtube.mediaCache.mode',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['direct', 'background'],
|
||||||
|
enumLabels: {
|
||||||
|
direct: 'Direct stream extraction',
|
||||||
|
background: 'Background media cache',
|
||||||
|
},
|
||||||
|
defaultValue: defaultConfig.youtube.mediaCache.mode,
|
||||||
|
description: 'How YouTube card audio/images are extracted.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'controller.enabled',
|
path: 'controller.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface ConfigOptionRegistryEntry {
|
|||||||
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
|
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
|
||||||
*/
|
*/
|
||||||
enumValues?: readonly string[];
|
enumValues?: readonly string[];
|
||||||
|
enumLabels?: Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Optional settings UI subset when legacy/runtime-valid enum options should remain
|
* Optional settings UI subset when legacy/runtime-valid enum options should remain
|
||||||
* editable in config files but hidden from new UI choices, for example
|
* editable in config files but hidden from new UI choices, for example
|
||||||
|
|||||||
@@ -297,6 +297,27 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
'Expected string array.',
|
'Expected string array.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(src.youtube.mediaCache)) {
|
||||||
|
const mode = src.youtube.mediaCache.mode;
|
||||||
|
if (mode === 'direct' || mode === 'background') {
|
||||||
|
resolved.youtube.mediaCache.mode = mode;
|
||||||
|
} else if (mode !== undefined) {
|
||||||
|
warn(
|
||||||
|
'youtube.mediaCache.mode',
|
||||||
|
mode,
|
||||||
|
resolved.youtube.mediaCache.mode,
|
||||||
|
"Expected 'direct' or 'background'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (src.youtube.mediaCache !== undefined) {
|
||||||
|
warn(
|
||||||
|
'youtube.mediaCache',
|
||||||
|
src.youtube.mediaCache,
|
||||||
|
resolved.youtube.mediaCache,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.subsync)) {
|
if (isObject(src.subsync)) {
|
||||||
|
|||||||
@@ -165,6 +165,17 @@ test('settings registry exposes specialized controls for config-assisted inputs'
|
|||||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
||||||
|
const mediaCacheMode = field('youtube.mediaCache.mode');
|
||||||
|
|
||||||
|
assert.equal(mediaCacheMode.control, 'select');
|
||||||
|
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
|
||||||
|
assert.deepEqual(mediaCacheMode.enumLabels, {
|
||||||
|
direct: 'Direct stream extraction',
|
||||||
|
background: 'Background media cache',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||||
const primaryVisible = fields
|
const primaryVisible = fields
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
|||||||
...(option?.settingsEnumValues || option?.enumValues
|
...(option?.settingsEnumValues || option?.enumValues
|
||||||
? { enumValues: option.settingsEnumValues ?? option.enumValues }
|
? { enumValues: option.settingsEnumValues ?? option.enumValues }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
|
||||||
restartBehavior: restartBehaviorForPath(leaf.path),
|
restartBehavior: restartBehaviorForPath(leaf.path),
|
||||||
advanced:
|
advanced:
|
||||||
leaf.path.startsWith('controller.') ||
|
leaf.path.startsWith('controller.') ||
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
|||||||
getAnkiIntegration: () => AnkiIntegration | null;
|
getAnkiIntegration: () => AnkiIntegration | null;
|
||||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: 'audio' | 'video',
|
||||||
|
) => Promise<string | null>;
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
createFieldGroupingCallback: () => (
|
createFieldGroupingCallback: () => (
|
||||||
@@ -107,6 +111,7 @@ export function registerAnkiJimakuIpcRuntime(
|
|||||||
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
|
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
|
||||||
undefined,
|
undefined,
|
||||||
options.showOverlayNotification,
|
options.showOverlayNotification,
|
||||||
|
options.getCachedMediaPath,
|
||||||
);
|
);
|
||||||
integration.start();
|
integration.start();
|
||||||
options.setAnkiIntegration(integration);
|
options.setAnkiIntegration(integration);
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ type CreateAnkiIntegrationArgs = {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
knownWordCacheStatePath: string;
|
knownWordCacheStatePath: string;
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: 'audio' | 'video',
|
||||||
|
) => Promise<string | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OverlayWindowTrackerOptions = {
|
export type OverlayWindowTrackerOptions = {
|
||||||
@@ -65,6 +69,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
|||||||
args.aiConfig,
|
args.aiConfig,
|
||||||
undefined,
|
undefined,
|
||||||
args.showOverlayNotification,
|
args.showOverlayNotification,
|
||||||
|
args.getCachedMediaPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +137,10 @@ export function initializeOverlayRuntime(
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: 'audio' | 'video',
|
||||||
|
) => Promise<string | null>;
|
||||||
shouldStartAnkiIntegration?: () => boolean;
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
backendOverride: string | null;
|
backendOverride: string | null;
|
||||||
@@ -166,6 +175,10 @@ export function initializeOverlayAnkiIntegration(options: {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: 'audio' | 'video',
|
||||||
|
) => Promise<string | null>;
|
||||||
shouldStartAnkiIntegration?: () => boolean;
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
@@ -200,6 +213,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
|||||||
showOverlayNotification: options.showOverlayNotification,
|
showOverlayNotification: options.showOverlayNotification,
|
||||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||||
|
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
|
||||||
});
|
});
|
||||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||||
integration.start();
|
integration.start();
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createYoutubeMediaCacheService } from './media-cache';
|
||||||
|
|
||||||
|
class FakeYtDlpProcess extends EventEmitter {
|
||||||
|
killed = false;
|
||||||
|
stdout = new EventEmitter();
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
|
||||||
|
kill(): boolean {
|
||||||
|
this.killed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpawnCall = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
options?: { stdio?: Array<'ignore' | 'pipe'> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeTempCacheRoot(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-media-cache-test-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('YouTube media cache does nothing in direct mode', async () => {
|
||||||
|
const cacheRoot = makeTempCacheRoot();
|
||||||
|
const spawnCalls: SpawnCall[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = createYoutubeMediaCacheService({
|
||||||
|
cacheRoot,
|
||||||
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
spawn: (command, args) => {
|
||||||
|
spawnCalls.push({ command, args });
|
||||||
|
return new FakeYtDlpProcess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/demo', { mode: 'direct' });
|
||||||
|
|
||||||
|
assert.deepEqual(spawnCalls, []);
|
||||||
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube media cache exposes the downloaded file after the background job completes', async () => {
|
||||||
|
const cacheRoot = makeTempCacheRoot();
|
||||||
|
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||||
|
const spawnCalls: SpawnCall[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = createYoutubeMediaCacheService({
|
||||||
|
cacheRoot,
|
||||||
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
spawn: (command, args, options) => {
|
||||||
|
spawnCalls.push({ command, args, options });
|
||||||
|
const proc = new FakeYtDlpProcess();
|
||||||
|
spawnedProcesses.push(proc);
|
||||||
|
return proc;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||||
|
|
||||||
|
assert.equal(spawnCalls.length, 1);
|
||||||
|
assert.equal(spawnCalls[0]?.command, 'yt-dlp');
|
||||||
|
assert.ok(spawnCalls[0]?.args.includes('--no-playlist'));
|
||||||
|
assert.ok(spawnCalls[0]?.args.includes('--merge-output-format'));
|
||||||
|
assert.deepEqual(spawnCalls[0]?.options?.stdio, ['ignore', 'ignore', 'ignore']);
|
||||||
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
||||||
|
|
||||||
|
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
|
||||||
|
assert.equal(typeof outputTemplate, 'string');
|
||||||
|
const outputDir = path.dirname(outputTemplate!);
|
||||||
|
const outputPath = path.join(outputDir, 'media.mkv');
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(outputPath, 'cached media');
|
||||||
|
spawnedProcesses[0]?.emit('close', 0);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube media cache restarts when a ready cached file was deleted externally', async () => {
|
||||||
|
const cacheRoot = makeTempCacheRoot();
|
||||||
|
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||||
|
const spawnCalls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = createYoutubeMediaCacheService({
|
||||||
|
cacheRoot,
|
||||||
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
spawn: (command, args) => {
|
||||||
|
spawnCalls.push({ command, args });
|
||||||
|
const proc = new FakeYtDlpProcess();
|
||||||
|
spawnedProcesses.push(proc);
|
||||||
|
return proc;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||||
|
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
|
||||||
|
assert.equal(typeof outputTemplate, 'string');
|
||||||
|
const outputDir = path.dirname(outputTemplate!);
|
||||||
|
const outputPath = path.join(outputDir, 'media.mkv');
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(outputPath, 'cached media');
|
||||||
|
spawnedProcesses[0]?.emit('close', 0);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
|
||||||
|
|
||||||
|
fs.rmSync(outputPath);
|
||||||
|
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||||
|
|
||||||
|
assert.equal(spawnCalls.length, 2);
|
||||||
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube media cache drops old sessions when a new background cache starts', async () => {
|
||||||
|
const cacheRoot = makeTempCacheRoot();
|
||||||
|
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||||
|
const spawnCalls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = createYoutubeMediaCacheService({
|
||||||
|
cacheRoot,
|
||||||
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
spawn: (command, args) => {
|
||||||
|
spawnCalls.push({ command, args });
|
||||||
|
const proc = new FakeYtDlpProcess();
|
||||||
|
spawnedProcesses.push(proc);
|
||||||
|
return proc;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/first', { mode: 'background' });
|
||||||
|
const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
|
||||||
|
assert.equal(typeof firstOutputTemplate, 'string');
|
||||||
|
const firstOutputDir = path.dirname(firstOutputTemplate!);
|
||||||
|
fs.mkdirSync(firstOutputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(firstOutputDir, 'media.mkv'), 'cached media');
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/second', { mode: 'background' });
|
||||||
|
|
||||||
|
assert.equal(spawnedProcesses[0]?.killed, true);
|
||||||
|
assert.equal(fs.existsSync(firstOutputDir), false);
|
||||||
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/first'), null);
|
||||||
|
assert.equal(spawnCalls.length, 2);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube media cache cleanup kills downloads and removes temp files', async () => {
|
||||||
|
const cacheRoot = makeTempCacheRoot();
|
||||||
|
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||||
|
const spawnCalls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = createYoutubeMediaCacheService({
|
||||||
|
cacheRoot,
|
||||||
|
getYtDlpCommand: () => 'yt-dlp',
|
||||||
|
spawn: (command, args) => {
|
||||||
|
spawnCalls.push({ command, args });
|
||||||
|
const proc = new FakeYtDlpProcess();
|
||||||
|
spawnedProcesses.push(proc);
|
||||||
|
return proc;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||||
|
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
|
||||||
|
assert.equal(typeof outputTemplate, 'string');
|
||||||
|
const outputDir = path.dirname(outputTemplate!);
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'media.mkv'), 'cached media');
|
||||||
|
|
||||||
|
cache.cleanup();
|
||||||
|
|
||||||
|
assert.equal(spawnedProcesses[0]?.killed, true);
|
||||||
|
assert.equal(fs.existsSync(outputDir), false);
|
||||||
|
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { spawn as spawnProcess } from 'node:child_process';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import type { YoutubeMediaCacheMode } from '../../../types/integrations';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
|
|
||||||
|
type MediaCacheSessionState = 'running' | 'ready' | 'failed';
|
||||||
|
|
||||||
|
type SpawnedProcess = EventEmitter & {
|
||||||
|
killed?: boolean;
|
||||||
|
kill?: (signal?: NodeJS.Signals | number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpawnProcess = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: { stdio?: Array<'ignore' | 'pipe'> },
|
||||||
|
) => SpawnedProcess;
|
||||||
|
|
||||||
|
interface MediaCacheSession {
|
||||||
|
url: string;
|
||||||
|
dir: string;
|
||||||
|
process: SpawnedProcess | null;
|
||||||
|
readyPath: string | null;
|
||||||
|
state: MediaCacheSessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubeMediaCacheStartOptions {
|
||||||
|
mode: YoutubeMediaCacheMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubeMediaCacheServiceDeps {
|
||||||
|
cacheRoot?: string;
|
||||||
|
getYtDlpCommand?: () => string;
|
||||||
|
spawn?: SpawnProcess;
|
||||||
|
logInfo?: (message: string) => void;
|
||||||
|
logWarn?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']);
|
||||||
|
|
||||||
|
function cacheKeyForUrl(url: string): string {
|
||||||
|
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinalMediaFile(fileName: string): boolean {
|
||||||
|
if (!fileName.startsWith('media.')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (fileName.endsWith('.part') || fileName.endsWith('.ytdl') || fileName.endsWith('.tmp')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return MEDIA_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function findReadyMediaPath(dir: string): string | null {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
const mediaFile = files.find(isFinalMediaFile);
|
||||||
|
return mediaFile ? path.join(dir, mediaFile) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createYtDlpArgs(url: string, outputTemplate: string): string[] {
|
||||||
|
return [
|
||||||
|
'--no-playlist',
|
||||||
|
'--no-warnings',
|
||||||
|
'-f',
|
||||||
|
'bestvideo*+bestaudio/best',
|
||||||
|
'--merge-output-format',
|
||||||
|
'mkv',
|
||||||
|
'-o',
|
||||||
|
outputTemplate,
|
||||||
|
url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDeps = {}) {
|
||||||
|
const cacheRoot = deps.cacheRoot ?? path.join(os.tmpdir(), 'subminer-youtube-media-cache');
|
||||||
|
const getYtDlpCommand = deps.getYtDlpCommand ?? getYoutubeYtDlpCommand;
|
||||||
|
const spawn: SpawnProcess =
|
||||||
|
deps.spawn ??
|
||||||
|
((command, args, options) =>
|
||||||
|
spawnProcess(command, args, options ?? {}) as unknown as SpawnedProcess);
|
||||||
|
const sessions = new Map<string, MediaCacheSession>();
|
||||||
|
let activeKey: string | null = null;
|
||||||
|
|
||||||
|
const getSessionDir = (url: string): string => path.join(cacheRoot, cacheKeyForUrl(url));
|
||||||
|
const removeSession = (key: string): void => {
|
||||||
|
const session = sessions.get(key);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.state === 'running' && session.process?.kill && !session.process.killed) {
|
||||||
|
session.process.kill();
|
||||||
|
}
|
||||||
|
sessions.delete(key);
|
||||||
|
if (activeKey === key) {
|
||||||
|
activeKey = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(session.dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Temp cache cleanup should not block shutdown or playback startup.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeInactiveSessions = (keyToKeep: string): void => {
|
||||||
|
for (const key of [...sessions.keys()]) {
|
||||||
|
if (key !== keyToKeep) {
|
||||||
|
removeSession(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCachedMediaPath = async (url: string): Promise<string | null> => {
|
||||||
|
const key = cacheKeyForUrl(url);
|
||||||
|
const session = sessions.get(key);
|
||||||
|
if (session?.readyPath && fs.existsSync(session.readyPath)) {
|
||||||
|
return session.readyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyPath = findReadyMediaPath(session?.dir ?? getSessionDir(url));
|
||||||
|
if (readyPath) {
|
||||||
|
sessions.set(key, {
|
||||||
|
url,
|
||||||
|
dir: path.dirname(readyPath),
|
||||||
|
process: null,
|
||||||
|
readyPath,
|
||||||
|
state: 'ready',
|
||||||
|
});
|
||||||
|
return readyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveCachedMediaPath = async (): Promise<string | null> => {
|
||||||
|
if (!activeKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const session = sessions.get(activeKey);
|
||||||
|
return session ? getCachedMediaPath(session.url) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = (url: string, options: YoutubeMediaCacheStartOptions): void => {
|
||||||
|
if (options.mode !== 'background') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = cacheKeyForUrl(url);
|
||||||
|
activeKey = key;
|
||||||
|
const existingSession = sessions.get(key);
|
||||||
|
if (existingSession?.state === 'running') {
|
||||||
|
removeInactiveSessions(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingSession) {
|
||||||
|
if (
|
||||||
|
existingSession.state === 'ready' &&
|
||||||
|
((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) ||
|
||||||
|
findReadyMediaPath(existingSession.dir))
|
||||||
|
) {
|
||||||
|
removeInactiveSessions(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeSession(key);
|
||||||
|
activeKey = key;
|
||||||
|
}
|
||||||
|
removeInactiveSessions(key);
|
||||||
|
|
||||||
|
const dir = getSessionDir(url);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const outputTemplate = path.join(dir, 'media.%(ext)s');
|
||||||
|
const args = createYtDlpArgs(url, outputTemplate);
|
||||||
|
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||||
|
const session: MediaCacheSession = {
|
||||||
|
url,
|
||||||
|
dir,
|
||||||
|
process: child,
|
||||||
|
readyPath: null,
|
||||||
|
state: 'running',
|
||||||
|
};
|
||||||
|
sessions.set(key, session);
|
||||||
|
deps.logInfo?.(`Started YouTube media cache download for ${url}`);
|
||||||
|
|
||||||
|
child.once('error', (error) => {
|
||||||
|
const currentSession = sessions.get(key);
|
||||||
|
if (currentSession !== session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.state = 'failed';
|
||||||
|
session.process = null;
|
||||||
|
deps.logWarn?.(
|
||||||
|
`YouTube media cache download failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.once('close', (code) => {
|
||||||
|
const currentSession = sessions.get(key);
|
||||||
|
if (currentSession !== session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.process = null;
|
||||||
|
if (code === 0) {
|
||||||
|
const readyPath = findReadyMediaPath(dir);
|
||||||
|
if (readyPath) {
|
||||||
|
session.state = 'ready';
|
||||||
|
session.readyPath = readyPath;
|
||||||
|
deps.logInfo?.(`YouTube media cache ready at ${readyPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.state = 'failed';
|
||||||
|
deps.logWarn?.(`YouTube media cache download exited without a usable media file.`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
for (const key of [...sessions.keys()]) {
|
||||||
|
removeSession(key);
|
||||||
|
}
|
||||||
|
activeKey = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
getActiveCachedMediaPath,
|
||||||
|
getCachedMediaPath,
|
||||||
|
start,
|
||||||
|
};
|
||||||
|
}
|
||||||
+31
@@ -332,6 +332,7 @@ import {
|
|||||||
acquireYoutubeSubtitleTrack,
|
acquireYoutubeSubtitleTrack,
|
||||||
acquireYoutubeSubtitleTracks,
|
acquireYoutubeSubtitleTracks,
|
||||||
} from './core/services/youtube/generate';
|
} from './core/services/youtube/generate';
|
||||||
|
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
||||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
import {
|
import {
|
||||||
@@ -1172,6 +1173,10 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
|||||||
},
|
},
|
||||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||||
});
|
});
|
||||||
|
const youtubeMediaCache = createYoutubeMediaCacheService({
|
||||||
|
logInfo: (message) => logger.info(message),
|
||||||
|
logWarn: (message) => logger.warn(message),
|
||||||
|
});
|
||||||
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
|
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
now: () => Date.now(),
|
now: () => Date.now(),
|
||||||
@@ -1316,6 +1321,11 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
|||||||
},
|
},
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||||
|
startYoutubeMediaCache: (url) => {
|
||||||
|
youtubeMediaCache.start(url, {
|
||||||
|
mode: getResolvedConfig().youtube.mediaCache.mode,
|
||||||
|
});
|
||||||
|
},
|
||||||
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
|
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
@@ -1635,6 +1645,22 @@ function isYoutubePlaybackActiveNow(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getCachedYoutubeMediaPathForCurrentPlayback(
|
||||||
|
currentVideoPath: string,
|
||||||
|
_kind: 'audio' | 'video',
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (getResolvedConfig().youtube.mediaCache.mode !== 'background') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isYoutubePlaybackActiveNow()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(await youtubeMediaCache.getCachedMediaPath(currentVideoPath)) ??
|
||||||
|
(await youtubeMediaCache.getActiveCachedMediaPath())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function reportYoutubeSubtitleFailure(message: string): void {
|
function reportYoutubeSubtitleFailure(message: string): void {
|
||||||
const type = getConfiguredStatusNotificationType();
|
const type = getConfiguredStatusNotificationType();
|
||||||
if (type === 'none') {
|
if (type === 'none') {
|
||||||
@@ -3801,6 +3827,7 @@ const {
|
|||||||
},
|
},
|
||||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||||
|
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
||||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||||
stopDiscordPresenceService: () => {
|
stopDiscordPresenceService: () => {
|
||||||
void appState.discordPresenceService?.stop();
|
void appState.discordPresenceService?.stop();
|
||||||
@@ -5731,6 +5758,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
|
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||||
|
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
showOverlayNotification,
|
showOverlayNotification,
|
||||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
@@ -6207,6 +6236,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
showOverlayNotification,
|
showOverlayNotification,
|
||||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
|
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||||
|
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||||
shouldStartAnkiIntegration: () =>
|
shouldStartAnkiIntegration: () =>
|
||||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
|||||||
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
|
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
|
||||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
||||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
||||||
|
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
|
||||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||||
@@ -317,6 +318,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
|||||||
getAnkiIntegration: params.getAnkiIntegration,
|
getAnkiIntegration: params.getAnkiIntegration,
|
||||||
setAnkiIntegration: params.setAnkiIntegration,
|
setAnkiIntegration: params.setAnkiIntegration,
|
||||||
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||||
|
...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}),
|
||||||
showDesktopNotification: params.showDesktopNotification,
|
showDesktopNotification: params.showDesktopNotification,
|
||||||
showOverlayNotification: params.showOverlayNotification,
|
showOverlayNotification: params.showOverlayNotification,
|
||||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||||
|
|||||||
@@ -41,18 +41,20 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
|
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
assert.equal(calls.length, 32);
|
assert.equal(calls.length, 33);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||||
|
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
|||||||
throw new Error('stop failed');
|
throw new Error('stop failed');
|
||||||
},
|
},
|
||||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
|
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
clearYomitanSettingsWindow: () => void;
|
clearYomitanSettingsWindow: () => void;
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||||
|
cleanupYoutubeMediaCache: () => void;
|
||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -67,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.cleanupJellyfinSubtitleCache();
|
deps.cleanupJellyfinSubtitleCache();
|
||||||
}
|
}
|
||||||
deps.cleanupYoutubeSubtitleTempDirs();
|
deps.cleanupYoutubeSubtitleTempDirs();
|
||||||
|
deps.cleanupYoutubeMediaCache();
|
||||||
deps.stopDiscordPresenceService();
|
deps.stopDiscordPresenceService();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
|
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
|
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
@@ -92,6 +93,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||||
|
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
assert.ok(calls.includes('stop-discord-presence'));
|
assert.ok(calls.includes('stop-discord-presence'));
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||||
@@ -147,6 +149,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
|||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
|
cleanupYoutubeMediaCache: () => {},
|
||||||
cleanupJellyfinSubtitleCache: () => {},
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
});
|
});
|
||||||
@@ -197,6 +200,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
|||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
|
cleanupYoutubeMediaCache: () => {},
|
||||||
cleanupJellyfinSubtitleCache: () => {},
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
|
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||||
|
cleanupYoutubeMediaCache: () => void;
|
||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -142,6 +143,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||||
|
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
||||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
|||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: async () => {},
|
stopJellyfinRemoteSession: async () => {},
|
||||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
|
cleanupYoutubeMediaCache: () => {},
|
||||||
cleanupJellyfinSubtitleCache: () => {},
|
cleanupJellyfinSubtitleCache: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
bindOverlayOwner?: () => void;
|
bindOverlayOwner?: () => void;
|
||||||
releaseOverlayOwner?: () => void;
|
releaseOverlayOwner?: () => void;
|
||||||
@@ -77,6 +78,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
showOverlayNotification: deps.showOverlayNotification,
|
showOverlayNotification: deps.showOverlayNotification,
|
||||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||||
|
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||||
bindOverlayOwner: deps.bindOverlayOwner,
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ type OverlayRuntimeOptions = {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: 'audio' | 'video',
|
||||||
|
) => Promise<string | null>;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
bindOverlayOwner?: () => void;
|
bindOverlayOwner?: () => void;
|
||||||
releaseOverlayOwner?: () => void;
|
releaseOverlayOwner?: () => void;
|
||||||
@@ -71,6 +75,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
getCachedMediaPath?: (
|
||||||
|
currentVideoPath: string,
|
||||||
|
kind: 'audio' | 'video',
|
||||||
|
) => Promise<string | null>;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
bindOverlayOwner?: () => void;
|
bindOverlayOwner?: () => void;
|
||||||
releaseOverlayOwner?: () => void;
|
releaseOverlayOwner?: () => void;
|
||||||
@@ -97,6 +105,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
showOverlayNotification: deps.showOverlayNotification,
|
showOverlayNotification: deps.showOverlayNotification,
|
||||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||||
|
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||||
bindOverlayOwner: deps.bindOverlayOwner,
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
|
|||||||
@@ -146,3 +146,73 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
|||||||
|
|
||||||
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let resolveCache: (() => void) | undefined;
|
||||||
|
const cachePromise = new Promise<void>((resolve) => {
|
||||||
|
resolveCache = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = createYoutubePlaybackRuntime({
|
||||||
|
platform: 'linux',
|
||||||
|
directPlaybackFormat: 'best',
|
||||||
|
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||||
|
autoLaunchTimeoutMs: 2_000,
|
||||||
|
connectTimeoutMs: 1_000,
|
||||||
|
getSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
getMpvConnected: () => true,
|
||||||
|
invalidatePendingAutoplayReadyFallbacks: () => {
|
||||||
|
calls.push('invalidate-autoplay');
|
||||||
|
},
|
||||||
|
setAppOwnedFlowInFlight: (next) => {
|
||||||
|
calls.push(`app-owned:${next}`);
|
||||||
|
},
|
||||||
|
ensureYoutubePlaybackRuntimeReady: async () => {
|
||||||
|
calls.push('ensure-runtime-ready');
|
||||||
|
},
|
||||||
|
resolveYoutubePlaybackUrl: async () => {
|
||||||
|
throw new Error('linux path should not resolve direct playback url');
|
||||||
|
},
|
||||||
|
launchWindowsMpv: async () => ({ ok: false }),
|
||||||
|
waitForYoutubeMpvConnected: async () => true,
|
||||||
|
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||||
|
calls.push(`prepare:${url}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
startYoutubeMediaCache: async (url) => {
|
||||||
|
calls.push(`cache:${url}`);
|
||||||
|
await cachePromise;
|
||||||
|
calls.push('cache-done');
|
||||||
|
},
|
||||||
|
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||||
|
calls.push(`run-flow:${url}:${mode}`);
|
||||||
|
},
|
||||||
|
logInfo: (message) => {
|
||||||
|
calls.push(`info:${message}`);
|
||||||
|
},
|
||||||
|
logWarn: (message) => {
|
||||||
|
calls.push(`warn:${message}`);
|
||||||
|
},
|
||||||
|
schedule: () => 1 as never,
|
||||||
|
clearScheduled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({
|
||||||
|
url: 'https://youtu.be/demo',
|
||||||
|
mode: 'download',
|
||||||
|
source: 'second-instance',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
calls.indexOf('prepare:https://youtu.be/demo') < calls.indexOf('cache:https://youtu.be/demo'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
calls.indexOf('cache:https://youtu.be/demo') <
|
||||||
|
calls.indexOf('run-flow:https://youtu.be/demo:download'),
|
||||||
|
);
|
||||||
|
assert.equal(calls.includes('cache-done'), false);
|
||||||
|
const resolveCacheNow = resolveCache;
|
||||||
|
assert.ok(resolveCacheNow);
|
||||||
|
resolveCacheNow();
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
|||||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
||||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||||
|
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
||||||
runYoutubePlaybackFlow: (request: {
|
runYoutubePlaybackFlow: (request: {
|
||||||
url: string;
|
url: string;
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||||
@@ -126,6 +127,15 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
if (!mediaReady) {
|
if (!mediaReady) {
|
||||||
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||||
}
|
}
|
||||||
|
if (deps.startYoutubeMediaCache) {
|
||||||
|
void Promise.resolve(deps.startYoutubeMediaCache(request.url)).catch((error) => {
|
||||||
|
deps.logWarn(
|
||||||
|
`Failed to start YouTube media cache: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await deps.runYoutubePlaybackFlow({
|
await deps.runYoutubePlaybackFlow({
|
||||||
url: request.url,
|
url: request.url,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async function withStubbedFfmpeg(
|
|||||||
" console.log(' V..... libaom-av1');",
|
" console.log(' V..... libaom-av1');",
|
||||||
' process.exit(0);',
|
' process.exit(0);',
|
||||||
'}',
|
'}',
|
||||||
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');",
|
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');",
|
||||||
'const outputPath = args.at(-1);',
|
'const outputPath = args.at(-1);',
|
||||||
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
|
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
@@ -61,7 +61,7 @@ async function withStubbedFfmpeg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readFfmpegArgs(argsPath: string): string[] {
|
function readFfmpegArgs(argsPath: string): string[] {
|
||||||
return fs.readFileSync(argsPath, 'utf8').trim().split('\n');
|
return JSON.parse(fs.readFileSync(argsPath, 'utf8')) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => {
|
test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => {
|
||||||
@@ -182,3 +182,64 @@ test('generateAudio recreates missing temp directory before invoking ffmpeg', as
|
|||||||
assert.equal(fs.existsSync(path.dirname(outputPath!)), true);
|
assert.equal(fs.existsSync(path.dirname(outputPath!)), true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generateAudio adds remote input options before the ffmpeg input', async () => {
|
||||||
|
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||||
|
await generator.generateAudio(
|
||||||
|
{
|
||||||
|
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
|
||||||
|
inputOptions: {
|
||||||
|
reconnect: true,
|
||||||
|
userAgent: 'Mozilla/5.0',
|
||||||
|
headers: {
|
||||||
|
Referer: 'https://www.youtube.com/',
|
||||||
|
Origin: 'https://www.youtube.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
|
||||||
|
const args = readFfmpegArgs(argsPath);
|
||||||
|
const inputIndex = args.indexOf('-i');
|
||||||
|
assert.ok(inputIndex > 0);
|
||||||
|
assert.ok(args.indexOf('-reconnect') > -1);
|
||||||
|
assert.ok(args.indexOf('-reconnect') < inputIndex);
|
||||||
|
assert.equal(args[args.indexOf('-reconnect') + 1], '1');
|
||||||
|
assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1');
|
||||||
|
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
|
||||||
|
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
|
||||||
|
assert.equal(
|
||||||
|
args[args.indexOf('-headers') + 1],
|
||||||
|
'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateAudio skips stale audio stream maps for single resolved streams', async () => {
|
||||||
|
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||||
|
await generator.generateAudio(
|
||||||
|
{
|
||||||
|
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
|
||||||
|
singleResolvedStream: true,
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
12,
|
||||||
|
0,
|
||||||
|
22,
|
||||||
|
);
|
||||||
|
|
||||||
|
const args = readFfmpegArgs(argsPath);
|
||||||
|
assert.equal(args.includes('-map'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateAudio keeps explicit audio stream maps for normal media paths', async () => {
|
||||||
|
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||||
|
await generator.generateAudio('/video.mp4', 10, 12, 0, 2);
|
||||||
|
|
||||||
|
const args = readFfmpegArgs(argsPath);
|
||||||
|
assert.equal(args[args.indexOf('-map') + 1], '0:2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+30
-6
@@ -21,9 +21,12 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
|
import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||||
|
|
||||||
const log = createLogger('media');
|
const log = createLogger('media');
|
||||||
|
|
||||||
|
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||||
|
|
||||||
function normalizeAnimatedImageFps(fps: number | undefined): number {
|
function normalizeAnimatedImageFps(fps: number | undefined): number {
|
||||||
const fallbackFps = 10;
|
const fallbackFps = 10;
|
||||||
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
|
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
|
||||||
@@ -181,7 +184,7 @@ export class MediaGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateAudio(
|
async generateAudio(
|
||||||
videoPath: string,
|
videoPath: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
padding: number = 0,
|
padding: number = 0,
|
||||||
@@ -190,12 +193,22 @@ export class MediaGenerator {
|
|||||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||||
const start = Math.max(0, startTime - safePadding);
|
const start = Math.max(0, startTime - safePadding);
|
||||||
const duration = endTime - start + safePadding;
|
const duration = endTime - start + safePadding;
|
||||||
|
const mediaInput = normalizeMediaInput(videoPath);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
||||||
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
|
const args: string[] = [
|
||||||
|
'-ss',
|
||||||
|
start.toString(),
|
||||||
|
'-t',
|
||||||
|
duration.toString(),
|
||||||
|
...mediaInput.inputArgs,
|
||||||
|
'-i',
|
||||||
|
mediaInput.path,
|
||||||
|
];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!mediaInput.singleResolvedStream &&
|
||||||
typeof audioStreamIndex === 'number' &&
|
typeof audioStreamIndex === 'number' &&
|
||||||
Number.isInteger(audioStreamIndex) &&
|
Number.isInteger(audioStreamIndex) &&
|
||||||
audioStreamIndex >= 0
|
audioStreamIndex >= 0
|
||||||
@@ -223,7 +236,7 @@ export class MediaGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateScreenshot(
|
async generateScreenshot(
|
||||||
videoPath: string,
|
videoPath: MediaInput,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
options: {
|
options: {
|
||||||
format: 'jpg' | 'png' | 'webp';
|
format: 'jpg' | 'png' | 'webp';
|
||||||
@@ -239,8 +252,17 @@ export class MediaGenerator {
|
|||||||
png: 'png',
|
png: 'png',
|
||||||
webp: 'webp',
|
webp: 'webp',
|
||||||
};
|
};
|
||||||
|
const mediaInput = normalizeMediaInput(videoPath);
|
||||||
|
|
||||||
const args: string[] = ['-ss', timestamp.toString(), '-i', videoPath, '-vframes', '1'];
|
const args: string[] = [
|
||||||
|
'-ss',
|
||||||
|
timestamp.toString(),
|
||||||
|
...mediaInput.inputArgs,
|
||||||
|
'-i',
|
||||||
|
mediaInput.path,
|
||||||
|
'-vframes',
|
||||||
|
'1',
|
||||||
|
];
|
||||||
|
|
||||||
const vfParts: string[] = [];
|
const vfParts: string[] = [];
|
||||||
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
|
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
|
||||||
@@ -334,7 +356,7 @@ export class MediaGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateAnimatedImage(
|
async generateAnimatedImage(
|
||||||
videoPath: string,
|
videoPath: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
padding: number = 0,
|
padding: number = 0,
|
||||||
@@ -364,6 +386,7 @@ export class MediaGenerator {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = this.createTempOutputPath('animation', 'avif');
|
const outputPath = this.createTempOutputPath('animation', 'avif');
|
||||||
|
const mediaInput = normalizeMediaInput(videoPath);
|
||||||
|
|
||||||
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||||
if (av1Encoder === 'libaom-av1') {
|
if (av1Encoder === 'libaom-av1') {
|
||||||
@@ -382,8 +405,9 @@ export class MediaGenerator {
|
|||||||
start.toString(),
|
start.toString(),
|
||||||
'-t',
|
'-t',
|
||||||
duration.toString(),
|
duration.toString(),
|
||||||
|
...mediaInput.inputArgs,
|
||||||
'-i',
|
'-i',
|
||||||
videoPath,
|
mediaInput.path,
|
||||||
'-vf',
|
'-vf',
|
||||||
buildAnimatedImageVideoFilter({
|
buildAnimatedImageVideoFilter({
|
||||||
fps: clampedFps,
|
fps: clampedFps,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
export interface MediaInputOptions {
|
||||||
|
reconnect?: boolean;
|
||||||
|
userAgent?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaInput =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
path: string;
|
||||||
|
inputOptions?: MediaInputOptions;
|
||||||
|
singleResolvedStream?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizedMediaInput = {
|
||||||
|
path: string;
|
||||||
|
inputArgs: string[];
|
||||||
|
singleResolvedStream: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCKED_FFMPEG_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||||
|
|
||||||
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderName(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (BLOCKED_FFMPEG_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeFfmpegHeaders(headers: Record<string, string> | undefined): string | null {
|
||||||
|
if (!headers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [rawName, rawValue] of Object.entries(headers)) {
|
||||||
|
const name = normalizeHeaderName(rawName);
|
||||||
|
const value = trimToNonEmptyString(rawValue);
|
||||||
|
if (!name || !value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(`${name}: ${value.replace(/[\r\n]+/g, ' ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.length > 0 ? `${lines.join('\r\n')}\r\n` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMediaInput(input: MediaInput): NormalizedMediaInput {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return { path: input, inputArgs: [], singleResolvedStream: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputArgs: string[] = [];
|
||||||
|
if (input.inputOptions?.reconnect) {
|
||||||
|
inputArgs.push('-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = trimToNonEmptyString(input.inputOptions?.userAgent);
|
||||||
|
if (userAgent) {
|
||||||
|
inputArgs.push('-user_agent', userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = serializeFfmpegHeaders(input.inputOptions?.headers);
|
||||||
|
if (headers) {
|
||||||
|
inputArgs.push('-headers', headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: input.path,
|
||||||
|
inputArgs,
|
||||||
|
singleResolvedStream: input.singleResolvedStream === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -93,3 +93,32 @@ test('select controls show config-only current values without offering them othe
|
|||||||
restoreDocument();
|
restoreDocument();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('select controls use configured option labels', () => {
|
||||||
|
const restoreDocument = installDocumentStub();
|
||||||
|
try {
|
||||||
|
const field = {
|
||||||
|
...createSelectField(),
|
||||||
|
enumValues: ['direct', 'background'],
|
||||||
|
enumLabels: {
|
||||||
|
direct: 'Direct stream extraction',
|
||||||
|
background: 'Background media cache',
|
||||||
|
},
|
||||||
|
} as ConfigSettingsField & { enumLabels: Record<string, string> };
|
||||||
|
|
||||||
|
const control = renderControl(field, {
|
||||||
|
valueForField: () => 'direct',
|
||||||
|
valueForPath: () => undefined,
|
||||||
|
updateDraft: () => {},
|
||||||
|
resetDraftPath: () => {},
|
||||||
|
setFieldError: () => {},
|
||||||
|
}) as HTMLSelectElement;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(control.options).map((option) => option.textContent),
|
||||||
|
['Direct stream extraction', 'Background media cache'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restoreDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export function renderControl(
|
|||||||
for (const enumValue of enumValues) {
|
for (const enumValue of enumValues) {
|
||||||
const option = createElement('option') as HTMLOptionElement;
|
const option = createElement('option') as HTMLOptionElement;
|
||||||
option.value = enumValue;
|
option.value = enumValue;
|
||||||
option.textContent = enumValue;
|
option.textContent = field.enumLabels?.[enumValue] ?? enumValue;
|
||||||
option.selected = enumValue === value;
|
option.selected = enumValue === value;
|
||||||
select.append(option);
|
select.append(option);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
StatsConfig,
|
StatsConfig,
|
||||||
YomitanConfig,
|
YomitanConfig,
|
||||||
YoutubeConfig,
|
YoutubeConfig,
|
||||||
|
YoutubeMediaCacheMode,
|
||||||
YoutubeSubgenConfig,
|
YoutubeSubgenConfig,
|
||||||
} from './integrations';
|
} from './integrations';
|
||||||
import type {
|
import type {
|
||||||
@@ -345,6 +346,9 @@ export interface ResolvedConfig {
|
|||||||
};
|
};
|
||||||
youtube: YoutubeConfig & {
|
youtube: YoutubeConfig & {
|
||||||
primarySubLanguages: string[];
|
primarySubLanguages: string[];
|
||||||
|
mediaCache: {
|
||||||
|
mode: YoutubeMediaCacheMode;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
youtubeSubgen: YoutubeSubgenConfig & {
|
youtubeSubgen: YoutubeSubgenConfig & {
|
||||||
whisperBin: string;
|
whisperBin: string;
|
||||||
|
|||||||
@@ -122,8 +122,15 @@ export interface AiConfig {
|
|||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type YoutubeMediaCacheMode = 'direct' | 'background';
|
||||||
|
|
||||||
|
export interface YoutubeMediaCacheConfig {
|
||||||
|
mode?: YoutubeMediaCacheMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface YoutubeConfig {
|
export interface YoutubeConfig {
|
||||||
primarySubLanguages?: string[];
|
primarySubLanguages?: string[];
|
||||||
|
mediaCache?: YoutubeMediaCacheConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YoutubeSubgenConfig {
|
export interface YoutubeSubgenConfig {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface ConfigSettingsField {
|
|||||||
control: ConfigSettingsControl;
|
control: ConfigSettingsControl;
|
||||||
defaultValue: unknown;
|
defaultValue: unknown;
|
||||||
enumValues?: readonly string[];
|
enumValues?: readonly string[];
|
||||||
|
enumLabels?: Record<string, string>;
|
||||||
restartBehavior: ConfigSettingsRestartBehavior;
|
restartBehavior: ConfigSettingsRestartBehavior;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
secret?: boolean;
|
secret?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user