veodl 1.10.1 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +48 -5
- package/docs/AGENT_GUIDE.md +5 -0
- package/package.json +1 -1
- package/src/cli.js +79 -15
- package/src/config-template.js +8 -0
- package/src/config.js +5 -0
- package/src/downloader.js +65 -14
- package/src/inspect-media.js +11 -0
- package/src/interactive.js +9 -4
- package/src/jobs.js +23 -10
- package/src/naming.js +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to veo. This project follows [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 1.11.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Incognito Downloads**: Added `--incognito` (and `"incognito": true` in configuration/profiles) to save media without creating run records, persistent retry jobs, run archives, download history, `.veo-history` records, or download statistics. Uses an isolated temporary staging directory and completely removes it upon completion, failure, or cancellation, and disables yt-dlp's filesystem cache.
|
|
10
|
+
- **Neutral Filenames**: Added `--neutral-filename` (and `"neutralFilename": true`) to generate unidentifiable random filenames (e.g. `video-a1b2c3d4e5f6.mp4`), suppressing video titles, channel names, and custom folder templates in filesystem paths.
|
|
11
|
+
- **Subtitle Inspection & Automatic Captions**:
|
|
12
|
+
- Added `veo subs <url>` (alias `--list-subs` / `--list-subtitles`) to inspect available manual and automatically generated subtitle tracks, languages, and source formats without downloading.
|
|
13
|
+
- Added `--auto-subs` (and `"autoSubs": true`) to download automatically generated captions/subtitles.
|
|
14
|
+
- Added `--sub-format <format>` (and `"subFormat": string`) to express a preferred subtitle source format (e.g. `srt/vtt/best`).
|
|
15
|
+
- **Post-Download Media Verification**: Added `--verify` (and `"verify": true`) to probe saved media files with FFprobe before recording success, verifying container integrity and stream presence. Retains saved files for inspection if verification fails.
|
|
16
|
+
|
|
5
17
|
## 1.10.1
|
|
6
18
|
|
|
7
19
|
### Added
|
package/README.md
CHANGED
|
@@ -71,7 +71,10 @@ Options:
|
|
|
71
71
|
-N, --concurrent-fragments <n> Parallel fragments, 1-64 (default: 8; 17-64 experimental)
|
|
72
72
|
--experimental-fragments Explicitly accept experimental values above 16
|
|
73
73
|
--subs Download subtitles (default languages: en)
|
|
74
|
-
--
|
|
74
|
+
--auto-subs Include automatically generated subtitles
|
|
75
|
+
--sub-langs <langs> Subtitle languages, e.g. "de,en" (manual unless --auto-subs)
|
|
76
|
+
--sub-format <format> Prefer subtitle formats, e.g. srt/vtt/best
|
|
77
|
+
--list-subs Show manual and automatic subtitle availability
|
|
75
78
|
--embed-subs Embed subtitles into the video file
|
|
76
79
|
--embed-metadata Embed title, date and other metadata
|
|
77
80
|
--embed-thumbnail Embed the thumbnail
|
|
@@ -80,9 +83,12 @@ Options:
|
|
|
80
83
|
--cookies <file> Netscape cookie file, for content you may access
|
|
81
84
|
--cookies-from-browser <browser[:profile]>
|
|
82
85
|
--resume Keep partial data and continue an interrupted download
|
|
86
|
+
--incognito Save media without run ID, history, stats or retry data
|
|
87
|
+
--neutral-filename Optional random filename instead of title and templates
|
|
83
88
|
--list-qualities Show available video resolutions and exit
|
|
84
89
|
--list-formats Show available formats and exit
|
|
85
90
|
--dry-run Show what would be downloaded and exit
|
|
91
|
+
--verify Probe saved media files with FFprobe (default: off)
|
|
86
92
|
--json One JSON object per URL instead of prose
|
|
87
93
|
--profile <name> Use a named profile from the config file
|
|
88
94
|
--batch-file <file> Read one URL per line (blank lines and # comments ignored)
|
|
@@ -292,8 +298,13 @@ video sites. All new options are also documented in `veo config guide`.
|
|
|
292
298
|
|
|
293
299
|
### Metadata and subtitles
|
|
294
300
|
|
|
295
|
-
-
|
|
296
|
-
|
|
301
|
+
- `veo subs <URL>` lists manual and automatically generated subtitles separately, including
|
|
302
|
+
their languages and source formats. `--json` returns one object without subtitle URLs.
|
|
303
|
+
- `--subs` writes manual subtitle files. `--auto-subs` adds generated subtitles, or selects
|
|
304
|
+
only generated subtitles when used alone. `--sub-langs de,en` selects languages;
|
|
305
|
+
`--sub-format srt/vtt/best` prefers available source formats in that order. A format
|
|
306
|
+
preference does not convert subtitles. `--embed-subs` embeds selected subtitles into
|
|
307
|
+
the video container.
|
|
297
308
|
- `--embed-metadata` and `--embed-thumbnail` use FFmpeg to write metadata and cover art.
|
|
298
309
|
- `--sponsorblock-remove sponsor,selfpromo` cuts sponsor segments (YouTube) and requires
|
|
299
310
|
FFmpeg; the cut re-encodes the affected parts.
|
|
@@ -301,6 +312,16 @@ video sites. All new options are also documented in `veo config guide`.
|
|
|
301
312
|
- `-N 8` downloads several fragments in parallel, which is noticeably faster on HLS/DASH
|
|
302
313
|
sources and heavier on the network.
|
|
303
314
|
|
|
315
|
+
### Optional post-download verification
|
|
316
|
+
|
|
317
|
+
Use `veo <URL> --verify` or set `"verify": true` in a config profile to check
|
|
318
|
+
saved media with FFprobe before recording a successful download. It checks that
|
|
319
|
+
the container can be probed and contains a video track (or an audio track for
|
|
320
|
+
`--audio`). Verification is off by default; `--no-verify` overrides a profile.
|
|
321
|
+
This quick check does not decode the whole file or prove that the audio is
|
|
322
|
+
audible. Use `veo inspect <file> --check-audio` for a full audio signal check.
|
|
323
|
+
If verification fails, veo reports failure and keeps the saved file for review.
|
|
324
|
+
|
|
304
325
|
### Access and credentials
|
|
305
326
|
|
|
306
327
|
veo downloads publicly accessible content. It does not bypass access controls, and it does
|
|
@@ -397,11 +418,11 @@ are not allowed.
|
|
|
397
418
|
}
|
|
398
419
|
```
|
|
399
420
|
|
|
400
|
-
Supported keys: `output`, `quality`, `format`, `rename`, `audio`, `open`, `resume`,
|
|
421
|
+
Supported keys: `output`, `quality`, `format`, `rename`, `audio`, `open`, `resume`, `incognito`, `neutralFilename`,
|
|
401
422
|
`closestQuality`, `cookies`, `cookiesFromBrowser`, `playlist`, `concurrentFragments`,
|
|
402
423
|
`experimentalFragments`, `playlistConcurrency`, `recode`, `compatible`, `concurrentDownloads`, `adaptiveConcurrency`,
|
|
403
424
|
`filenameTemplate`, `folderTemplate`, `checkSpace`, `timings`, `color`,
|
|
404
|
-
`subs`, `subLangs`, `embedSubs`, `embedMetadata`, `embedThumbnail`, `sponsorblockRemove`,
|
|
425
|
+
`subs`, `autoSubs`, `subLangs`, `subFormat`, `embedSubs`, `embedMetadata`, `embedThumbnail`, `sponsorblockRemove`, `verify`,
|
|
405
426
|
`section`, `json`. An explicit command-line flag always wins over a stored default. An
|
|
406
427
|
unknown key produces a warning; invalid JSON or a wrong value type is an error, because
|
|
407
428
|
silently ignoring a typo would be worse.
|
|
@@ -420,6 +441,27 @@ Additional defaults are `skipExisting` and `playlistItems`. Boolean defaults can
|
|
|
420
441
|
with `--no-open`, `--no-audio`, `--no-resume`, `--no-embed-metadata`, etc. `--no-subs` also
|
|
421
442
|
disables stored subtitle languages and subtitle embedding for that invocation.
|
|
422
443
|
|
|
444
|
+
### Incognito downloads
|
|
445
|
+
|
|
446
|
+
Use `veo <url> --incognito` or set `"incognito": true` in a named profile. The
|
|
447
|
+
downloaded media is saved normally. veo does not assign a run ID or write a
|
|
448
|
+
retry job, run archive, download history, output `.veo-history` record, or
|
|
449
|
+
statistics for that attempt. It uses a private temporary staging directory and
|
|
450
|
+
removes it after success, failure, or cancellation. It also disables yt-dlp's
|
|
451
|
+
filesystem cache. `--no-incognito` overrides
|
|
452
|
+
a profile for one request. Incognito cannot be combined with `--resume`,
|
|
453
|
+
`--skip-existing`, or `--retry-failed`; there is no veo retry for an incognito
|
|
454
|
+
attempt. A force-killed process may leave temporary files that the OS can
|
|
455
|
+
clean later. The destination file, shell history, network service, and source
|
|
456
|
+
website can still reveal the download.
|
|
457
|
+
|
|
458
|
+
Example profile: `"profiles": { "private": { "incognito": true } }`.
|
|
459
|
+
Add `"neutralFilename": true` to that profile, or pass `--neutral-filename`,
|
|
460
|
+
to save files as names such as `video-a1b2c3d4e5f6.mp4`. This is optional:
|
|
461
|
+
incognito keeps the normal title by default. Neutral naming overrides `--rename`,
|
|
462
|
+
`--filename-template`, and `--folder-template` for that request. The selected
|
|
463
|
+
output directory, media contents, and embedded metadata are unaffected.
|
|
464
|
+
|
|
423
465
|
### Named profiles
|
|
424
466
|
|
|
425
467
|
If no profile is selected, `profiles.default` is applied automatically. `veo profile music`
|
|
@@ -548,6 +590,7 @@ veo retry --last # retry the newest failed or unfinished job
|
|
|
548
590
|
veo runs # active runs with their id; veo runs <id> for details
|
|
549
591
|
veo runs --json # machine-readable active run metadata
|
|
550
592
|
veo inspect FILE --check-audio --json # track metadata and audio signal
|
|
593
|
+
veo subs URL --json # available manual and automatic subtitles
|
|
551
594
|
veo inspect run ID --check-audio --json # finished run and saved media
|
|
552
595
|
veo stop [id] # stop one run, or every active run
|
|
553
596
|
veo flush # stop runs, clear temporary downloads and retry jobs
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -68,6 +68,8 @@ veo retry --last --json
|
|
|
68
68
|
|
|
69
69
|
## Inspect saved media and audio
|
|
70
70
|
|
|
71
|
+
`veo subs <URL> --json` lists manual and automatic subtitle languages and formats without saving media. Select manual subtitles with `--subs --sub-langs de,en`, generated subtitles with `--auto-subs --sub-langs de,en`, and prefer a source format with `--sub-format srt/vtt/best`. `--verify` optionally probes saved media files before recording success; it is off by default and does not decode the full track.
|
|
72
|
+
|
|
71
73
|
```sh
|
|
72
74
|
veo inspect "/downloads/Example.mp4" --json
|
|
73
75
|
veo inspect "/downloads/Example.mp4" --check-audio --json
|
|
@@ -85,6 +87,9 @@ The run record is stored in veo's per-user cache on the device that ran the down
|
|
|
85
87
|
|
|
86
88
|
## Operational notes
|
|
87
89
|
|
|
90
|
+
- `veo <URL> --incognito --json` saves media without a run ID, retry job, history, run archive, output duplicate record or statistics. It disables yt-dlp's filesystem cache. A config profile can set `"incognito": true`; `--no-incognito` overrides it. Incognito cannot use `--resume`, `--skip-existing`, or retry. Its temporary staging data is removed after the attempt; the output file and records outside veo (such as shell history or server logs) still exist.
|
|
91
|
+
- Add `--neutral-filename` or `"neutralFilename": true` to use random media filenames instead of titles. This option is independent of `--incognito` and overrides custom file and folder naming for that request.
|
|
92
|
+
|
|
88
93
|
- Fragment concurrency defaults to 8. Values 1-16 need no confirmation; 17-64 are experimental and require terminal `y/N` confirmation before downloading. Scripts and JSON calls must explicitly accept with `--experimental-fragments` or `"experimentalFragments": true` in the config/profile. Values above 64 are always rejected. More connections may trigger server limits and do not guarantee higher speed. Dry-runs and source/format listings do not require confirmation.
|
|
89
94
|
- Example profile: `"experimental": { "concurrentFragments": 32, "experimentalFragments": true }`. Run with `veo <URL> --profile experimental`; use `--no-experimental-fragments` to revoke a stored opt-in.
|
|
90
95
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -48,7 +48,10 @@ Options:
|
|
|
48
48
|
--experimental-fragments Explicitly accept experimental values above 16
|
|
49
49
|
Otherwise requires terminal confirmation before download
|
|
50
50
|
--subs Download subtitles (default languages: en)
|
|
51
|
-
--
|
|
51
|
+
--auto-subs Include automatically generated subtitles
|
|
52
|
+
--sub-langs <langs> Subtitle languages, e.g. "de,en" (manual unless --auto-subs)
|
|
53
|
+
--sub-format <format> Preferred subtitle format, e.g. srt/vtt/best
|
|
54
|
+
--list-subs Show manual and automatic subtitle languages, then exit
|
|
52
55
|
--embed-subs Embed subtitles into the video file
|
|
53
56
|
--embed-metadata Embed title, date and other metadata
|
|
54
57
|
--embed-thumbnail Embed the thumbnail
|
|
@@ -59,6 +62,8 @@ Options:
|
|
|
59
62
|
--cookies-from-browser <browser[:profile]>
|
|
60
63
|
Read cookies from an installed browser
|
|
61
64
|
--resume Keep partial data and continue an interrupted download
|
|
65
|
+
--incognito Save media without run IDs, history, stats or retry data
|
|
66
|
+
--neutral-filename Use a random video name; omit title and naming templates
|
|
62
67
|
--list-qualities Show available video resolutions and exit
|
|
63
68
|
--list-qualitys Alias for --list-qualities
|
|
64
69
|
--list-formats Show the available formats and exit
|
|
@@ -68,6 +73,7 @@ Options:
|
|
|
68
73
|
--deep-scan Check every media candidate found during source search
|
|
69
74
|
--timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
|
|
70
75
|
--dry-run Show what would be downloaded and exit
|
|
76
|
+
--verify Probe saved media files with FFprobe (default: off)
|
|
71
77
|
--json Print one JSON object per URL instead of prose
|
|
72
78
|
--profile <name> Apply a named config profile
|
|
73
79
|
--batch-file <file> Read URLs from a file (one per line; # comments)
|
|
@@ -89,6 +95,7 @@ Commands:
|
|
|
89
95
|
veo history --failed --limit 20 Filter and extend download history
|
|
90
96
|
veo runs [id] List active runs; add --json for metadata and progress
|
|
91
97
|
veo inspect <file> Read media metadata; --check-audio measures audio signal
|
|
98
|
+
veo subs <url> List available manual and automatic subtitles
|
|
92
99
|
veo inspect run <id> Inspect saved files from a finished run by its id
|
|
93
100
|
veo stop [id] Stop one run, or every active run
|
|
94
101
|
veo alias list|add|remove Manage extra command names (wrappers calling veo)
|
|
@@ -128,6 +135,7 @@ const STRING_OPTIONS = {
|
|
|
128
135
|
'cookies-from-browser': {},
|
|
129
136
|
'concurrent-fragments': { short: 'N' },
|
|
130
137
|
'sub-langs': {},
|
|
138
|
+
'sub-format': {},
|
|
131
139
|
'sponsorblock-remove': {},
|
|
132
140
|
section: {},
|
|
133
141
|
profile: {},
|
|
@@ -149,8 +157,13 @@ const BOOLEAN_OPTIONS = {
|
|
|
149
157
|
open: {},
|
|
150
158
|
audio: {},
|
|
151
159
|
resume: {},
|
|
160
|
+
incognito: {},
|
|
161
|
+
'neutral-filename': {},
|
|
152
162
|
playlist: {},
|
|
153
163
|
subs: {},
|
|
164
|
+
'auto-subs': {},
|
|
165
|
+
'list-subs': {},
|
|
166
|
+
verify: {},
|
|
154
167
|
'embed-subs': {},
|
|
155
168
|
'embed-metadata': {},
|
|
156
169
|
'embed-thumbnail': {},
|
|
@@ -195,8 +208,13 @@ export function optionDefaults(config = {}) {
|
|
|
195
208
|
open: config.open ?? false,
|
|
196
209
|
audio: config.audio ?? false,
|
|
197
210
|
resume: config.resume ?? false,
|
|
211
|
+
incognito: config.incognito ?? false,
|
|
212
|
+
'neutral-filename': config.neutralFilename ?? false,
|
|
198
213
|
playlist: config.playlist ?? false,
|
|
199
214
|
subs: config.subs ?? false,
|
|
215
|
+
'auto-subs': config.autoSubs ?? false,
|
|
216
|
+
'sub-format': config.subFormat,
|
|
217
|
+
verify: config.verify ?? false,
|
|
200
218
|
'embed-subs': config.embedSubs ?? false,
|
|
201
219
|
'embed-metadata': config.embedMetadata ?? false,
|
|
202
220
|
'embed-thumbnail': config.embedThumbnail ?? false,
|
|
@@ -238,7 +256,7 @@ const COMMAND_FLAGS = {
|
|
|
238
256
|
config: ['--profile', '--external', '--terminal'],
|
|
239
257
|
profile: [],
|
|
240
258
|
};
|
|
241
|
-
const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
|
|
259
|
+
const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'subs', 'help', 'version'];
|
|
242
260
|
|
|
243
261
|
function mistypedCommandOption(args) {
|
|
244
262
|
const command = args[0];
|
|
@@ -296,9 +314,18 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
296
314
|
} else { validateItems(values['playlist-items']); values.playlist = true; }
|
|
297
315
|
}
|
|
298
316
|
if (values['retry-failed'] && (positionals.length || values['batch-file'])) throw new Error('--retry-failed cannot be combined with URLs or --batch-file.');
|
|
317
|
+
if (values.incognito && values['retry-failed']) throw new Error('--incognito cannot retry a saved job. Use --no-incognito for this request.');
|
|
318
|
+
if (values.incognito && values.resume) {
|
|
319
|
+
if (tokens.some(token => token.name === 'resume')) throw new Error('--incognito cannot be combined with --resume.');
|
|
320
|
+
values.resume = false;
|
|
321
|
+
}
|
|
322
|
+
if (values.incognito && values['skip-existing']) {
|
|
323
|
+
if (tokens.some(token => token.name === 'skip-existing')) throw new Error('--incognito cannot be combined with --skip-existing.');
|
|
324
|
+
values['skip-existing'] = false;
|
|
325
|
+
}
|
|
299
326
|
if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
|
|
300
327
|
if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
|
|
301
|
-
if (positionals.length > 1 && values.rename !== undefined && !values.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
|
|
328
|
+
if (!values['neutral-filename'] && positionals.length > 1 && values.rename !== undefined && !values.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
|
|
302
329
|
if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
|
|
303
330
|
if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
|
|
304
331
|
if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
|
|
@@ -306,11 +333,17 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
306
333
|
delete values['list-qualitys'];
|
|
307
334
|
if (values['list-qualities']) {
|
|
308
335
|
if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-qualities accepts exactly one URL.');
|
|
309
|
-
if (values['list-formats'] || values['list-sources']) throw new Error('--list-qualities cannot be combined with other listing actions.');
|
|
336
|
+
if (values['list-formats'] || values['list-sources'] || values['list-subs']) throw new Error('--list-qualities cannot be combined with other listing actions.');
|
|
310
337
|
if (values.audio || values.playlist) throw new Error('--list-qualities requires a single video without --audio or --playlist.');
|
|
311
338
|
}
|
|
312
339
|
if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
|
|
313
340
|
if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
341
|
+
if (values['list-subs']) {
|
|
342
|
+
if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-subs accepts exactly one URL.');
|
|
343
|
+
if (values['list-formats'] || values['list-sources']) throw new Error('--list-subs cannot be combined with other listing actions.');
|
|
344
|
+
if (values.playlist) throw new Error('--list-subs requires a single video without --playlist.');
|
|
345
|
+
}
|
|
346
|
+
if (values['sub-format'] && (!/^[a-z0-9]+(?:\/[a-z0-9]+)*$/i.test(values['sub-format']) || values['sub-format'].length > 100)) throw new Error('--sub-format must be a format or preference list such as srt/vtt/best.');
|
|
314
347
|
if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
|
|
315
348
|
if (values.source && values['list-sources'] && !tokens.some(token => token.name === 'list-sources')) values['list-sources'] = false;
|
|
316
349
|
if (values['list-sources'] && !tokens.some(token => token.name === 'list-sources')
|
|
@@ -356,12 +389,12 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
356
389
|
if (!Number.isInteger(options.concurrentDownloads) || options.concurrentDownloads < 1 || options.concurrentDownloads > 4) throw new Error('--concurrent-downloads must be a whole number between 1 and 4.');
|
|
357
390
|
if (options.filenameTemplate !== undefined) validateTemplate(options.filenameTemplate);
|
|
358
391
|
if (options.folderTemplate !== undefined) validateTemplate(options.folderTemplate, { folders: true });
|
|
359
|
-
if (options.rename && options.filenameTemplate) throw new Error('--rename and --filename-template cannot be combined.');
|
|
392
|
+
if (options.rename && options.filenameTemplate && !options.neutralFilename) throw new Error('--rename and --filename-template cannot be combined.');
|
|
360
393
|
const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
|
|
361
|
-
if (options.subLangs && !disableSubs) options.subs = true;
|
|
394
|
+
if (options.subLangs && !disableSubs && !options.autoSubs) options.subs = true;
|
|
362
395
|
if (disableSubs) {
|
|
363
|
-
options.subLangs = undefined;
|
|
364
396
|
options.embedSubs = false;
|
|
397
|
+
if (!options.autoSubs) options.subLangs = undefined;
|
|
365
398
|
}
|
|
366
399
|
options.profile = profileName;
|
|
367
400
|
return options;
|
|
@@ -373,9 +406,11 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
|
|
|
373
406
|
if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
|
|
374
407
|
let color = !args.includes('--no-color') && !args.includes('--json');
|
|
375
408
|
let display;
|
|
409
|
+
let loadedConfig;
|
|
376
410
|
try {
|
|
377
411
|
display = outputOptions(args);
|
|
378
412
|
const loaded = config ?? await loadConfig();
|
|
413
|
+
loadedConfig = loaded.config || {};
|
|
379
414
|
color = !args.includes('--json') && (display.color ?? applyProfile(loaded.config || {}, display.profile).color ?? true);
|
|
380
415
|
// Validate explicit profiles even when a color flag overrides their setting.
|
|
381
416
|
if (display.profile) applyProfile(loaded.config || {}, display.profile);
|
|
@@ -391,7 +426,12 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
|
|
|
391
426
|
// One throttled (once per day) update hint after every successful command.
|
|
392
427
|
// Failures, cancellations, help/version output and the update commands
|
|
393
428
|
// themselves never trigger it; VEO_NO_UPDATE_CHECK=1 disables it for scripts.
|
|
394
|
-
|
|
429
|
+
let incognitoRequest = !args.length;
|
|
430
|
+
if (!incognitoRequest && code === 0 && shouldUpdateNotice(args)) {
|
|
431
|
+
try { incognitoRequest = Boolean(parseCli(args, { config: loadedConfig }).incognito); }
|
|
432
|
+
catch { /* Non-download commands have their own argument parser. */ }
|
|
433
|
+
}
|
|
434
|
+
if (code === 0 && shouldUpdateNotice(args) && !incognitoRequest) {
|
|
395
435
|
try {
|
|
396
436
|
const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
|
|
397
437
|
if (notice) process.stderr.write(`${notice}\n`);
|
|
@@ -447,6 +487,17 @@ async function runMain(args, { config }) {
|
|
|
447
487
|
args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
|
|
448
488
|
} catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
449
489
|
}
|
|
490
|
+
if (args[0] === 'subs') {
|
|
491
|
+
if (args[1] === '--help' || args[1] === '-h') {
|
|
492
|
+
stdout.write('veo subs <url> [--json] [--source <n>] [--profile <name>]\n\nList manual and automatically generated subtitle languages and source formats without downloading media.\n');
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
if (!args[1] || args[1].startsWith('-')) {
|
|
496
|
+
stderr.write('veo: Usage: veo subs <url> [--json] [--source <n>]\n');
|
|
497
|
+
return 1;
|
|
498
|
+
}
|
|
499
|
+
args = [args[1], '--list-subs', ...args.slice(2)];
|
|
500
|
+
}
|
|
450
501
|
if (args[0] === 'profile') {
|
|
451
502
|
try { return await profileMain(args.slice(1)); }
|
|
452
503
|
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
@@ -509,9 +560,6 @@ async function runMain(args, { config }) {
|
|
|
509
560
|
process.once('SIGINT', cancel);
|
|
510
561
|
process.once('SIGTERM', cancel);
|
|
511
562
|
try {
|
|
512
|
-
if (!args.includes('--help') && !args.includes('-h') && !args.includes('--version')) {
|
|
513
|
-
run = await (await import('./runs.js')).registerRun(cancel);
|
|
514
|
-
}
|
|
515
563
|
const loaded = config ?? await loadConfig();
|
|
516
564
|
for (const warning of loaded.warnings || []) stderr.write(`veo: ${warning}\n`);
|
|
517
565
|
if (!args.length && process.stdin.isTTY && process.stderr.isTTY) {
|
|
@@ -526,6 +574,7 @@ async function runMain(args, { config }) {
|
|
|
526
574
|
stdout.write(`${pkg.version}\n`);
|
|
527
575
|
return 0;
|
|
528
576
|
}
|
|
577
|
+
if (!options.incognito) run = await (await import('./runs.js')).registerRun(cancel);
|
|
529
578
|
let retryItems;
|
|
530
579
|
if (options.retryFailed) {
|
|
531
580
|
retryItems = await retryOptions(options.retryFailed);
|
|
@@ -546,19 +595,19 @@ async function runMain(args, { config }) {
|
|
|
546
595
|
options.urls.push(...lines.map(validateUrl));
|
|
547
596
|
options.url = options.urls[0];
|
|
548
597
|
if (!options.urls.length) throw new Error('The URL list is empty.');
|
|
549
|
-
if (options.rename && options.urls.length > 1 && !options.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
|
|
598
|
+
if (!options.neutralFilename && options.rename && options.urls.length > 1 && !options.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
|
|
550
599
|
if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
551
600
|
}
|
|
552
601
|
await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
|
|
553
602
|
// The job file is created before the first download, so another terminal can
|
|
554
603
|
// follow this run's per-item progress with `veo runs <id>`.
|
|
555
|
-
const jobFile = options.listQualities || options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
|
|
604
|
+
const jobFile = options.incognito || options.listQualities || options.listFormats || options.listSources || options.listSubs || options.dryRun ? null : jobFilePath();
|
|
556
605
|
await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
|
|
557
606
|
audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
|
|
558
607
|
playlist: options.playlist, job: jobFile });
|
|
559
608
|
reporter.configure?.({ color: options.color && !options.json });
|
|
560
609
|
reporter.start(options.rename);
|
|
561
|
-
if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources) reporter.profile(options.profile);
|
|
610
|
+
if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources && !options.listSubs) reporter.profile(options.profile);
|
|
562
611
|
const preparedBackends = new Map();
|
|
563
612
|
const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
|
|
564
613
|
const cookieWarning = cookieFileWarning(options.cookies);
|
|
@@ -596,6 +645,20 @@ async function runMain(args, { config }) {
|
|
|
596
645
|
item.mediaUrl = discovery.mediaUrl;
|
|
597
646
|
}
|
|
598
647
|
|
|
648
|
+
if (options.listSubs) {
|
|
649
|
+
const { listSubtitles } = await import('./downloader.js');
|
|
650
|
+
const result = await listSubtitles(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
|
|
651
|
+
if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
652
|
+
else {
|
|
653
|
+
stdout.write(`Subtitles: ${cleanText(result.title || options.url)}\n`);
|
|
654
|
+
for (const [label, entries] of [['Manual', result.manual], ['Automatic', result.automatic]]) {
|
|
655
|
+
stdout.write(`${label}:\n`);
|
|
656
|
+
if (!entries.length) stdout.write(' none\n');
|
|
657
|
+
for (const entry of entries) stdout.write(` ${cleanText(entry.language)}${entry.name ? ` (${cleanText(entry.name)})` : ''}: ${entry.formats.join(', ') || 'unknown'}\n`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
599
662
|
if (options.listQualities) {
|
|
600
663
|
const { listQualities } = await import('./downloader.js');
|
|
601
664
|
const result = await listQualities(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
|
|
@@ -631,7 +694,8 @@ async function runMain(args, { config }) {
|
|
|
631
694
|
const { createHistoryRecorder } = await import('./history.js');
|
|
632
695
|
if (!options.json && process.stdout.isTTY) reporter.enableInline();
|
|
633
696
|
const result = await runJob(options, { download: (request, dependencies) => download(request, { ...dependencies, backendResolver: reuseBackend(request) }),
|
|
634
|
-
reporter, stdout: reporter.output(stdout), stderr: reporter.output(stderr), signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id,
|
|
697
|
+
reporter, stdout: reporter.output(stdout), stderr: reporter.output(stderr), signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id,
|
|
698
|
+
recordStats: options.incognito ? undefined : createStatsRecorder(), recordHistory: options.incognito ? undefined : createHistoryRecorder() });
|
|
635
699
|
return result;
|
|
636
700
|
} catch (error) {
|
|
637
701
|
reporter.fail(controller.signal.aborted);
|
package/src/config-template.js
CHANGED
|
@@ -40,7 +40,10 @@ export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
|
|
|
40
40
|
// "folderTemplate": "{channel}/{year}",
|
|
41
41
|
// "filenameTemplate": "{index} - {title}", // Without extension; do not combine with rename
|
|
42
42
|
// "resume": true,
|
|
43
|
+
// "incognito": false, // Save only media; no run ID, history, stats or retry data
|
|
44
|
+
// "neutralFilename": false, // Optional random filenames; ignores rename and naming templates
|
|
43
45
|
// "skipExisting": true
|
|
46
|
+
// "verify": false, // Probe saved media with FFprobe; no full decode
|
|
44
47
|
// Lossless container change (the selected codecs must fit the container):
|
|
45
48
|
// "format": "mkv",
|
|
46
49
|
// "compatible": false, // Opt-in H.264/AAC MP4; converts only when needed
|
|
@@ -82,7 +85,10 @@ const TEMPLATE_SOURCE = String.raw`${TEMPLATE_MARKER}
|
|
|
82
85
|
// "section": "*10:00-12:00", // Download only a time range
|
|
83
86
|
// "open": false, // Open the completed file automatically
|
|
84
87
|
// "resume": true, // Resume interrupted downloads
|
|
88
|
+
// "incognito": true, // Save only media; no run ID, history, stats or retry data
|
|
89
|
+
// "neutralFilename": false, // Set true for random filenames without title or template folders
|
|
85
90
|
// "skipExisting": true, // Skip previously saved downloads
|
|
91
|
+
// "verify": false, // Check saved media container and tracks with FFprobe
|
|
86
92
|
// "concurrentFragments": 8, // Concurrent fragments: 1 to 64; default 8
|
|
87
93
|
// Values above 16 require confirmation or the explicit opt-in below.
|
|
88
94
|
// "experimentalFragments": false, // true accepts experimental values 17-64
|
|
@@ -95,7 +101,9 @@ ${SOURCE_OPTIONS_GUIDE}
|
|
|
95
101
|
|
|
96
102
|
// Subtitles and additional information:
|
|
97
103
|
// "subs": true, // Download subtitles; defaults to English
|
|
104
|
+
// "autoSubs": false, // Include automatically generated subtitles
|
|
98
105
|
// "subLangs": "de,en", // Enable subtitles for these languages
|
|
106
|
+
// "subFormat": "srt/vtt/best", // Prefer these available subtitle formats
|
|
99
107
|
// "embedSubs": true, // Embed subtitles in the video
|
|
100
108
|
// "embedMetadata": true, // Embed the title, date and other metadata
|
|
101
109
|
// "embedThumbnail": true, // Embed the thumbnail
|
package/src/config.js
CHANGED
|
@@ -29,6 +29,8 @@ export const CONFIG_KEYS = Object.freeze({
|
|
|
29
29
|
audio: 'boolean',
|
|
30
30
|
open: 'boolean',
|
|
31
31
|
resume: 'boolean',
|
|
32
|
+
incognito: 'boolean',
|
|
33
|
+
neutralFilename: 'boolean',
|
|
32
34
|
closestQuality: 'boolean',
|
|
33
35
|
cookies: 'string',
|
|
34
36
|
cookiesFromBrowser: 'string',
|
|
@@ -36,7 +38,10 @@ export const CONFIG_KEYS = Object.freeze({
|
|
|
36
38
|
concurrentFragments: 'number',
|
|
37
39
|
experimentalFragments: 'boolean',
|
|
38
40
|
subs: 'boolean',
|
|
41
|
+
autoSubs: 'boolean',
|
|
39
42
|
subLangs: 'string',
|
|
43
|
+
subFormat: 'string',
|
|
44
|
+
verify: 'boolean',
|
|
40
45
|
embedSubs: 'boolean',
|
|
41
46
|
embedMetadata: 'boolean',
|
|
42
47
|
embedThumbnail: 'boolean',
|
package/src/downloader.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { resolveBackend } from './backend.js';
|
|
10
10
|
import { availableHeights, cappedHeight, closestHeight, saveUnique, sanitizeTitle } from './utils.js';
|
|
11
11
|
import { digest, readJson, writeJson } from './state.js';
|
|
12
|
+
import { isMediaFile } from './run-archive.js';
|
|
12
13
|
import { cleanupDownloadCache, downloadCacheRoot, TRANSFER_RETENTION_MS } from './download-cache.js';
|
|
13
14
|
import { selectedEntries, sizeEstimate, describeEstimate } from './playlist.js';
|
|
14
15
|
|
|
@@ -157,6 +158,8 @@ function downloadSettings(options) {
|
|
|
157
158
|
embedMetadata: false, embedThumbnail: false, ...options };
|
|
158
159
|
const settings = Object.fromEntries(['quality', 'audio', 'format', 'closestQuality', 'subs', 'subLangs', 'embedSubs',
|
|
159
160
|
'embedMetadata', 'embedThumbnail', 'sponsorblockRemove', 'section'].map(key => [key, options[key] ?? null]));
|
|
161
|
+
if (options.autoSubs) settings.autoSubs = true;
|
|
162
|
+
if (options.subFormat) settings.subFormat = options.subFormat;
|
|
160
163
|
// Existing conversion jobs retain their keys; remux requests must not reuse them.
|
|
161
164
|
if (options.format && !options.audio && !options.recode) settings.videoRemux = true;
|
|
162
165
|
if (options.compatible) settings.compatible = true;
|
|
@@ -229,6 +232,7 @@ function backendArgs(options, backend) {
|
|
|
229
232
|
'--socket-timeout', '30', '--retries', '3', '--fragment-retries', '3',
|
|
230
233
|
'--no-js-runtimes', '--js-runtimes', `node:${process.execPath}`,
|
|
231
234
|
'--ffmpeg-location', backend.ffmpegLocation];
|
|
235
|
+
if (options.incognito) common.push('--no-cache-dir');
|
|
232
236
|
if (!options.playlist) common.push('--no-playlist');
|
|
233
237
|
if (options._entryIndex) common.push('--playlist-items', String(options._entryIndex));
|
|
234
238
|
common.push('--concurrent-fragments', String(options.concurrentFragments ?? 8));
|
|
@@ -265,9 +269,12 @@ function mediaArgs(options, quality) {
|
|
|
265
269
|
// WebM keep their own source selection. Resolution remains the primary choice.
|
|
266
270
|
const selectedSort = options.recode || options.format === 'webm' ? quality.sort : sort;
|
|
267
271
|
if (!options.audio && selectedSort) args.push('-S', selectedSort);
|
|
268
|
-
if (options.subs || options.subLangs || options.embedSubs) {
|
|
269
|
-
args.push('--write-subs'
|
|
272
|
+
if (options.subs || options.autoSubs || options.subLangs || options.embedSubs) {
|
|
273
|
+
if (options.subs || (options.embedSubs && !options.autoSubs)) args.push('--write-subs');
|
|
274
|
+
if (options.autoSubs) args.push('--write-auto-subs');
|
|
275
|
+
args.push('--sub-langs', options.subLangs || 'en.*,en');
|
|
270
276
|
}
|
|
277
|
+
if (options.subFormat) args.push('--sub-format', options.subFormat);
|
|
271
278
|
if (options.embedSubs) args.push('--embed-subs');
|
|
272
279
|
if (options.embedMetadata) args.push('--embed-metadata');
|
|
273
280
|
if (options.embedThumbnail) args.push('--embed-thumbnail');
|
|
@@ -334,6 +341,19 @@ export async function listQualities(options, { signal, backendResolver = resolve
|
|
|
334
341
|
return { url: options.url, status: 'qualities', title: metadata.title || null, qualities };
|
|
335
342
|
}
|
|
336
343
|
|
|
344
|
+
/** List subtitle languages and source formats without exposing signed subtitle URLs. */
|
|
345
|
+
export async function listSubtitles(options, { signal, backendResolver = resolveBackend, runner = runBackend, reporter } = {}) {
|
|
346
|
+
const backend = await prepareBackend(options, { signal, backendResolver, reporter });
|
|
347
|
+
const metadata = await fetchMetadata({ ...options, playlist: false }, { signal, backend, runner, reporter });
|
|
348
|
+
const summarize = source => Object.entries(source || {}).map(([language, tracks]) => ({
|
|
349
|
+
language,
|
|
350
|
+
name: Array.isArray(tracks) ? tracks.find(track => typeof track?.name === 'string')?.name || null : null,
|
|
351
|
+
formats: Array.isArray(tracks) ? [...new Set(tracks.map(track => track?.ext).filter(ext => typeof ext === 'string' && /^[a-z0-9]+$/i.test(ext)))].sort() : [],
|
|
352
|
+
})).sort((a, b) => a.language.localeCompare(b.language));
|
|
353
|
+
return { url: options.url, status: 'subtitles', title: metadata.title || null,
|
|
354
|
+
manual: summarize(metadata.subtitles), automatic: summarize(metadata.automatic_captions) };
|
|
355
|
+
}
|
|
356
|
+
|
|
337
357
|
/** `veo --list-formats`: print the backend's own format table and stop. */
|
|
338
358
|
export async function listFormats(options, { signal, backendResolver = resolveBackend, runner = runBackend, reporter } = {}) {
|
|
339
359
|
const backend = await prepareBackend(options, { signal, backendResolver, reporter });
|
|
@@ -405,8 +425,9 @@ async function downloadCollection(options, metadata, dependencies) {
|
|
|
405
425
|
} catch (error) {
|
|
406
426
|
if (signal?.aborted) throw error;
|
|
407
427
|
failures.push({ index, error: error.message });
|
|
428
|
+
results[offset] = error.files || [];
|
|
408
429
|
reporter?.status(`Entry ${index} failed: ${error.message}`);
|
|
409
|
-
outcome = { index, status: 'failed', error: error.message };
|
|
430
|
+
outcome = { index, status: 'failed', error: error.message, files: error.files || [] };
|
|
410
431
|
}
|
|
411
432
|
await notify(outcome);
|
|
412
433
|
}
|
|
@@ -417,7 +438,9 @@ async function downloadCollection(options, metadata, dependencies) {
|
|
|
417
438
|
if (rejected) throw rejected.reason;
|
|
418
439
|
const files = results.flatMap(files => files || []);
|
|
419
440
|
failures.sort((a, b) => a.index - b.index);
|
|
420
|
-
return { url: options.url, title: metadata.title || metadata.id || 'Playlist', files, saved, skipped, failures,
|
|
441
|
+
return { url: options.url, title: metadata.title || metadata.id || 'Playlist', files, saved, skipped, failures,
|
|
442
|
+
...(options.verify && !failures.length && !skipped ? { verified: true } : {}),
|
|
443
|
+
...(entryTimings.length ? { entryTimings: entryTimings.sort((a, b) => a.index - b.index) } : {}),
|
|
421
444
|
status: failures.length ? 'failed' : saved ? 'saved' : 'skipped' };
|
|
422
445
|
}
|
|
423
446
|
|
|
@@ -425,7 +448,7 @@ async function downloadCollection(options, metadata, dependencies) {
|
|
|
425
448
|
* Download one URL. Returns every file that was saved, in the order the
|
|
426
449
|
* backend produced them, so collections and subtitle sidecars are reported.
|
|
427
450
|
*/
|
|
428
|
-
export async function download(options, { signal, reporter, backendResolver = resolveBackend, runner = runBackend, onEntry, localRoot = downloadCacheRoot(), skipCacheCleanup = false, adaptiveState = { divisor: 1 }, wait, diskChecker = reserveSpace, now, compatibilityRunner = runBackend } = {}) {
|
|
451
|
+
export async function download(options, { signal, reporter, backendResolver = resolveBackend, runner = runBackend, onEntry, localRoot = downloadCacheRoot(), skipCacheCleanup = false, adaptiveState = { divisor: 1 }, wait, diskChecker = reserveSpace, now, compatibilityRunner = runBackend, verifier } = {}) {
|
|
429
452
|
const timer = phaseTimer(now);
|
|
430
453
|
const timingResult = () => {
|
|
431
454
|
if (options.timings === false) return {};
|
|
@@ -438,13 +461,13 @@ export async function download(options, { signal, reporter, backendResolver = re
|
|
|
438
461
|
const stagingPath = path.join(localRoot, `${PARTIAL_PREFIX}${requestKey}`);
|
|
439
462
|
const existingStage = await lstat(stagingPath).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
440
463
|
if (existingStage && (!existingStage.isDirectory() || existingStage.isSymbolicLink())) throw new Error(`The partial download path is not a plain directory: ${stagingPath}`);
|
|
441
|
-
const cached = await readJson(path.join(stagingPath, 'job.json'), null);
|
|
464
|
+
const cached = options.incognito ? null : await readJson(path.join(stagingPath, 'job.json'), null);
|
|
442
465
|
const readyToTransfer = cached?.requestKey === requestKey && cached.ready?.length && cached.metadata
|
|
443
466
|
&& (cached.backendSucceeded === true || cached.compatibilityChecked === true || cached.files?.length);
|
|
444
467
|
const backend = readyToTransfer ? null : await prepareBackend(options, { signal, backendResolver, reporter });
|
|
445
468
|
timer.switch('metadata');
|
|
446
469
|
const metadata = readyToTransfer ? cached.metadata : await adaptiveRun(() => fetchMetadata(options, { signal, backend, runner, reporter }), { enabled: options.adaptiveConcurrency !== false, state: adaptiveState, signal, reporter, wait, timer });
|
|
447
|
-
if (metadata.entries && !options._entryIndex) return downloadCollection(options, metadata, { signal, reporter, backendResolver: async () => backend, runner, onEntry, localRoot, adaptiveState, wait, diskChecker, now, compatibilityRunner });
|
|
470
|
+
if (metadata.entries && !options._entryIndex) return downloadCollection(options, metadata, { signal, reporter, backendResolver: async () => backend, runner, onEntry, localRoot, adaptiveState, wait, diskChecker, now, compatibilityRunner, verifier });
|
|
448
471
|
const destination = mediaDestination(options, metadata, stagedTitle('media.mp4', metadata, options.rename));
|
|
449
472
|
directory = destination.directory;
|
|
450
473
|
await prepareDestination(options.output, directory);
|
|
@@ -456,10 +479,23 @@ export async function download(options, { signal, reporter, backendResolver = re
|
|
|
456
479
|
|
|
457
480
|
const key = partialKey(metadata, options.url, options);
|
|
458
481
|
const historyFile = path.join(directory, '.veo-history', `${key}.json`);
|
|
459
|
-
const history = options.skipExisting || (options.resume && options._entryIndex) ? await readJson(historyFile, null) : null;
|
|
460
|
-
if (await pruneHistoryRecord(historyFile, directory, history)) {
|
|
482
|
+
const history = !options.incognito && (options.skipExisting || (options.resume && options._entryIndex)) ? await readJson(historyFile, null) : null;
|
|
483
|
+
if (!options.incognito && await pruneHistoryRecord(historyFile, directory, history)) {
|
|
484
|
+
if (options.verify) {
|
|
485
|
+
const verifyFile = verifier || (await import('./inspect-media.js')).verifySavedMedia;
|
|
486
|
+
try {
|
|
487
|
+
const media = history.files.filter(isMediaFile);
|
|
488
|
+
if (!media.length) throw new Error('Verification failed: no saved media file was found.');
|
|
489
|
+
for (const file of media) await verifyFile(file, { audio: Boolean(options.audio), signal });
|
|
490
|
+
} catch (error) {
|
|
491
|
+
error.files = history.files;
|
|
492
|
+
error.verificationFailed = true;
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
461
496
|
reporter?.status('Already downloaded; skipped.');
|
|
462
|
-
return { url: options.url, title: metadata.title, files: [], status: 'skipped', saved: 0, skipped: 1,
|
|
497
|
+
return { url: options.url, title: metadata.title, files: [], status: 'skipped', saved: 0, skipped: 1,
|
|
498
|
+
...(options.verify ? { verified: true } : {}), ...timingResult() };
|
|
463
499
|
}
|
|
464
500
|
const staging = await prepareStaging(stagingPath);
|
|
465
501
|
const lockPath = path.join(staging, '.lock');
|
|
@@ -562,15 +598,30 @@ export async function download(options, { signal, reporter, backendResolver = re
|
|
|
562
598
|
files.push(saved);
|
|
563
599
|
files.push(...await saveSidecars(staging, resolved, saved, { signal, manifest, manifestFile }));
|
|
564
600
|
}
|
|
565
|
-
|
|
566
|
-
|
|
601
|
+
if (options.verify) {
|
|
602
|
+
const media = files.filter(isMediaFile);
|
|
603
|
+
if (!media.length) throw new Error('Verification failed: no saved media file was found.');
|
|
604
|
+
const verifyFile = verifier || (await import('./inspect-media.js')).verifySavedMedia;
|
|
605
|
+
try {
|
|
606
|
+
reporter?.status('Verifying saved media…');
|
|
607
|
+
for (const file of media) await verifyFile(file, { audio: Boolean(options.audio), signal });
|
|
608
|
+
reporter?.status('Media verification passed.');
|
|
609
|
+
} catch (error) {
|
|
610
|
+
error.files = files;
|
|
611
|
+
error.verificationFailed = true;
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (!options.incognito) await writeJson(historyFile, { version: 1, source: sourceKey(metadata, options.url), settings: downloadSettings(options), files });
|
|
616
|
+
return { url: options.url, title: metadata.title || metadata.id || 'video', files, status: 'saved', saved: 1, skipped: 0,
|
|
617
|
+
...(options.verify ? { verified: true } : {}), ...timingResult() };
|
|
567
618
|
} catch (error) {
|
|
568
619
|
if (!signal?.aborted) reporter?.failStep?.();
|
|
569
620
|
// A kept staging directory is the whole point of --resume, and cancellation
|
|
570
621
|
// is the most common reason to want one.
|
|
571
|
-
keepPartial = Boolean(options.resume || manifest?.ready?.length || unconfirmedOutput);
|
|
622
|
+
keepPartial = !options.incognito && !error.verificationFailed && Boolean(options.resume || manifest?.ready?.length || unconfirmedOutput);
|
|
572
623
|
if (manifest && unconfirmedOutput) manifest.unconfirmedOutput = true;
|
|
573
|
-
if (manifest?.ready?.length || (unconfirmedOutput && !options.resume)) {
|
|
624
|
+
if (keepPartial && (manifest?.ready?.length || (unconfirmedOutput && !options.resume))) {
|
|
574
625
|
manifest.expiresAt = Date.now() + TRANSFER_RETENTION_MS;
|
|
575
626
|
await writeJson(manifestFile, manifest);
|
|
576
627
|
}
|
package/src/inspect-media.js
CHANGED
|
@@ -92,6 +92,17 @@ export async function inspectMedia(file, { checkAudio = false, signal, runner =
|
|
|
92
92
|
return result;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/** A fast post-download container/stream check; it does not decode the full file. */
|
|
96
|
+
export async function verifySavedMedia(file, { audio = false, signal, inspect = inspectMedia } = {}) {
|
|
97
|
+
let report;
|
|
98
|
+
try { report = await inspect(file, { signal }); }
|
|
99
|
+
catch (error) { throw new Error(`Verification failed for ${file}: ${error.message}`); }
|
|
100
|
+
if (audio ? !report.hasAudioTrack : !report.hasVideoTrack) {
|
|
101
|
+
throw new Error(`Verification failed for ${file}: no ${audio ? 'audio' : 'video'} track found.`);
|
|
102
|
+
}
|
|
103
|
+
return report;
|
|
104
|
+
}
|
|
105
|
+
|
|
95
106
|
export async function inspectRun(id, { checkAudio = false, searchRoot, signal, root, inspect = inspectMedia } = {}) {
|
|
96
107
|
if (!RUN_ARCHIVE_ID.test(id)) throw new Error('Run ids have 6 letters or digits. List active runs with veo runs --json or finished attempts with veo history --json.');
|
|
97
108
|
const archive = await readRunArchive(id, root);
|
package/src/interactive.js
CHANGED
|
@@ -104,10 +104,15 @@ export async function interactiveArgs(config, { signal, input = process.stdin, o
|
|
|
104
104
|
if (type === 'video' && ['mp3', 'm4a', 'aac', 'opus', 'flac', 'wav'].includes(defaults.format)) args.push('--format', 'mp4');
|
|
105
105
|
if (type === 'audio' && ['mp4', 'mkv', 'webm', 'mov'].includes(defaults.format)) args.push('--format', 'mp3');
|
|
106
106
|
const directory = (await question(`Output directory [${defaults.output || process.cwd()}]: `)).trim() || defaults.output || process.cwd();
|
|
107
|
-
args.push('-o', directory
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
args.push('-o', directory);
|
|
108
|
+
if (defaults.incognito) {
|
|
109
|
+
args.push('--no-resume', '--no-skip-existing');
|
|
110
|
+
} else {
|
|
111
|
+
args.push('--resume');
|
|
112
|
+
const skipDefault = defaults.skipExisting === false ? 'n' : 'y';
|
|
113
|
+
const skip = await choose(`Skip previously downloaded videos? (y/n) [${skipDefault}]: `, ['y', 'n'], skipDefault);
|
|
114
|
+
args.push(skip === 'y' ? '--skip-existing' : '--no-skip-existing');
|
|
115
|
+
}
|
|
111
116
|
output.write(`Ready: ${type}, ${directory}\n`);
|
|
112
117
|
if (await choose('Start download? (y/n) [y]: ', ['y', 'n'], 'y') === 'n') return null;
|
|
113
118
|
return args;
|
package/src/jobs.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { runPool, serialQueue, reducedLimit, formatTimings } from './execution.js';
|
|
2
2
|
import { styleText, terminalText } from './progress.js';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { lstat, readdir } from 'node:fs/promises';
|
|
4
|
+
import { lstat, mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import { randomUUID } from 'node:crypto';
|
|
6
7
|
import { cacheBase } from './paths.js';
|
|
7
8
|
import { publicOptions, readJson, writeJson } from './state.js';
|
|
@@ -74,6 +75,13 @@ export async function latestFailedJob(root = cacheBase()) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export async function runJob(options, { download, reporter, signal, openFile, stdout = process.stdout, stderr = process.stderr, jobFile, items, recordStats, recordHistory, runId, archiveRoot } = {}) {
|
|
78
|
+
if (options.incognito) {
|
|
79
|
+
runId = undefined;
|
|
80
|
+
recordStats = undefined;
|
|
81
|
+
recordHistory = undefined;
|
|
82
|
+
}
|
|
83
|
+
const concurrency = options.concurrentDownloads ?? 2;
|
|
84
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('Concurrent downloads must be between 1 and 4.');
|
|
77
85
|
const file = jobFile || jobFilePath();
|
|
78
86
|
const requests = items || options.urls.map(url => ({ ...options, url }));
|
|
79
87
|
const job = { version: 1, ...(runId ? { runId } : {}), startedAt: new Date().toISOString(), options: publicOptions({ ...options, output: path.resolve(options.output) }),
|
|
@@ -81,17 +89,17 @@ export async function runJob(options, { download, reporter, signal, openFile, st
|
|
|
81
89
|
// Each retry item can have its own playlist selection.
|
|
82
90
|
job.items.forEach((item, index) => { item.options = publicOptions(requests[index]); });
|
|
83
91
|
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
84
|
-
await cleanupDownloadCache();
|
|
92
|
+
if (!options.incognito) await cleanupDownloadCache();
|
|
93
|
+
const privateRoot = options.incognito ? await mkdtemp(path.join(os.tmpdir(), 'veo-incognito-')) : null;
|
|
85
94
|
const queue = serialQueue();
|
|
86
|
-
const persist = () => queue(() => writeJson(file, job));
|
|
95
|
+
const persist = () => options.incognito ? Promise.resolve() : queue(() => writeJson(file, job));
|
|
87
96
|
await persist();
|
|
88
97
|
const activeTargets = new Map();
|
|
89
98
|
const totals = { saved: 0, skipped: 0, failed: 0 };
|
|
90
99
|
const adaptiveState = { divisor: 1 };
|
|
91
100
|
const archiveTools = runId ? await import('./run-archive.js') : null;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
await runPool(requests, () => reducedLimit(concurrency, adaptiveState), async (request, index) => {
|
|
101
|
+
try {
|
|
102
|
+
await runPool(requests, () => reducedLimit(concurrency, adaptiveState), async (request, index) => {
|
|
95
103
|
const targetKey = JSON.stringify([request.url, path.resolve(request.output)]);
|
|
96
104
|
const previous = activeTargets.get(targetKey);
|
|
97
105
|
let release;
|
|
@@ -133,7 +141,8 @@ export async function runJob(options, { download, reporter, signal, openFile, st
|
|
|
133
141
|
try {
|
|
134
142
|
item.status = 'running';
|
|
135
143
|
await persist();
|
|
136
|
-
const result = await download(request, { reporter: itemReporter, signal, adaptiveState, skipCacheCleanup: true,
|
|
144
|
+
const result = await download(request, { reporter: itemReporter, signal, adaptiveState, skipCacheCleanup: true,
|
|
145
|
+
...(privateRoot ? { localRoot: privateRoot } : {}), onEntry: async entry => {
|
|
137
146
|
await captureFiles(entry.files);
|
|
138
147
|
item.entries.push(entry);
|
|
139
148
|
await persist();
|
|
@@ -156,9 +165,10 @@ export async function runJob(options, { download, reporter, signal, openFile, st
|
|
|
156
165
|
item.status = signal?.aborted ? 'cancelled' : 'failed';
|
|
157
166
|
item.error = readableError(error);
|
|
158
167
|
if (!signal?.aborted) failed++;
|
|
159
|
-
item.files = item.entries.flatMap(entry => entry.files || []);
|
|
168
|
+
item.files = [...new Set([...item.entries.flatMap(entry => entry.files || []), ...(error.files || [])])];
|
|
160
169
|
if (options.json) stdout.write(`${JSON.stringify({ url: request.url, status: item.status, profile: request.profile || null, error: item.error, files: item.files, ...(runId ? { runId } : {}) })}\n`);
|
|
161
170
|
else stderr.write(styleText(stderr, terminalText(stderr, `veo: ${item.error} (${request.url})`), 'error', options.color !== false) + '\n');
|
|
171
|
+
if (error.verificationFailed && !options.json) for (const file of item.files) stderr.write(`Saved file retained: ${cleanText(file)}\n`);
|
|
162
172
|
}
|
|
163
173
|
if (recordStats) {
|
|
164
174
|
try {
|
|
@@ -180,7 +190,10 @@ export async function runJob(options, { download, reporter, signal, openFile, st
|
|
|
180
190
|
await persist();
|
|
181
191
|
totals.saved += saved; totals.skipped += skipped; totals.failed += failed;
|
|
182
192
|
} finally { release(); if (activeTargets.get(targetKey) === current) activeTargets.delete(targetKey); }
|
|
183
|
-
|
|
193
|
+
}, { signal }).catch(error => { if (!signal?.aborted) throw error; });
|
|
194
|
+
} finally {
|
|
195
|
+
if (privateRoot) await rm(privateRoot, { recursive: true, force: true });
|
|
196
|
+
}
|
|
184
197
|
const { saved, skipped, failed } = totals;
|
|
185
198
|
if (runId) {
|
|
186
199
|
try {
|
|
@@ -199,7 +212,7 @@ export async function runJob(options, { download, reporter, signal, openFile, st
|
|
|
199
212
|
}
|
|
200
213
|
if (Object.keys(totals).length) stderr.write(styleText(stderr, formatTimings(totals), 'muted', options.color !== false && !options.json) + '\n');
|
|
201
214
|
}
|
|
202
|
-
if (unfinished) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed ${runId || `"${file}"`}\n`);
|
|
215
|
+
if (unfinished && !options.incognito) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed ${runId || `"${file}"`}\n`);
|
|
203
216
|
if (unfinished) reporter.fail(Boolean(signal?.aborted)); else reporter.complete();
|
|
204
217
|
return signal?.aborted ? 130 : unfinished ? 1 : 0;
|
|
205
218
|
}
|
package/src/naming.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { lstat, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { sanitizeTitle } from './utils.js';
|
|
4
5
|
|
|
5
6
|
const FIELDS = new Set(['title', 'id', 'channel', 'year', 'playlist', 'index']);
|
|
@@ -17,6 +18,9 @@ export function validateTemplate(template, { folders = false } = {}) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function mediaDestination(options, metadata, fallbackTitle) {
|
|
21
|
+
if (options.neutralFilename) {
|
|
22
|
+
return { directory: path.resolve(options.output), title: `video-${randomBytes(6).toString('hex')}` };
|
|
23
|
+
}
|
|
20
24
|
const fields = {
|
|
21
25
|
title: metadata.title || metadata.id || 'video', id: metadata.id || 'unknown',
|
|
22
26
|
channel: metadata.channel || metadata.uploader || 'Unknown channel',
|