mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0008b55b70
|
|||
|
d16ae9c745
|
|||
| 36f94151b8 | |||
| 5326ad32f5 |
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
### Added
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
### Fixed
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps, including cached YouTube files.
|
||||
- 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; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default.
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
**Added**
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
**Fixed**
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
|
||||
**Changed**
|
||||
|
||||
@@ -188,6 +188,9 @@ Control the minimum log level for runtime output:
|
||||
| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
|
||||
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
|
||||
|
||||
Log filenames use the local calendar date, for example `app-YYYY-MM-DD.log`, `launcher-YYYY-MM-DD.log`, and `mpv-YYYY-MM-DD.log`.
|
||||
Log export creates a sanitized copy of those files; it does not rewrite the original log files on disk.
|
||||
|
||||
### Updates
|
||||
|
||||
Configure automatic update checks and update notifications:
|
||||
@@ -1535,7 +1538,9 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
||||
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
|
||||
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download. Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready.
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
|
||||
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
|
||||
|
||||
Current launcher behavior:
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ subminer stats -b # start background stats daemon
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer logs -e` | Export a sanitized log ZIP and print its path |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
| `subminer mpv status` | Check mpv socket readiness |
|
||||
|
||||
+2
-2
@@ -148,7 +148,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
|
||||
SubMiner.AppImage --help # Show all options
|
||||
```
|
||||
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete.
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
||||
|
||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||
|
||||
@@ -191,7 +191,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists.
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||
- `subminer config`: config file helpers (`path`, `show`).
|
||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
|
||||
@@ -88,6 +88,12 @@ SubMiner handles several YouTube subtitle formats transparently:
|
||||
|
||||
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
|
||||
|
||||
## Card Media Cache
|
||||
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
|
||||
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Primary Subtitle Languages
|
||||
@@ -131,6 +137,7 @@ Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
|
||||
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
|
||||
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
|
||||
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
|
||||
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
|
||||
- **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video.
|
||||
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
|
||||
|
||||
@@ -2,9 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
||||
import { localDateKey } from '../src/shared/log-files.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
@@ -20,7 +21,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
});
|
||||
|
||||
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultLauncherLogFile({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -1,60 +1,24 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.17.0 -->
|
||||
<!-- prerelease-base-version: 0.17.1 -->
|
||||
|
||||
## Highlights
|
||||
### Changed
|
||||
### Added
|
||||
|
||||
- **Subtitle Delay Shortcuts:** Overlay subtitle delay controls now match mpv's native defaults.
|
||||
- `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show the current delay on the OSD.
|
||||
- The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||
|
||||
- **Update Notifications:** New installs now default to overlay-only update notifications instead of overlay plus system notifications.
|
||||
- **YouTube Media Cache Mode**: A new `youtube.mediaCache.mode` setting (`direct` or `background`) lets you choose how SubMiner extracts audio and image from YouTube cards.
|
||||
- In background mode, SubMiner creates a text-only card immediately, downloads a yt-dlp media cache (capped at 720p by default), and fills audio and image fields once the file is ready — with overlay and OSD notifications when the download starts and when media is available.
|
||||
- If a background download fails, SubMiner now notifies you and clears any pending media updates rather than leaving cards silently incomplete.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki Card Enrichment:** Fixed two issues where card fields were not populated correctly after mining.
|
||||
- Highlight Word now bolds the mined word in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence has no existing bold markup.
|
||||
- Lapis and Kiku word cards enriched through SubMiner now include the word-and-sentence marker, restoring sentence context on the card front.
|
||||
- **Log Export**: Log filenames now use your local date, so exporting logs near midnight no longer pulls stale files from the previous UTC day. Export redaction has also been expanded to mask a broader range of sensitive data, including IP addresses, email addresses, authentication and cookie headers, yt-dlp cookie arguments, URL credentials, and signed YouTube media URLs.
|
||||
|
||||
- **Windows Overlay:** Fixed shaky hover and click behavior on the subtitle bar when a video attaches to an already-running SubMiner instance.
|
||||
|
||||
- **Windows Anki & Media:** Fixed two issues affecting Windows users running SubMiner in background-launch mode.
|
||||
- Known-word cache refreshes no longer fail when no deck is configured.
|
||||
- Audio and image clipping now works correctly by recreating missing FFmpeg temp directories before processing.
|
||||
|
||||
- **Windows Character Dictionary:** The character dictionary auto-sync now correctly falls back to mpv's current video path on Windows when app media state is not yet ready.
|
||||
|
||||
- **Linux Support Assets:** Linux updates now create and refresh both managed support assets: the launcher runtime plugin copy and the rofi theme.
|
||||
- First playback on a fresh Linux install auto-installs those bundled assets before mpv starts if either one is missing.
|
||||
- Asset refreshes leave unrelated SubMiner data directories untouched and stage plugin copies before replacing the live runtime plugin.
|
||||
|
||||
- **Linux Visible Overlay Startup:** Auto-paused visible overlay startup stays fully interactive during the first measurement gap.
|
||||
- Startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads refresh parsed cues before warm readiness resumes playback.
|
||||
|
||||
- **Playlist Transitions:** The visible overlay stays active while mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
|
||||
- **macOS Yomitan Popup Focus:** Yomitan popup focus is restored after card mining or popup reload.
|
||||
- Clicking transparent overlay space now closes the popup and returns passthrough to mpv without a hide/reappear cycle.
|
||||
|
||||
- **Stats AniList Search:** Manual AniList linking from the stats page now strips generated `Season N` suffixes before searching, so the base anime title is used.
|
||||
|
||||
- **Desktop Notifications:** System notifications now show the SubMiner app icon when no custom notification image is provided.
|
||||
|
||||
- **Release Notes:** GitHub release `What's Changed` and `New Contributors` attribution sections are preserved when CI regenerates release notes from committed changelog output.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal playback auto-installs those managed support assets if either one is missing.
|
||||
- **YouTube Card Media Reliability**: Direct stream extraction now uses safer ffmpeg options and skips stale or cached stream map entries to reduce failed media generation. Background cache downloads are hardened with IPv4 and extractor retry flags, stale cache files are cleaned up on startup and before new downloads, and in-flight background downloads are stopped automatically when switching back to direct mode.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
|
||||
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
|
||||
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
|
||||
- feat(youtube): add mediaCache mode and safer stream media extraction by @ksyasuda in #130
|
||||
- fix(logs): use local date for log filenames and expand export redaction by @ksyasuda in #131
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **YouTube Media Cache Mode:** New `youtube.mediaCache.mode` setting lets you choose between `direct` (the existing behavior) and `background` modes for extracting audio and images when creating cards from YouTube videos.
|
||||
- Background mode uses a separate yt-dlp download to cache the video file locally, which can rescue media extraction when direct stream URLs are unreliable or expire quickly.
|
||||
- While the cache downloads, SubMiner creates a text-only card immediately, then fills in audio and image once the file is ready — no need to re-mine the sentence.
|
||||
- Cache downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`) to keep file sizes reasonable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Log Export:** Log filenames now use your local date, so exporting logs around UTC midnight correctly captures today's file instead of yesterday's.
|
||||
- Log exports also now redact a broader set of sensitive data — IP addresses, email addresses, auth and cookie headers, yt-dlp cookie arguments, URL credentials, API keys, and signed YouTube media URLs.
|
||||
|
||||
- **YouTube Card Media Generation:** Extraction is more reliable and resilient across a wider range of stream conditions.
|
||||
- Stale or cached stream maps are skipped automatically, and ffmpeg requests use safer options for resolved streams to reduce failures.
|
||||
- Background cache downloads retry on network issues (IPv4 and extractor fallbacks), notify you on failure instead of silently hanging, and clean up stale cache files on startup and before each new download.
|
||||
- Switching back to direct mode now cancels any in-progress background downloads cleanly.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(youtube): add mediaCache mode and safer stream media extraction by @ksyasuda in #130
|
||||
- fix(logs): use local date for log filenames and expand export redaction by @ksyasuda in #131
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
+13
-3
@@ -69,7 +69,10 @@ import {
|
||||
} from './anki-integration/media-source';
|
||||
import type { PendingYoutubeMediaUpdate } from './anki-integration/pending-youtube-media';
|
||||
import { PendingYoutubeMediaQueue } from './anki-integration/pending-youtube-media-queue';
|
||||
import type { PendingYoutubeMediaQueueReadyOptions } from './anki-integration/pending-youtube-media-queue';
|
||||
import type {
|
||||
PendingYoutubeMediaQueueFailedOptions,
|
||||
PendingYoutubeMediaQueueReadyOptions,
|
||||
} from './anki-integration/pending-youtube-media-queue';
|
||||
|
||||
const log = createLogger('anki').child('integration');
|
||||
|
||||
@@ -950,6 +953,13 @@ export class AnkiIntegration {
|
||||
await this.pendingYoutubeMediaQueue.handleReady(sourceUrl, cachedPath, options);
|
||||
}
|
||||
|
||||
async handleYoutubeMediaCacheFailed(
|
||||
sourceUrl: string,
|
||||
options?: PendingYoutubeMediaQueueFailedOptions,
|
||||
): Promise<void> {
|
||||
await this.pendingYoutubeMediaQueue.handleFailed(sourceUrl, options);
|
||||
}
|
||||
|
||||
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
|
||||
const mpvClient = this.mpvClient;
|
||||
if (!mpvClient || !mpvClient.currentVideoPath) {
|
||||
@@ -1595,7 +1605,7 @@ export class AnkiIntegration {
|
||||
miscInfoValue?: string;
|
||||
} = {};
|
||||
|
||||
if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) {
|
||||
if (this.config.media?.generateAudio !== false && this.mpvClient?.currentVideoPath) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.generateAudio();
|
||||
@@ -1615,7 +1625,7 @@ export class AnkiIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
|
||||
if (this.config.media?.generateImage !== false && this.mpvClient?.currentVideoPath) {
|
||||
try {
|
||||
const animatedLeadInSeconds = noteInfo
|
||||
? await this.getAnimatedImageLeadInSeconds(noteInfo)
|
||||
|
||||
@@ -20,6 +20,14 @@ import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateAudio !== false;
|
||||
}
|
||||
|
||||
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateImage !== false;
|
||||
}
|
||||
|
||||
export interface CardCreationNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
@@ -268,15 +276,18 @@ export class CardCreationService {
|
||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||
);
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const videoPath = this.deps.getConfig().media?.generateImage
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
|
||||
if (this.deps.getConfig().media?.generateAudio) {
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
@@ -304,7 +315,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -464,7 +475,7 @@ export class CardCreationService {
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (shouldGenerateImage(this.deps.getConfig())) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -545,22 +556,23 @@ export class CardCreationService {
|
||||
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const videoPath = await resolveMediaGenerationInput(
|
||||
mpvClient,
|
||||
'video',
|
||||
mediaResolverOptions,
|
||||
);
|
||||
const audioSourcePath = await resolveMediaGenerationInput(
|
||||
mpvClient,
|
||||
'audio',
|
||||
mediaResolverOptions,
|
||||
);
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const missingRequestedMediaInput =
|
||||
(generateImage && !videoPath) || (generateAudio && !audioSourcePath);
|
||||
const shouldQueuePendingYoutubeMedia =
|
||||
!videoPath &&
|
||||
missingRequestedMediaInput &&
|
||||
this.deps.shouldRequireRemoteMediaCache?.() === true &&
|
||||
typeof this.deps.queuePendingYoutubeMediaUpdate === 'function';
|
||||
if (!videoPath && !shouldQueuePendingYoutubeMedia) {
|
||||
if (missingRequestedMediaInput && !shouldQueuePendingYoutubeMedia) {
|
||||
this.deps.showOsdNotification('No video loaded');
|
||||
return false;
|
||||
}
|
||||
@@ -570,13 +582,13 @@ export class CardCreationService {
|
||||
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio';
|
||||
const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
|
||||
const translationField = config.fields?.translation || 'SelectionText';
|
||||
let resolvedMiscInfoField: string | null = null;
|
||||
let resolvedSentenceAudioField: string = audioFieldName;
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
const ankiAiConfig = this.deps.getConfig().ai;
|
||||
const ankiAiConfig = config.ai;
|
||||
const ankiAiEnabled =
|
||||
typeof ankiAiConfig === 'object' && ankiAiConfig !== null
|
||||
? ankiAiConfig.enabled === true
|
||||
@@ -599,7 +611,7 @@ export class CardCreationService {
|
||||
|
||||
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
|
||||
fields.IsSentenceCard = 'x';
|
||||
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
||||
fields[getConfiguredWordFieldName(config)] = sentence;
|
||||
}
|
||||
|
||||
const pendingNoteInfo = this.createPendingNoteInfo(fields);
|
||||
@@ -608,7 +620,7 @@ export class CardCreationService {
|
||||
);
|
||||
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
|
||||
pendingNoteFields,
|
||||
this.deps.getConfig(),
|
||||
config,
|
||||
).trim();
|
||||
let duplicateNoteIds: number[] = [];
|
||||
if (
|
||||
@@ -626,7 +638,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
const deck = this.deps.getConfig().deck || 'Default';
|
||||
const deck = config.deck || 'Default';
|
||||
let noteId: number;
|
||||
try {
|
||||
noteId = await this.deps.client.addNote(
|
||||
@@ -672,7 +684,7 @@ export class CardCreationService {
|
||||
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
|
||||
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.miscInfo,
|
||||
config.fields?.miscInfo,
|
||||
);
|
||||
|
||||
const cardTypeFields: Record<string, string> = {};
|
||||
@@ -699,54 +711,58 @@ export class CardCreationService {
|
||||
endTime,
|
||||
label,
|
||||
audioFieldName: resolvedSentenceAudioField,
|
||||
imageFieldName: this.deps.getConfig().fields?.image,
|
||||
imageFieldName: config.fields?.image,
|
||||
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
||||
generateAudio: this.deps.getConfig().media?.generateAudio !== false,
|
||||
generateImage: this.deps.getConfig().media?.generateImage !== false,
|
||||
generateAudio,
|
||||
generateImage,
|
||||
});
|
||||
await this.deps.showNotification(noteId, label, 'media queued');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!videoPath) {
|
||||
if (missingRequestedMediaInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
|
||||
if (generateImage) {
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
||||
|
||||
const imageField = this.deps.getConfig().fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
const imageField = config.fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().fields?.miscInfo) {
|
||||
if (config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
|
||||
if (miscInfo && resolvedMiscInfoField) {
|
||||
mediaFields[resolvedMiscInfoField] = miscInfo;
|
||||
|
||||
@@ -234,9 +234,10 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
const generateAudio = config.media?.generateAudio !== false;
|
||||
const generateImage = config.media?.generateImage !== false;
|
||||
const mediaCacheQueued =
|
||||
(config.media?.generateAudio || config.media?.generateImage) &&
|
||||
this.deps.queuePendingYoutubeMediaUpdate
|
||||
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
|
||||
? await this.deps.queuePendingYoutubeMediaUpdate({
|
||||
noteId,
|
||||
noteInfo,
|
||||
@@ -245,7 +246,7 @@ export class NoteUpdateWorkflow {
|
||||
})
|
||||
: false;
|
||||
|
||||
if (!mediaCacheQueued && config.media?.generateAudio) {
|
||||
if (!mediaCacheQueued && generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.deps.generateAudioFilename();
|
||||
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
||||
@@ -270,7 +271,7 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaCacheQueued && config.media?.generateImage) {
|
||||
if (!mediaCacheQueued && generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.deps.generateImageFilename();
|
||||
|
||||
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
import { PendingYoutubeMediaQueue, type PendingYoutubeMediaQueueDeps } from './pending-youtube-media-queue';
|
||||
import {
|
||||
PendingYoutubeMediaQueue,
|
||||
type PendingYoutubeMediaQueueDeps,
|
||||
} from './pending-youtube-media-queue';
|
||||
|
||||
function createDeps(
|
||||
overrides: Partial<PendingYoutubeMediaQueueDeps> = {},
|
||||
@@ -70,3 +73,121 @@ test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate gene
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('PendingYoutubeMediaQueue drains matching queued jobs when the cache download fails', async () => {
|
||||
const statusMessages: string[] = [];
|
||||
const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = [];
|
||||
const updatedNotes: number[] = [];
|
||||
const deps = createDeps({
|
||||
client: {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId) => {
|
||||
updatedNotes.push(noteId);
|
||||
},
|
||||
storeMediaFile: async () => {},
|
||||
},
|
||||
showStatusNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
showNotification: async (noteId, label, suffix) => {
|
||||
notifications.push({ noteId, label, suffix });
|
||||
},
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
queue.enqueue({
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
noteId: 42,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
label: 'queued',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
});
|
||||
|
||||
await queue.handleFailed('https://youtu.be/abc123');
|
||||
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.deepEqual(updatedNotes, []);
|
||||
assert.deepEqual(notifications, [{ noteId: 42, label: 'queued', suffix: 'media cache failed' }]);
|
||||
assert.equal(
|
||||
statusMessages.includes('YouTube media cache failed. Media was not added to 1 queued card.'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queuing from notes', async () => {
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
const deps = createDeps({
|
||||
client: {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updatedNotes.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
},
|
||||
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
const queued = await queue.queueFromNote({
|
||||
noteId: 42,
|
||||
noteInfo: { noteId: 42, fields: {} },
|
||||
label: 'demo',
|
||||
});
|
||||
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(queued, true);
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
|
||||
});
|
||||
|
||||
test('PendingYoutubeMediaQueue only announces a download once per source while jobs collect', () => {
|
||||
const statusMessages: string[] = [];
|
||||
const deps = createDeps({
|
||||
showStatusNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
queue.enqueue({
|
||||
sourceUrl: 'https://youtu.be/abc123',
|
||||
noteId: 1,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
label: 'first',
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
});
|
||||
queue.enqueue({
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
noteId: 2,
|
||||
startTime: 3,
|
||||
endTime: 4,
|
||||
label: 'second',
|
||||
generateAudio: false,
|
||||
generateImage: true,
|
||||
});
|
||||
|
||||
assert.equal(statusMessages.length, 1);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface PendingYoutubeMediaQueueReadyOptions {
|
||||
notifyNoQueued?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingYoutubeMediaQueueFailedOptions {
|
||||
notifyStatus?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingYoutubeMediaNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
@@ -65,6 +69,14 @@ function trimToNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateAudio !== false;
|
||||
}
|
||||
|
||||
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateImage !== false;
|
||||
}
|
||||
|
||||
export class PendingYoutubeMediaQueue {
|
||||
private updates: PendingYoutubeMediaUpdate[] = [];
|
||||
|
||||
@@ -74,11 +86,16 @@ export class PendingYoutubeMediaQueue {
|
||||
if (!job.generateAudio && !job.generateImage) {
|
||||
return;
|
||||
}
|
||||
const isFirstQueuedForSource = !this.updates.some((existing) =>
|
||||
youtubeMediaUrlsMatch(existing.sourceUrl, job.sourceUrl),
|
||||
);
|
||||
this.updates.push(job);
|
||||
this.deps.logInfo('Queued YouTube media update for note:', job.noteId);
|
||||
this.deps.showStatusNotification(
|
||||
'YouTube media cache is still downloading. Card media will be added when the cache is ready.',
|
||||
);
|
||||
if (isFirstQueuedForSource) {
|
||||
this.deps.showStatusNotification(
|
||||
'YouTube media cache is still downloading. Card media will be added when the cache is ready.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async queueFromNote(job: {
|
||||
@@ -124,8 +141,8 @@ export class PendingYoutubeMediaQueue {
|
||||
) ?? undefined,
|
||||
miscInfoFieldName:
|
||||
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
|
||||
generateAudio: config.media?.generateAudio === true,
|
||||
generateImage: config.media?.generateImage === true,
|
||||
generateAudio: shouldGenerateAudio(config),
|
||||
generateImage: shouldGenerateImage(config),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -178,6 +195,39 @@ export class PendingYoutubeMediaQueue {
|
||||
}
|
||||
}
|
||||
|
||||
async handleFailed(
|
||||
sourceUrl: string,
|
||||
options: PendingYoutubeMediaQueueFailedOptions = {},
|
||||
): Promise<void> {
|
||||
const jobs = this.takeMatchingUpdates(sourceUrl);
|
||||
if (jobs.length === 0) {
|
||||
if (options.notifyStatus !== false) {
|
||||
this.deps.showStatusNotification('YouTube media cache failed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.notifyStatus !== false) {
|
||||
this.deps.showStatusNotification(
|
||||
`YouTube media cache failed. Media was not added to ${jobs.length} queued card${
|
||||
jobs.length === 1 ? '' : 's'
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
this.deps.logWarn('Discarding queued YouTube media updates after cache failure:', jobs.length);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await this.deps.showNotification(job.noteId, job.label, 'media cache failed');
|
||||
} catch (error) {
|
||||
this.deps.logWarn(
|
||||
'Failed to show queued YouTube media failure notification:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
|
||||
const matched: PendingYoutubeMediaUpdate[] = [];
|
||||
const remaining: PendingYoutubeMediaUpdate[] = [];
|
||||
|
||||
@@ -82,6 +82,10 @@ test('YouTube media cache exposes the downloaded file after the background job c
|
||||
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('--force-ipv4'));
|
||||
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--retries') + 1], '5');
|
||||
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--fragment-retries') + 1], '5');
|
||||
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--extractor-retries') + 1], '5');
|
||||
assert.ok(spawnCalls[0]?.args.includes('--merge-output-format'));
|
||||
assert.equal(
|
||||
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
|
||||
@@ -107,6 +111,149 @@ test('YouTube media cache exposes the downloaded file after the background job c
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache clears the active cache when direct mode starts', 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/background', { 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.getActiveCachedMediaPath(), outputPath);
|
||||
|
||||
cache.start('https://youtu.be/direct', { mode: 'direct' });
|
||||
|
||||
assert.equal(await cache.getActiveCachedMediaPath(), null);
|
||||
assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), outputPath);
|
||||
} finally {
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache cancels the active download when direct mode starts', async () => {
|
||||
const cacheRoot = makeTempCacheRoot();
|
||||
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
const readyEvents: Array<{ url: string; path: string }> = [];
|
||||
|
||||
try {
|
||||
const cache = createYoutubeMediaCacheService({
|
||||
cacheRoot,
|
||||
getYtDlpCommand: () => 'yt-dlp',
|
||||
onReady: (event) => {
|
||||
readyEvents.push(event);
|
||||
},
|
||||
spawn: (command, args, options) => {
|
||||
spawnCalls.push({ command, args, options });
|
||||
const proc = new FakeYtDlpProcess();
|
||||
spawnedProcesses.push(proc);
|
||||
return proc;
|
||||
},
|
||||
});
|
||||
|
||||
cache.start('https://youtu.be/background', { 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');
|
||||
|
||||
cache.start('https://youtu.be/direct', { mode: 'direct' });
|
||||
spawnedProcesses[0]?.emit('close', 0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(spawnedProcesses[0]?.killed, true);
|
||||
assert.deepEqual(readyEvents, []);
|
||||
assert.equal(await cache.getActiveCachedMediaPath(), null);
|
||||
assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), null);
|
||||
} finally {
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache reports failed background downloads', async () => {
|
||||
const cacheRoot = makeTempCacheRoot();
|
||||
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||
const failedEvents: Array<{ url: string }> = [];
|
||||
|
||||
try {
|
||||
const cache = createYoutubeMediaCacheService({
|
||||
cacheRoot,
|
||||
getYtDlpCommand: () => 'yt-dlp',
|
||||
onFailed: (event) => {
|
||||
failedEvents.push(event);
|
||||
},
|
||||
spawn: () => {
|
||||
const proc = new FakeYtDlpProcess();
|
||||
spawnedProcesses.push(proc);
|
||||
return proc;
|
||||
},
|
||||
});
|
||||
|
||||
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||
spawnedProcesses[0]?.emit('close', 1);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]);
|
||||
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
||||
} finally {
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache only reports a failed download once when error is followed by close', async () => {
|
||||
const cacheRoot = makeTempCacheRoot();
|
||||
const spawnedProcesses: FakeYtDlpProcess[] = [];
|
||||
const failedEvents: Array<{ url: string }> = [];
|
||||
|
||||
try {
|
||||
const cache = createYoutubeMediaCacheService({
|
||||
cacheRoot,
|
||||
getYtDlpCommand: () => 'yt-dlp',
|
||||
onFailed: (event) => {
|
||||
failedEvents.push(event);
|
||||
},
|
||||
spawn: () => {
|
||||
const proc = new FakeYtDlpProcess();
|
||||
spawnedProcesses.push(proc);
|
||||
return proc;
|
||||
},
|
||||
});
|
||||
|
||||
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||
spawnedProcesses[0]?.emit('error', new Error('spawn failed'));
|
||||
spawnedProcesses[0]?.emit('close', 1);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]);
|
||||
} finally {
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache can disable the download height cap', () => {
|
||||
const cacheRoot = makeTempCacheRoot();
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
@@ -159,6 +306,105 @@ test('YouTube media cache applies the configured download height cap', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache restarts ready sessions when the height cap changes', 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', maxHeight: 720 });
|
||||
const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
|
||||
assert.equal(typeof firstOutputTemplate, 'string');
|
||||
const firstOutputDir = path.dirname(firstOutputTemplate!);
|
||||
const firstOutputPath = path.join(firstOutputDir, 'media.mkv');
|
||||
fs.mkdirSync(firstOutputDir, { recursive: true });
|
||||
fs.writeFileSync(firstOutputPath, 'cached media');
|
||||
spawnedProcesses[0]?.emit('close', 0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), firstOutputPath);
|
||||
|
||||
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
|
||||
|
||||
assert.equal(spawnCalls.length, 2);
|
||||
assert.equal(fs.existsSync(firstOutputPath), false);
|
||||
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
|
||||
assert.equal(
|
||||
spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1],
|
||||
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache restarts running sessions when the height cap changes', () => {
|
||||
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', maxHeight: 720 });
|
||||
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
|
||||
|
||||
assert.equal(spawnedProcesses[0]?.killed, true);
|
||||
assert.equal(spawnCalls.length, 2);
|
||||
assert.equal(
|
||||
spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1],
|
||||
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache removes stale files from previous runs on startup', () => {
|
||||
const cacheRoot = makeTempCacheRoot();
|
||||
const staleDir = path.join(cacheRoot, 'stale-session');
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media');
|
||||
|
||||
createYoutubeMediaCacheService({
|
||||
cacheRoot,
|
||||
getYtDlpCommand: () => 'yt-dlp',
|
||||
spawn: (command, args) => {
|
||||
spawnCalls.push({ command, args });
|
||||
return new FakeYtDlpProcess();
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(staleDir), false);
|
||||
assert.deepEqual(spawnCalls, []);
|
||||
} 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[] = [];
|
||||
@@ -198,6 +444,32 @@ test('YouTube media cache restarts when a ready cached file was deleted external
|
||||
}
|
||||
});
|
||||
|
||||
test('YouTube media cache removes stale disk siblings before starting a new cache', () => {
|
||||
const cacheRoot = makeTempCacheRoot();
|
||||
const staleDir = path.join(cacheRoot, 'stale-sibling');
|
||||
const spawnCalls: Array<{ command: string; args: string[] }> = [];
|
||||
|
||||
try {
|
||||
const cache = createYoutubeMediaCacheService({
|
||||
cacheRoot,
|
||||
getYtDlpCommand: () => 'yt-dlp',
|
||||
spawn: (command, args) => {
|
||||
spawnCalls.push({ command, args });
|
||||
return new FakeYtDlpProcess();
|
||||
},
|
||||
});
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media');
|
||||
|
||||
cache.start('https://youtu.be/demo', { mode: 'background' });
|
||||
|
||||
assert.equal(fs.existsSync(staleDir), false);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
} 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[] = [];
|
||||
|
||||
@@ -24,6 +24,7 @@ type SpawnProcess = (
|
||||
interface MediaCacheSession {
|
||||
url: string;
|
||||
dir: string;
|
||||
maxHeight: number;
|
||||
process: SpawnedProcess | null;
|
||||
readyPath: string | null;
|
||||
state: MediaCacheSessionState;
|
||||
@@ -40,6 +41,7 @@ export interface YoutubeMediaCacheServiceDeps {
|
||||
spawn?: SpawnProcess;
|
||||
onDownloadStarted?: (event: { url: string }) => void;
|
||||
onReady?: (event: { url: string; path: string }) => void;
|
||||
onFailed?: (event: { url: string }) => void;
|
||||
logInfo?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
}
|
||||
@@ -88,6 +90,13 @@ function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number
|
||||
return [
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--force-ipv4',
|
||||
'--retries',
|
||||
'5',
|
||||
'--fragment-retries',
|
||||
'5',
|
||||
'--extractor-retries',
|
||||
'5',
|
||||
'-f',
|
||||
getFormatSelector(normalizeMaxHeight(maxHeight)),
|
||||
'--merge-output-format',
|
||||
@@ -109,6 +118,29 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
let activeKey: string | null = null;
|
||||
|
||||
const getSessionDir = (url: string): string => path.join(cacheRoot, cacheKeyForUrl(url));
|
||||
const removeCacheDir = (dir: string): void => {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Temp cache cleanup should not block shutdown or playback startup.
|
||||
}
|
||||
};
|
||||
const removeCacheRootEntriesExcept = (dirsToKeep: string[]): void => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const keepDirs = new Set(dirsToKeep.map((dir) => path.resolve(dir)));
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheRoot, entry.name);
|
||||
if (keepDirs.has(path.resolve(entryPath))) {
|
||||
continue;
|
||||
}
|
||||
removeCacheDir(entryPath);
|
||||
}
|
||||
};
|
||||
const removeSession = (key: string): void => {
|
||||
const session = sessions.get(key);
|
||||
if (!session) {
|
||||
@@ -121,11 +153,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
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.
|
||||
}
|
||||
removeCacheDir(session.dir);
|
||||
};
|
||||
const removeInactiveSessions = (keyToKeep: string): void => {
|
||||
for (const key of [...sessions.keys()]) {
|
||||
@@ -134,6 +162,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
}
|
||||
}
|
||||
};
|
||||
removeCacheRootEntriesExcept([]);
|
||||
|
||||
const getCachedMediaPath = async (url: string): Promise<string | null> => {
|
||||
const key = cacheKeyForUrl(url);
|
||||
@@ -147,6 +176,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
sessions.set(key, {
|
||||
url,
|
||||
dir: path.dirname(readyPath),
|
||||
maxHeight: session?.maxHeight ?? DEFAULT_MAX_HEIGHT,
|
||||
process: null,
|
||||
readyPath,
|
||||
state: 'ready',
|
||||
@@ -167,38 +197,52 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
|
||||
const start = (url: string, options: YoutubeMediaCacheStartOptions): void => {
|
||||
if (options.mode !== 'background') {
|
||||
if (activeKey) {
|
||||
const activeSession = sessions.get(activeKey);
|
||||
if (activeSession?.state === 'running') {
|
||||
removeSession(activeKey);
|
||||
}
|
||||
}
|
||||
activeKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const key = cacheKeyForUrl(url);
|
||||
const maxHeight = normalizeMaxHeight(options.maxHeight);
|
||||
activeKey = key;
|
||||
const dir = getSessionDir(url);
|
||||
const existingSession = sessions.get(key);
|
||||
if (existingSession?.state === 'running') {
|
||||
const canReuseExistingSession = existingSession?.maxHeight === maxHeight;
|
||||
if (existingSession?.state === 'running' && canReuseExistingSession) {
|
||||
removeInactiveSessions(key);
|
||||
removeCacheRootEntriesExcept([existingSession.dir]);
|
||||
return;
|
||||
}
|
||||
if (existingSession) {
|
||||
if (
|
||||
canReuseExistingSession &&
|
||||
existingSession.state === 'ready' &&
|
||||
((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) ||
|
||||
findReadyMediaPath(existingSession.dir))
|
||||
) {
|
||||
removeInactiveSessions(key);
|
||||
removeCacheRootEntriesExcept([existingSession.dir]);
|
||||
return;
|
||||
}
|
||||
removeSession(key);
|
||||
activeKey = key;
|
||||
}
|
||||
removeInactiveSessions(key);
|
||||
removeCacheRootEntriesExcept([dir]);
|
||||
|
||||
const dir = getSessionDir(url);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const outputTemplate = path.join(dir, 'media.%(ext)s');
|
||||
const args = createYtDlpArgs(url, outputTemplate, options.maxHeight);
|
||||
const args = createYtDlpArgs(url, outputTemplate, maxHeight);
|
||||
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
const session: MediaCacheSession = {
|
||||
url,
|
||||
dir,
|
||||
maxHeight,
|
||||
process: child,
|
||||
readyPath: null,
|
||||
state: 'running',
|
||||
@@ -219,6 +263,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
deps.onFailed?.({ url });
|
||||
});
|
||||
|
||||
child.once('close', (code) => {
|
||||
@@ -226,6 +271,9 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
if (currentSession !== session) {
|
||||
return;
|
||||
}
|
||||
if (session.state === 'failed') {
|
||||
return;
|
||||
}
|
||||
session.process = null;
|
||||
if (code === 0) {
|
||||
const readyPath = findReadyMediaPath(dir);
|
||||
@@ -239,6 +287,7 @@ export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDep
|
||||
}
|
||||
session.state = 'failed';
|
||||
deps.logWarn?.(`YouTube media cache download exited without a usable media file.`);
|
||||
deps.onFailed?.({ url });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+4
-3
@@ -2,9 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultLogFilePath, setLogRotation } from './logger';
|
||||
import { localDateKey } from './shared/log-files';
|
||||
|
||||
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
@@ -20,7 +21,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
@@ -34,7 +35,7 @@ test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
|
||||
test('setLogRotation accepts numeric retention days', () => {
|
||||
const previous = process.env.SUBMINER_LOG_ROTATION;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
setLogRotation(14);
|
||||
try {
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
|
||||
+17
@@ -1200,6 +1200,23 @@ const youtubeMediaCache = createYoutubeMediaCacheService({
|
||||
);
|
||||
});
|
||||
},
|
||||
onFailed: (event) => {
|
||||
showConfiguredStatusNotification('YouTube media cache failed.', {
|
||||
id: 'youtube-media-cache-status',
|
||||
title: 'YouTube media cache',
|
||||
variant: 'error',
|
||||
persistent: false,
|
||||
});
|
||||
void appState.ankiIntegration
|
||||
?.handleYoutubeMediaCacheFailed(event.url, { notifyStatus: false })
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
`Failed to drain queued YouTube media updates after cache failure: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
|
||||
@@ -14,6 +14,20 @@ function cleanupDir(dirPath: string): void {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function withTimeZone<T>(timeZone: string, run: () => T): T {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = timeZone;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeLog(logsDir: string, name: string, content: string, mtime: string): string {
|
||||
const logPath = path.join(logsDir, name);
|
||||
fs.writeFileSync(logPath, content, 'utf8');
|
||||
@@ -67,6 +81,80 @@ test('maskUsernamesInLogText redacts linux macOS and Windows home paths', () =>
|
||||
assert.doesNotMatch(masked, /kyle/);
|
||||
});
|
||||
|
||||
test('maskUsernamesInLogText redacts IP addresses and emails', () => {
|
||||
const masked = maskUsernamesInLogText(
|
||||
[
|
||||
'ffmpeg failed after request from public ip 203.0.113.42',
|
||||
'connect tcp 192.168.1.25:443: i/o timeout',
|
||||
'remote addr [2001:db8::1234]:443',
|
||||
'support email kyle@example.test',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /public ip <ip>/);
|
||||
assert.match(masked, /tcp <ip>:443/);
|
||||
assert.match(masked, /remote addr \[<ip>\]:443/);
|
||||
assert.match(masked, /support email <email>/);
|
||||
assert.doesNotMatch(masked, /203\.0\.113\.42/);
|
||||
assert.doesNotMatch(masked, /192\.168\.1\.25/);
|
||||
assert.doesNotMatch(masked, /2001:db8::1234/);
|
||||
assert.doesNotMatch(masked, /kyle@example\.test/);
|
||||
});
|
||||
|
||||
test('maskUsernamesInLogText redacts headers and yt-dlp cookie arguments', () => {
|
||||
const masked = maskUsernamesInLogText(
|
||||
[
|
||||
'Authorization: Bearer ya29.secret-token',
|
||||
'Cookie: SID=session-value; HSID=history-value',
|
||||
'Set-Cookie: VISITOR_INFO1_LIVE=visitor; Path=/',
|
||||
'x-goog-visitor-id: Cgt2aXNpdG9y',
|
||||
'warn yt-dlp header Authorization: Bearer inline-token',
|
||||
'yt-dlp --cookies /Users/kyle/cookies.txt --cookies-from-browser chrome:Profile 1',
|
||||
'yt-dlp --cookies=/tmp/ytdlp-cookies.txt --cookies-from-browser=firefox:default',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /Authorization: <redacted>/);
|
||||
assert.match(masked, /Cookie: <redacted>/);
|
||||
assert.match(masked, /Set-Cookie: <redacted>/);
|
||||
assert.match(masked, /x-goog-visitor-id: <redacted>/i);
|
||||
assert.match(masked, /--cookies <redacted>/);
|
||||
assert.match(masked, /--cookies=<redacted>/);
|
||||
assert.match(masked, /--cookies-from-browser <redacted>/);
|
||||
assert.match(masked, /--cookies-from-browser=<redacted>/);
|
||||
assert.doesNotMatch(masked, /ya29/);
|
||||
assert.doesNotMatch(masked, /inline-token/);
|
||||
assert.doesNotMatch(masked, /session-value/);
|
||||
assert.doesNotMatch(masked, /cookies\.txt/);
|
||||
assert.doesNotMatch(masked, /firefox:default/);
|
||||
});
|
||||
|
||||
test('maskUsernamesInLogText redacts URL credentials and sensitive query values', () => {
|
||||
const masked = maskUsernamesInLogText(
|
||||
[
|
||||
'GET https://alice:secret@example.test/watch?v=abc&access_token=tok123&api_key=key456',
|
||||
'stream https://video.example.test/file.m3u8?signature=sig789&expire=1777777777',
|
||||
'callback subminer://anilist-setup?access_token=ani-token&state=ok',
|
||||
'json {"password":"hunter2","refreshToken":"refresh-token","client_secret":"client-secret"}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /https:\/\/<credentials>@example\.test/);
|
||||
assert.match(masked, /access_token=<redacted>/);
|
||||
assert.match(masked, /api_key=<redacted>/);
|
||||
assert.match(masked, /signature=<redacted>/);
|
||||
assert.match(masked, /"password":"<redacted>"/);
|
||||
assert.match(masked, /"refreshToken":"<redacted>"/);
|
||||
assert.match(masked, /"client_secret":"<redacted>"/);
|
||||
assert.doesNotMatch(masked, /alice:secret/);
|
||||
assert.doesNotMatch(masked, /tok123/);
|
||||
assert.doesNotMatch(masked, /key456/);
|
||||
assert.doesNotMatch(masked, /sig789/);
|
||||
assert.doesNotMatch(masked, /ani-token/);
|
||||
assert.doesNotMatch(masked, /hunter2/);
|
||||
assert.match(masked, /state=ok/);
|
||||
});
|
||||
|
||||
test('exportLogsArchive exports current-day logs and masks usernames', () => {
|
||||
const root = makeTempDir();
|
||||
const logsDir = path.join(root, 'logs');
|
||||
@@ -152,6 +240,43 @@ test('exportLogsArchive ignores older dated logs when current-day dated logs exi
|
||||
}
|
||||
});
|
||||
|
||||
test('exportLogsArchive ranks dated fallback logs by local day freshness', () => {
|
||||
withTimeZone('America/Los_Angeles', () => {
|
||||
const root = makeTempDir();
|
||||
const logsDir = path.join(root, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const datedLog = writeLog(
|
||||
logsDir,
|
||||
'app-2026-05-25.log',
|
||||
'dated local-day log\n',
|
||||
'2026-05-25T12:00:00Z',
|
||||
);
|
||||
writeLog(
|
||||
logsDir,
|
||||
'app-2026-05-undated.log',
|
||||
'undated touched before local midnight\n',
|
||||
'2026-05-26T01:00:00Z',
|
||||
);
|
||||
|
||||
const result = exportLogsArchive({
|
||||
logsDir,
|
||||
outputDir: root,
|
||||
now: new Date('2026-05-27T16:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(result.mode, 'most-recent');
|
||||
assert.deepEqual(result.exportedFiles, [datedLog]);
|
||||
|
||||
const entries = readStoredZipEntries(result.zipPath);
|
||||
assert.deepEqual([...entries.keys()], ['logs/app-2026-05-25.log']);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('exportLogsArchive falls back to newest log per kind', () => {
|
||||
const root = makeTempDir();
|
||||
const logsDir = path.join(root, 'logs');
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveLogBaseDir } from '../../shared/log-files';
|
||||
import { localDateKey, resolveLogBaseDir } from '../../shared/log-files';
|
||||
import { writeStoredZip } from '../../shared/stored-zip';
|
||||
import { redactLogExportText } from './log-redaction';
|
||||
|
||||
type LogCandidate = {
|
||||
path: string;
|
||||
@@ -29,16 +30,10 @@ export type ExportLogsOptions = {
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
const REDACTED_USER = '<user>';
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function localDateKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function localWeekKey(date: Date): string {
|
||||
const startOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const dayOfYear =
|
||||
@@ -126,17 +121,41 @@ function selectMostRecentPerKind(candidates: LogCandidate[]): LogCandidate[] {
|
||||
return [...byKind.values()].sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function localDateEndMs(dateKey: string): number | null {
|
||||
const match = dateKey.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const date = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||
if (
|
||||
Number.isNaN(date.getTime()) ||
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function localWeekEndMs(weekKey: string): number | null {
|
||||
const match = weekKey.match(/^(\d{4})-W(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const week = Number(match[2]);
|
||||
const date = new Date(year, 0, week * 7, 23, 59, 59, 999);
|
||||
return Number.isNaN(date.getTime()) ? null : date.getTime();
|
||||
}
|
||||
|
||||
function candidateFreshnessMs(candidate: LogCandidate): number {
|
||||
if (candidate.fileDateKey) {
|
||||
return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`);
|
||||
return localDateEndMs(candidate.fileDateKey) ?? candidate.mtimeMs;
|
||||
}
|
||||
if (candidate.fileWeekKey) {
|
||||
const match = candidate.fileWeekKey.match(/^(\d{4})-W(\d{2})$/);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
const week = Number(match[2]);
|
||||
return Date.UTC(year, 0, week * 7, 23, 59, 59, 999);
|
||||
}
|
||||
return localWeekEndMs(candidate.fileWeekKey) ?? candidate.mtimeMs;
|
||||
}
|
||||
return candidate.mtimeMs;
|
||||
}
|
||||
@@ -167,9 +186,7 @@ function selectLogCandidates(
|
||||
}
|
||||
|
||||
export function maskUsernamesInLogText(text: string): string {
|
||||
return text
|
||||
.replace(/(\/(?:home|Users)\/)([^/\r\n]+)(?=\/|$)/g, `$1${REDACTED_USER}`)
|
||||
.replace(/([A-Za-z]:[\\/]+Users[\\/]+)([^\\/:\r\n]+)(?=[\\/]|$)/g, `$1${REDACTED_USER}`);
|
||||
return redactLogExportText(text);
|
||||
}
|
||||
|
||||
export function exportLogsArchive(options: ExportLogsOptions = {}): ExportLogsResult {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { LOG_EXPORT_REDACTION_RULES, redactLogExportText } from './log-redaction';
|
||||
|
||||
test('log export redaction rules expose auditable fixtures', () => {
|
||||
assert.deepEqual(
|
||||
LOG_EXPORT_REDACTION_RULES.map((rule) => rule.name),
|
||||
[
|
||||
'signed-youtube-media-url-query',
|
||||
'url-sensitive-components',
|
||||
'sensitive-headers',
|
||||
'yt-dlp-cookie-args',
|
||||
'json-secret-values',
|
||||
'generic-sensitive-key-values',
|
||||
'home-path-usernames',
|
||||
'email-addresses',
|
||||
'ip-addresses',
|
||||
],
|
||||
);
|
||||
|
||||
const names = new Set<string>();
|
||||
for (const rule of LOG_EXPORT_REDACTION_RULES) {
|
||||
assert.equal(names.has(rule.name), false, `duplicate rule name: ${rule.name}`);
|
||||
names.add(rule.name);
|
||||
assert.ok(rule.pattern.length > 0, `${rule.name} pattern`);
|
||||
assert.ok(rule.replacement.length > 0, `${rule.name} replacement`);
|
||||
assert.ok(rule.risk.length > 0, `${rule.name} risk`);
|
||||
assert.ok(rule.examples.length > 0, `${rule.name} examples`);
|
||||
|
||||
for (const example of rule.examples) {
|
||||
assert.equal(rule.redact(example.input), example.expected, `${rule.name} direct`);
|
||||
assert.equal(redactLogExportText(example.input), example.expected, rule.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts parsed URL query values without swallowing punctuation', () => {
|
||||
const masked = redactLogExportText(
|
||||
[
|
||||
'failed URL (https://example.test/watch?access_token=tok123).',
|
||||
'callback subminer://anilist-setup?access_token=ani-token&state=ok',
|
||||
'encoded https://example.test/watch?client%5Fsecret=secret-value&plain=ok!',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /\(https:\/\/example\.test\/watch\?access_token=<redacted>\)\./);
|
||||
assert.match(masked, /subminer:\/\/anilist-setup\?access_token=<redacted>&state=ok/);
|
||||
assert.match(masked, /client%5Fsecret=<redacted>&plain=ok!/);
|
||||
assert.doesNotMatch(masked, /tok123/);
|
||||
assert.doesNotMatch(masked, /ani-token/);
|
||||
assert.doesNotMatch(masked, /secret-value/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts URL credentials containing at signs', () => {
|
||||
const masked = redactLogExportText('GET https://alice:p@ss@example.test/watch?state=ok');
|
||||
|
||||
assert.equal(masked, 'GET https://<credentials>@example.test/watch?state=ok');
|
||||
assert.doesNotMatch(masked, /alice/);
|
||||
assert.doesNotMatch(masked, /p@ss/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts signed YouTube media URL query strings', () => {
|
||||
const masked = redactLogExportText(
|
||||
[
|
||||
'ffmpeg failed for (https://rr1---sn-a5mekn6r.googlevideo.com/videoplayback?expire=1777777777&ei=EI_VALUE&id=o-SECRETID&itag=251&ip=203.0.113.42&source=youtube&requiressl=yes&n=NSECRET&sparams=expire,ei,ip,id,itag,source,requiressl&sig=SIGSECRET&lsig=LSIGSECRET).',
|
||||
'manifest https://manifest.googlevideo.com/videoplayback?expire=1777777777&signature=SIG2#frag',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
masked,
|
||||
/\(https:\/\/rr1---sn-a5mekn6r\.googlevideo\.com\/videoplayback\?<redacted>\)\./,
|
||||
);
|
||||
assert.match(masked, /https:\/\/manifest\.googlevideo\.com\/videoplayback\?<redacted>#frag/);
|
||||
assert.doesNotMatch(masked, /1777777777/);
|
||||
assert.doesNotMatch(masked, /EI_VALUE/);
|
||||
assert.doesNotMatch(masked, /o-SECRETID/);
|
||||
assert.doesNotMatch(masked, /203\.0\.113\.42/);
|
||||
assert.doesNotMatch(masked, /NSECRET/);
|
||||
assert.doesNotMatch(masked, /SIGSECRET/);
|
||||
assert.doesNotMatch(masked, /LSIGSECRET/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts nested JSON secret values', () => {
|
||||
const masked = redactLogExportText(
|
||||
'json {"nested":{"token":123,"safe":"ok"},"items":[{"password":true},{"client_secret":null}]}',
|
||||
);
|
||||
|
||||
assert.match(masked, /"token":"<redacted>"/);
|
||||
assert.match(masked, /"safe":"ok"/);
|
||||
assert.match(masked, /"password":"<redacted>"/);
|
||||
assert.match(masked, /"client_secret":"<redacted>"/);
|
||||
assert.doesNotMatch(masked, /123/);
|
||||
assert.doesNotMatch(masked, /true/);
|
||||
assert.doesNotMatch(masked, /null/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts compound secret keys', () => {
|
||||
const masked = redactLogExportText(
|
||||
[
|
||||
'json {"openaiApiKey":"sk-secret","google_api_key":"AIza-secret","userPassword":"hunter2","safe":"ok"}',
|
||||
'openaiApiKey=sk-inline google_api_key=AIza-inline userPassword=hunter-inline',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /"openaiApiKey":"<redacted>"/);
|
||||
assert.match(masked, /"google_api_key":"<redacted>"/);
|
||||
assert.match(masked, /"userPassword":"<redacted>"/);
|
||||
assert.match(masked, /"safe":"ok"/);
|
||||
assert.match(masked, /openaiApiKey=<redacted>/);
|
||||
assert.match(masked, /google_api_key=<redacted>/);
|
||||
assert.match(masked, /userPassword=<redacted>/);
|
||||
assert.doesNotMatch(masked, /sk-secret|AIza-secret|hunter2/);
|
||||
assert.doesNotMatch(masked, /sk-inline|AIza-inline|hunter-inline/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts quoted secret values containing the opposite quote', () => {
|
||||
const masked = redactLogExportText(`"password":"pa'ss" 'token':'to"ken' "safe":"pa'ss"`);
|
||||
|
||||
assert.equal(masked, `"password":"<redacted>" 'token':'<redacted>' "safe":"pa'ss"`);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts IPv6 addresses with zone identifiers', () => {
|
||||
const masked = redactLogExportText('connected [fe80::1%en0]:443 and fe80::2%eth0');
|
||||
|
||||
assert.equal(masked, 'connected [<ip>]:443 and <ip>');
|
||||
});
|
||||
|
||||
test('redactLogExportText keeps malformed JSON scans bounded', () => {
|
||||
const malformedLine = `${'{'.repeat(70 * 1024)} token=secret`;
|
||||
const masked = redactLogExportText(`${malformedLine}\njson {"token":"secret","safe":"ok"}`);
|
||||
|
||||
assert.match(masked, /token=<redacted>/);
|
||||
assert.match(masked, /json {"token":"<redacted>","safe":"ok"}/);
|
||||
assert.doesNotMatch(masked, /token=secret/);
|
||||
assert.doesNotMatch(masked, /"token":"secret"/);
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import * as net from 'net';
|
||||
|
||||
type LogRedactionExample = {
|
||||
input: string;
|
||||
expected: string;
|
||||
};
|
||||
|
||||
export type LogRedactionRule = {
|
||||
name: string;
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
risk: 'pii' | 'secret' | 'pii-or-secret';
|
||||
examples: readonly LogRedactionExample[];
|
||||
redact: (text: string) => string;
|
||||
};
|
||||
|
||||
const REDACTED_USER = '<user>';
|
||||
const REDACTED_VALUE = '<redacted>';
|
||||
const REDACTED_CREDENTIALS = '<credentials>';
|
||||
const REDACTED_EMAIL = '<email>';
|
||||
const REDACTED_IP = '<ip>';
|
||||
const MAX_JSON_CANDIDATE_SCAN_CHARS = 64 * 1024;
|
||||
|
||||
const SENSITIVE_HEADER_NAMES = [
|
||||
'api-key',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'proxy-authorization',
|
||||
'set-cookie',
|
||||
'x-api-key',
|
||||
'x-emby-token',
|
||||
'x-goog-visitor-id',
|
||||
'x-mediabrowser-token',
|
||||
].join('|');
|
||||
|
||||
const SENSITIVE_VALUE_KEY_PATTERNS = [
|
||||
'access[_-]?token',
|
||||
'api[_-]?key',
|
||||
'apikey',
|
||||
'authorization',
|
||||
'client[_-]?secret',
|
||||
'cookie',
|
||||
'cookies',
|
||||
'id[_-]?token',
|
||||
'password',
|
||||
'passwd',
|
||||
'pwd',
|
||||
'refresh[_-]?token',
|
||||
'secret',
|
||||
'session',
|
||||
'sid',
|
||||
'sig',
|
||||
'signature',
|
||||
'token',
|
||||
].join('|');
|
||||
|
||||
const SENSITIVE_VALUE_KEY_SEQUENCES: readonly (readonly string[])[] = [
|
||||
['access', 'token'],
|
||||
['api', 'key'],
|
||||
['apikey'],
|
||||
['authorization'],
|
||||
['client', 'secret'],
|
||||
['cookie'],
|
||||
['cookies'],
|
||||
['id', 'token'],
|
||||
['password'],
|
||||
['passwd'],
|
||||
['pwd'],
|
||||
['refresh', 'token'],
|
||||
['secret'],
|
||||
['session'],
|
||||
['sid'],
|
||||
['sig'],
|
||||
['signature'],
|
||||
['token'],
|
||||
];
|
||||
const SENSITIVE_NORMALIZED_KEY_SUFFIXES = [
|
||||
'accesstoken',
|
||||
'apikey',
|
||||
'authorization',
|
||||
'clientsecret',
|
||||
'cookie',
|
||||
'cookies',
|
||||
'idtoken',
|
||||
'password',
|
||||
'passwd',
|
||||
'pwd',
|
||||
'refreshtoken',
|
||||
'secret',
|
||||
'session',
|
||||
'signature',
|
||||
'token',
|
||||
] as const;
|
||||
const SENSITIVE_HEADER_RE = new RegExp(
|
||||
`(^|\\r?\\n)(\\s*(?:${SENSITIVE_HEADER_NAMES})\\s*:\\s*)[^\\r\\n]*`,
|
||||
'gi',
|
||||
);
|
||||
const SENSITIVE_INLINE_HEADER_RE = new RegExp(
|
||||
`\\b((?:${SENSITIVE_HEADER_NAMES})\\s*:\\s*)[^\\r\\n]*`,
|
||||
'gi',
|
||||
);
|
||||
const KEY_VALUE_NAME_PATTERN = '[A-Za-z0-9][A-Za-z0-9_.%-]*';
|
||||
const SENSITIVE_QUOTED_VALUE_RE = new RegExp(
|
||||
`(["'])(${KEY_VALUE_NAME_PATTERN})\\1(\\s*[:=]\\s*)(["'])((?:(?!\\4)[^\\r\\n])*)\\4`,
|
||||
'g',
|
||||
);
|
||||
const SENSITIVE_UNQUOTED_VALUE_RE = new RegExp(
|
||||
`\\b(${KEY_VALUE_NAME_PATTERN})(\\s*[:=]\\s*)(?:"[^"\\r\\n]*"|'[^'\\r\\n]*'|[^\\s,}\\]\\)&<>"']+)`,
|
||||
'g',
|
||||
);
|
||||
const YTDLP_COOKIE_EQUALS_RE =
|
||||
/(--(?:cookies|cookies-from-browser)=)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s\r\n]+)/gi;
|
||||
const YTDLP_COOKIE_SPACE_RE =
|
||||
/(--(?:cookies|cookies-from-browser)\s+)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\r\n]*?)(?=\s+--[A-Za-z0-9][A-Za-z0-9-]*|\r?\n|$)/gi;
|
||||
const URL_TOKEN_RE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi;
|
||||
const URL_CREDENTIALS_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/?#\s"'<>]*@)/gi;
|
||||
const URL_QUERY_PAIR_RE = /([?&;]|&)([^=&#;]+)=([^&#;]*)/gi;
|
||||
const TRAILING_URL_PUNCTUATION_RE = /[)\].,;:!?]+$/;
|
||||
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
||||
const BRACKETED_IP_RE = /\[([0-9A-F:.]+(?:%[A-Z0-9_.~-]+)?)\]/gi;
|
||||
const IPV4_RE = /(^|[^A-Za-z0-9_.-])((?:\d{1,3}\.){3}\d{1,3})(?![A-Za-z0-9_.-])/g;
|
||||
const IPV6_RE =
|
||||
/(^|[^A-Za-z0-9_.-])([0-9A-F]{0,4}:[0-9A-F:.]*(?:%[A-Z0-9_.~-]+)?)(?![A-Za-z0-9_.-])/gi;
|
||||
|
||||
function safeDecodeFormComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value.replace(/\+/g, ' '));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeValueKey(key: string): string[] {
|
||||
return (
|
||||
key
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase()
|
||||
.match(/[a-z0-9]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function containsTokenSequence(tokens: readonly string[], sequence: readonly string[]): boolean {
|
||||
for (let index = 0; index <= tokens.length - sequence.length; index += 1) {
|
||||
if (sequence.every((token, offset) => tokens[index + offset] === token)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSensitiveValueKey(key: string): boolean {
|
||||
const decoded = safeDecodeFormComponent(key);
|
||||
const tokens = tokenizeValueKey(decoded);
|
||||
if (SENSITIVE_VALUE_KEY_SEQUENCES.some((sequence) => containsTokenSequence(tokens, sequence))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = tokens.join('');
|
||||
return SENSITIVE_NORMALIZED_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
|
||||
}
|
||||
|
||||
function splitTrailingUrlPunctuation(token: string): { core: string; trailing: string } {
|
||||
const match = token.match(TRAILING_URL_PUNCTUATION_RE);
|
||||
if (!match) return { core: token, trailing: '' };
|
||||
return {
|
||||
core: token.slice(0, -match[0].length),
|
||||
trailing: match[0],
|
||||
};
|
||||
}
|
||||
|
||||
function redactSensitiveUrlQueryPairs(rawUrl: string): string {
|
||||
const queryStart = rawUrl.indexOf('?');
|
||||
if (queryStart === -1) return rawUrl;
|
||||
|
||||
const hashStart = rawUrl.indexOf('#', queryStart);
|
||||
const queryEnd = hashStart === -1 ? rawUrl.length : hashStart;
|
||||
const query = rawUrl.slice(queryStart, queryEnd);
|
||||
const redactedQuery = query.replace(URL_QUERY_PAIR_RE, (match, prefix: string, rawKey: string) =>
|
||||
isSensitiveValueKey(rawKey) ? `${prefix}${rawKey}=${REDACTED_VALUE}` : match,
|
||||
);
|
||||
|
||||
return `${rawUrl.slice(0, queryStart)}${redactedQuery}${rawUrl.slice(queryEnd)}`;
|
||||
}
|
||||
|
||||
function redactUrlToken(token: string): string {
|
||||
const { core, trailing } = splitTrailingUrlPunctuation(token);
|
||||
try {
|
||||
new URL(core);
|
||||
} catch {
|
||||
return token;
|
||||
}
|
||||
|
||||
const withoutCredentials = core.replace(URL_CREDENTIALS_RE, `$1${REDACTED_CREDENTIALS}@`);
|
||||
return `${redactSensitiveUrlQueryPairs(withoutCredentials)}${trailing}`;
|
||||
}
|
||||
|
||||
function redactUrlSensitiveComponents(text: string): string {
|
||||
return text.replace(URL_TOKEN_RE, (token: string) => redactUrlToken(token));
|
||||
}
|
||||
|
||||
function isGoogleVideoHost(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return normalized === 'googlevideo.com' || normalized.endsWith('.googlevideo.com');
|
||||
}
|
||||
|
||||
function isSignedYouTubeMediaUrl(url: URL): boolean {
|
||||
return isGoogleVideoHost(url.hostname) && url.pathname === '/videoplayback' && url.search !== '';
|
||||
}
|
||||
|
||||
function redactSignedYouTubeMediaUrlToken(token: string): string {
|
||||
const { core, trailing } = splitTrailingUrlPunctuation(token);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(core);
|
||||
} catch {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (!isSignedYouTubeMediaUrl(parsed)) return token;
|
||||
|
||||
const queryStart = core.indexOf('?');
|
||||
const hashStart = core.indexOf('#', queryStart);
|
||||
if (queryStart === -1) return token;
|
||||
|
||||
const hash = hashStart === -1 ? '' : core.slice(hashStart);
|
||||
return `${core.slice(0, queryStart)}?${REDACTED_VALUE}${hash}${trailing}`;
|
||||
}
|
||||
|
||||
function redactSignedYouTubeMediaUrlQueries(text: string): string {
|
||||
return text.replace(URL_TOKEN_RE, (token: string) => redactSignedYouTubeMediaUrlToken(token));
|
||||
}
|
||||
|
||||
function redactSensitiveHeaders(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
SENSITIVE_HEADER_RE,
|
||||
(_match, lineStart: string, headerPrefix: string) =>
|
||||
`${lineStart}${headerPrefix}${REDACTED_VALUE}`,
|
||||
)
|
||||
.replace(
|
||||
SENSITIVE_INLINE_HEADER_RE,
|
||||
(_match, headerPrefix: string) => `${headerPrefix}${REDACTED_VALUE}`,
|
||||
);
|
||||
}
|
||||
|
||||
function redactYtDlpCookieArgs(text: string): string {
|
||||
return text
|
||||
.replace(YTDLP_COOKIE_EQUALS_RE, `$1${REDACTED_VALUE}`)
|
||||
.replace(YTDLP_COOKIE_SPACE_RE, `$1${REDACTED_VALUE}`);
|
||||
}
|
||||
|
||||
function findJsonScanEnd(text: string, start: number): number {
|
||||
const boundedEnd = Math.min(text.length, start + MAX_JSON_CANDIDATE_SCAN_CHARS);
|
||||
const newline = text.indexOf('\n', start);
|
||||
if (newline !== -1 && newline < boundedEnd) return newline;
|
||||
return boundedEnd;
|
||||
}
|
||||
|
||||
function findJsonEnd(text: string, start: number, endExclusive: number): number {
|
||||
const stack: string[] = [];
|
||||
let inString = false;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let index = start; index < endExclusive; index += 1) {
|
||||
const char = text[index];
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
inString = true;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
stack.push('}');
|
||||
} else if (char === '[') {
|
||||
stack.push(']');
|
||||
} else if (char === '}' || char === ']') {
|
||||
if (stack.pop() !== char) return -1;
|
||||
if (stack.length === 0) return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function redactJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactJsonValue(entry));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = isSensitiveValueKey(key) ? REDACTED_VALUE : redactJsonValue(entry);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function redactJsonPayloads(text: string): string {
|
||||
let output = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char !== '{' && char !== '[') continue;
|
||||
|
||||
const scanEnd = findJsonScanEnd(text, index);
|
||||
const end = findJsonEnd(text, index, scanEnd);
|
||||
if (end === -1) {
|
||||
index = Math.max(index, scanEnd - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = text.slice(index, end + 1);
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
output += text.slice(cursor, index);
|
||||
output += JSON.stringify(redactJsonValue(parsed));
|
||||
cursor = end + 1;
|
||||
index = end;
|
||||
} catch {
|
||||
// Non-JSON brace pairs remain available to the fallback key-value rules.
|
||||
}
|
||||
}
|
||||
|
||||
return `${output}${text.slice(cursor)}`;
|
||||
}
|
||||
|
||||
function redactSensitiveKeyValues(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
SENSITIVE_QUOTED_VALUE_RE,
|
||||
(match, keyQuote: string, key: string, separator: string, valueQuote: string) =>
|
||||
isSensitiveValueKey(key)
|
||||
? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${REDACTED_VALUE}${valueQuote}`
|
||||
: match,
|
||||
)
|
||||
.replace(SENSITIVE_UNQUOTED_VALUE_RE, (match, key: string, separator: string) => {
|
||||
if (!isSensitiveValueKey(key)) return match;
|
||||
const value = match.slice(key.length + separator.length);
|
||||
const quote = value[0];
|
||||
if (quote === '"' || quote === "'") {
|
||||
return `${key}${separator}${quote}${REDACTED_VALUE}${quote}`;
|
||||
}
|
||||
return `${key}${separator}${REDACTED_VALUE}`;
|
||||
});
|
||||
}
|
||||
|
||||
function redactHomePathUsernames(text: string): string {
|
||||
return text
|
||||
.replace(/(\/(?:home|Users)\/)([^/\r\n]+)(?=\/|$)/g, `$1${REDACTED_USER}`)
|
||||
.replace(/([A-Za-z]:[\\/]+Users[\\/]+)([^\\/:\r\n]+)(?=[\\/]|$)/g, `$1${REDACTED_USER}`);
|
||||
}
|
||||
|
||||
function normalizeIpCandidate(candidate: string): string {
|
||||
return candidate.split('%', 1)[0] ?? candidate;
|
||||
}
|
||||
|
||||
function isIpAddress(candidate: string): boolean {
|
||||
return net.isIP(normalizeIpCandidate(candidate)) !== 0;
|
||||
}
|
||||
|
||||
function redactIpAddresses(text: string): string {
|
||||
return text
|
||||
.replace(BRACKETED_IP_RE, (match, candidate: string) =>
|
||||
isIpAddress(candidate) ? `[${REDACTED_IP}]` : match,
|
||||
)
|
||||
.replace(IPV4_RE, (match, prefix: string, candidate: string) =>
|
||||
net.isIP(candidate) === 4 ? `${prefix}${REDACTED_IP}` : match,
|
||||
)
|
||||
.replace(IPV6_RE, (match, prefix: string, candidate: string) =>
|
||||
net.isIP(normalizeIpCandidate(candidate)) === 6 ? `${prefix}${REDACTED_IP}` : match,
|
||||
);
|
||||
}
|
||||
|
||||
export const LOG_EXPORT_REDACTION_RULES: readonly LogRedactionRule[] = [
|
||||
{
|
||||
name: 'signed-youtube-media-url-query',
|
||||
pattern: '*.googlevideo.com/videoplayback query strings',
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'pii-or-secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1&sig=secret',
|
||||
expected: `https://rr1---sn.example.googlevideo.com/videoplayback?${REDACTED_VALUE}`,
|
||||
},
|
||||
],
|
||||
redact: redactSignedYouTubeMediaUrlQueries,
|
||||
},
|
||||
{
|
||||
name: 'url-sensitive-components',
|
||||
pattern: 'URL tokens with credentials or sensitive query params',
|
||||
replacement: `${REDACTED_CREDENTIALS}, ${REDACTED_VALUE}`,
|
||||
risk: 'pii-or-secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'GET https://alice:secret@example.test/watch?access_token=tok123&state=ok',
|
||||
expected: `GET https://${REDACTED_CREDENTIALS}@example.test/watch?access_token=${REDACTED_VALUE}&state=ok`,
|
||||
},
|
||||
],
|
||||
redact: redactUrlSensitiveComponents,
|
||||
},
|
||||
{
|
||||
name: 'sensitive-headers',
|
||||
pattern: `headers: ${SENSITIVE_HEADER_NAMES}`,
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'Authorization: Bearer token',
|
||||
expected: `Authorization: ${REDACTED_VALUE}`,
|
||||
},
|
||||
],
|
||||
redact: redactSensitiveHeaders,
|
||||
},
|
||||
{
|
||||
name: 'yt-dlp-cookie-args',
|
||||
pattern: '--cookies and --cookies-from-browser arguments',
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'yt-dlp --cookies /Users/kyle/cookies.txt --verbose',
|
||||
expected: `yt-dlp --cookies ${REDACTED_VALUE} --verbose`,
|
||||
},
|
||||
],
|
||||
redact: redactYtDlpCookieArgs,
|
||||
},
|
||||
{
|
||||
name: 'json-secret-values',
|
||||
pattern: `JSON object keys: ${SENSITIVE_VALUE_KEY_PATTERNS}`,
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'json {"token":123,"safe":"ok"}',
|
||||
expected: `json {"token":"${REDACTED_VALUE}","safe":"ok"}`,
|
||||
},
|
||||
{
|
||||
input: 'json {"openaiApiKey":"sk-secret","safe":"ok"}',
|
||||
expected: `json {"openaiApiKey":"${REDACTED_VALUE}","safe":"ok"}`,
|
||||
},
|
||||
],
|
||||
redact: redactJsonPayloads,
|
||||
},
|
||||
{
|
||||
name: 'generic-sensitive-key-values',
|
||||
pattern: `key-value pairs: ${SENSITIVE_VALUE_KEY_PATTERNS}`,
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'refreshToken=abc123 state=ok',
|
||||
expected: `refreshToken=${REDACTED_VALUE} state=ok`,
|
||||
},
|
||||
{
|
||||
input: 'google_api_key=AIza-secret userPassword=hunter2',
|
||||
expected: `google_api_key=${REDACTED_VALUE} userPassword=${REDACTED_VALUE}`,
|
||||
},
|
||||
],
|
||||
redact: redactSensitiveKeyValues,
|
||||
},
|
||||
{
|
||||
name: 'home-path-usernames',
|
||||
pattern: 'Linux, macOS, and Windows home paths',
|
||||
replacement: REDACTED_USER,
|
||||
risk: 'pii',
|
||||
examples: [
|
||||
{
|
||||
input: '/Users/kyle/Library/Application Support/SubMiner',
|
||||
expected: `/Users/${REDACTED_USER}/Library/Application Support/SubMiner`,
|
||||
},
|
||||
],
|
||||
redact: redactHomePathUsernames,
|
||||
},
|
||||
{
|
||||
name: 'email-addresses',
|
||||
pattern: 'email-like addresses',
|
||||
replacement: REDACTED_EMAIL,
|
||||
risk: 'pii',
|
||||
examples: [
|
||||
{
|
||||
input: 'support kyle@example.test',
|
||||
expected: `support ${REDACTED_EMAIL}`,
|
||||
},
|
||||
],
|
||||
redact: (text) => text.replace(EMAIL_RE, REDACTED_EMAIL),
|
||||
},
|
||||
{
|
||||
name: 'ip-addresses',
|
||||
pattern: 'IPv4, bracketed IPv6, and bare IPv6 candidates validated with net.isIP',
|
||||
replacement: REDACTED_IP,
|
||||
risk: 'pii',
|
||||
examples: [
|
||||
{
|
||||
input: 'remote addr [2001:db8::1234]:443 and 203.0.113.42',
|
||||
expected: `remote addr [${REDACTED_IP}]:443 and ${REDACTED_IP}`,
|
||||
},
|
||||
],
|
||||
redact: redactIpAddresses,
|
||||
},
|
||||
];
|
||||
|
||||
export function redactLogExportText(text: string): string {
|
||||
return LOG_EXPORT_REDACTION_RULES.reduce((redacted, rule) => rule.redact(redacted), text);
|
||||
}
|
||||
@@ -212,6 +212,8 @@ test('generateAudio adds remote input options before the ffmpeg input', async ()
|
||||
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_on_network_error') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx');
|
||||
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
|
||||
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
|
||||
assert.equal(
|
||||
|
||||
+12
-1
@@ -65,7 +65,18 @@ export function normalizeMediaInput(input: MediaInput): NormalizedMediaInput {
|
||||
|
||||
const inputArgs: string[] = [];
|
||||
if (input.inputOptions?.reconnect) {
|
||||
inputArgs.push('-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5');
|
||||
inputArgs.push(
|
||||
'-reconnect',
|
||||
'1',
|
||||
'-reconnect_streamed',
|
||||
'1',
|
||||
'-reconnect_on_network_error',
|
||||
'1',
|
||||
'-reconnect_on_http_error',
|
||||
'403,5xx',
|
||||
'-reconnect_delay_max',
|
||||
'5',
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = trimToNonEmptyString(input.inputOptions?.userAgent);
|
||||
|
||||
@@ -12,6 +12,45 @@ import {
|
||||
resolveDefaultLogFilePath,
|
||||
} from './log-files';
|
||||
|
||||
function withTimeZone<T>(timeZone: string, run: () => T): T {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = timeZone;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withMockedNow<T>(isoDate: string, run: () => T): T {
|
||||
const RealDate = Date;
|
||||
const fixedMs = RealDate.parse(isoDate);
|
||||
class FixedDate extends RealDate {
|
||||
constructor(value?: string | number | Date) {
|
||||
if (arguments.length === 0) {
|
||||
super(fixedMs);
|
||||
} else {
|
||||
super(value as string);
|
||||
}
|
||||
}
|
||||
|
||||
static override now(): number {
|
||||
return fixedMs;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.Date = FixedDate as DateConstructor;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
globalThis.Date = RealDate;
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
||||
const now = new Date('2026-03-22T12:00:00.000Z');
|
||||
const resolved = resolveDefaultLogFilePath('app', {
|
||||
@@ -26,6 +65,36 @@ test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath uses the local date when UTC has advanced', () => {
|
||||
withTimeZone('America/Los_Angeles', () => {
|
||||
const now = new Date('2026-05-26T02:00:00.000Z');
|
||||
assert.equal(now.toISOString().slice(0, 10), '2026-05-26');
|
||||
|
||||
const resolved = resolveDefaultLogFilePath('app', {
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
now,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
resolved,
|
||||
path.join('/home/tester', '.config', 'SubMiner', 'logs', 'app-2026-05-25.log'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath rejects invalid dates', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveDefaultLogFilePath('app', {
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
now: new Date('invalid'),
|
||||
}),
|
||||
/Invalid time value/,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath uses daily filenames for mpv logs', () => {
|
||||
const now = new Date('2026-03-22T12:00:00.000Z');
|
||||
const resolved = resolveDefaultLogFilePath('mpv', {
|
||||
@@ -91,6 +160,34 @@ test('pruneLogFiles removes logs older than retention window', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('pruneLogDirectoryForPath deduplicates by local day across UTC midnight', () => {
|
||||
withTimeZone('America/Los_Angeles', () => {
|
||||
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-prune-local-day-'));
|
||||
const logPath = path.join(logsDir, 'app-2026-05-25.log');
|
||||
const firstStalePath = path.join(logsDir, 'app-first-stale.log');
|
||||
const secondStalePath = path.join(logsDir, 'app-second-stale.log');
|
||||
const staleDate = new Date('2026-05-01T12:00:00.000Z');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(firstStalePath, 'stale\n', 'utf8');
|
||||
fs.utimesSync(firstStalePath, staleDate, staleDate);
|
||||
|
||||
withMockedNow('2026-05-25T23:30:00.000Z', () => pruneLogDirectoryForPath(logPath, 7));
|
||||
|
||||
assert.equal(fs.existsSync(firstStalePath), false);
|
||||
|
||||
fs.writeFileSync(secondStalePath, 'stale\n', 'utf8');
|
||||
fs.utimesSync(secondStalePath, staleDate, staleDate);
|
||||
|
||||
withMockedNow('2026-05-26T00:30:00.000Z', () => pruneLogDirectoryForPath(logPath, 7));
|
||||
|
||||
assert.equal(fs.existsSync(secondStalePath), true);
|
||||
} finally {
|
||||
fs.rmSync(logsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('appendLogLine trims oversized logs to newest bytes', () => {
|
||||
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-trim-'));
|
||||
const logPath = path.join(logsDir, 'app.log');
|
||||
|
||||
+13
-2
@@ -36,6 +36,17 @@ function floorDiv(left: number, right: number): number {
|
||||
return Math.floor(left / right);
|
||||
}
|
||||
|
||||
function padDatePart(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function localDateKey(date: Date): string {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new RangeError('Invalid time value');
|
||||
}
|
||||
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`;
|
||||
}
|
||||
|
||||
function daysFromCivil(year: number, month: number, day: number): bigint {
|
||||
const adjustedYear = year - (month <= 2 ? 1 : 0);
|
||||
const era = floorDiv(adjustedYear >= 0 ? adjustedYear : adjustedYear - 399, 400);
|
||||
@@ -78,7 +89,7 @@ export function resolveDefaultLogFilePath(
|
||||
},
|
||||
): string {
|
||||
const now = options?.now ?? new Date();
|
||||
const suffix = now.toISOString().slice(0, 10);
|
||||
const suffix = localDateKey(now);
|
||||
return path.join(resolveLogBaseDir(options), 'logs', `${kind}-${suffix}.log`);
|
||||
}
|
||||
|
||||
@@ -167,7 +178,7 @@ export function pruneLogFiles(
|
||||
|
||||
function maybePruneLogDirectory(logPath: string, retentionDays: number): void {
|
||||
const logsDir = path.dirname(logPath);
|
||||
const key = `${logsDir}:${new Date().toISOString().slice(0, 10)}:${retentionDays}`;
|
||||
const key = `${logsDir}:${localDateKey(new Date())}:${retentionDays}`;
|
||||
if (prunedDirectories.has(key)) return;
|
||||
pruneLogFiles(logsDir, { retentionDays });
|
||||
prunedDirectories.add(key);
|
||||
|
||||
Reference in New Issue
Block a user