veodl 1.12.0 → 2.0.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +183 -100
  3. package/bin/veo.js +47 -2
  4. package/docs/AGENT_GUIDE.md +1 -0
  5. package/native/android-arm64/veo +0 -0
  6. package/native/darwin-arm64/veo +0 -0
  7. package/native/darwin-x64/veo +0 -0
  8. package/native/linux-arm64/veo +0 -0
  9. package/native/linux-x64/veo +0 -0
  10. package/native/win32-arm64/veo.exe +0 -0
  11. package/native/win32-x64/veo.exe +0 -0
  12. package/package.json +53 -61
  13. package/src/alias.js +0 -258
  14. package/src/backend-update.js +0 -191
  15. package/src/backend.js +0 -548
  16. package/src/changes.js +0 -278
  17. package/src/cli.js +0 -781
  18. package/src/compatibility.js +0 -39
  19. package/src/config-clipboard.js +0 -67
  20. package/src/config-diagnostics.js +0 -155
  21. package/src/config-editor.js +0 -404
  22. package/src/config-errors.js +0 -64
  23. package/src/config-reset.js +0 -39
  24. package/src/config-template.js +0 -227
  25. package/src/config.js +0 -307
  26. package/src/disk-space.js +0 -68
  27. package/src/doctor.js +0 -302
  28. package/src/download-cache.js +0 -31
  29. package/src/downloader.js +0 -669
  30. package/src/execution.js +0 -68
  31. package/src/experimental-fragments.js +0 -29
  32. package/src/flush.js +0 -66
  33. package/src/history.js +0 -173
  34. package/src/inline-region.js +0 -72
  35. package/src/inspect-media.js +0 -179
  36. package/src/interactive.js +0 -120
  37. package/src/jobs.js +0 -218
  38. package/src/legacy-config-comments.js +0 -106
  39. package/src/naming.js +0 -54
  40. package/src/open-file.js +0 -15
  41. package/src/option-suggestions.js +0 -43
  42. package/src/output.js +0 -81
  43. package/src/path-links.js +0 -53
  44. package/src/paths.js +0 -26
  45. package/src/playlist.js +0 -31
  46. package/src/progress.js +0 -341
  47. package/src/run-archive.js +0 -148
  48. package/src/runs.js +0 -346
  49. package/src/source-discovery.js +0 -303
  50. package/src/state.js +0 -27
  51. package/src/stats.js +0 -63
  52. package/src/terminal-size.js +0 -8
  53. package/src/terminal-title.js +0 -19
  54. package/src/tool-setup.js +0 -151
  55. package/src/uninstall.js +0 -222
  56. package/src/updater.js +0 -229
  57. package/src/utils.js +0 -208
  58. package/src/version.js +0 -38
@@ -1,227 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
-
3
- export const TEMPLATE_MARKER = '// veo: commented configuration template';
4
-
5
- export const SOURCE_OPTIONS_GUIDE = String.raw`// veo: source discovery options v1
6
- // Copy these settings into the global object or a named profile if desired.
7
- // "deepScan": true, // Verify every observed media candidate instead of the first 30
8
- // "timeout": "2m", // Discovery deadline: 5s to 10m; plain numbers mean seconds
9
- // "listSources": false, // true = list sources and exit for single-URL calls
10
- // "autoListSources": true, // Search after extraction fails; false disables fallback
11
- // Command-line flags override these values, including --no-deep-scan and --no-list-sources.
12
- `;
13
-
14
- const PROFILE_OPTIONS_MARKER = '// veo: profile download options v5';
15
- export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
16
- // Any global option can also be placed inside a profile.
17
- // Copy the settings you want into "profiles" > "default" or a named profile.
18
- // Remove the leading // on copied settings; separate entries with commas.
19
- // Speed without reducing the selected video quality:
20
- // "quality": "best",
21
- // "concurrentDownloads": 2, // Parallel URLs/batch items: 1-4; default 2
22
- // "adaptiveConcurrency": true, // Reduce parallelism and retry transient errors
23
- // "concurrentFragments": 8, // DASH/HLS fragments: 1-64; default 8
24
- // Values 17-64 are experimental and require terminal confirmation before download.
25
- // "experimentalFragments": false, // true explicitly accepts 17-64, including in scripts
26
- // More connections may trigger server limits and do not guarantee better speed.
27
- // "deepScan": true, // Check every observed source candidate
28
- // "timeout": "2m", // Source-search deadline, 5s to 10m
29
- // "listSources": false, // List sources and exit for one URL
30
- // "autoListSources": true, // Search after extraction fails (default)
31
- // "playlist": true, // Enable only for playlist downloads
32
- // "playlistConcurrency": 2, // Simultaneous entries: 1-4; default 2
33
- // "checkSpace": true, // Estimate free cache/output space first
34
- // "timings": true, // Show time spent in each phase
35
- // "color": true, // Gray details and colored results; false = plain text
36
- // Applies to veo's own terminal output, including stats/history/doctor.
37
- // Each profile can set color independently; NO_COLOR always disables styling.
38
- // Example inside profiles: "plain": { "color": false }
39
- // Use: veo stats --profile plain or veo URL --profile plain
40
- // "folderTemplate": "{channel}/{year}",
41
- // "filenameTemplate": "{index} - {title}", // Without extension; do not combine with rename
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
45
- // "skipExisting": true
46
- // "verify": false, // Probe saved media with FFprobe; no full decode
47
- // Lossless container change (the selected codecs must fit the container):
48
- // "format": "mkv",
49
- // "compatible": false, // Opt-in H.264/AAC MP4; converts only when needed
50
- // "recode": false
51
- // Compatibility profile to add inside profiles:
52
- // "kompatibel": { "audio": false, "format": "mp4", "compatible": true, "recode": false }
53
- // Use: veo URL --profile kompatibel (or simply veo URL --compatible).
54
- // Optional conversion instead: "format": "webm", "recode": true
55
- // Conversion can be slower and lose quality; recode is for video only.
56
- // Sequential downloads: "concurrentDownloads": 1, "concurrentFragments": 1, "playlistConcurrency": 1
57
- // Example named profile inside the profiles object:
58
- // "fast": { "quality": "best", "concurrentFragments": 8, "playlistConcurrency": 2, "resume": true }
59
- // Use with: veo URL --profile fast (add --playlist for a playlist URL).
60
- // Inspect: veo config show --profile fast | Validate: veo config check
61
- // Template fields: {title}, {id}, {channel}, {year}, {playlist}, {index}.
62
- // Missing values use Unknown channel/year, No playlist, unknown ID, index 001.
63
- `;
64
-
65
- const TEMPLATE_SOURCE = String.raw`${TEMPLATE_MARKER}
66
- // Save and close the editor. Settings apply the next time you run veo.
67
- // Comments using // or /* ... */ are supported. Command-line options take priority.
68
- // To enable an option, remove its leading // and adjust the value.
69
- // Separate active entries with commas. Do not add a comma after the last entry.
70
- {
71
- // Output directory: use / or double backslashes, for example "D:\\Videos".
72
- // Without output, downloads are saved in the current working directory.
73
- // "output": "D:/Videos",
74
- // Filename without extension. Every * inserts the original video title.
75
- // "rename": "movie_*",
76
-
77
- // Maximum video resolution: best, 2160p, 1440p, 1080p, 720p, ...
78
- // "quality": "1080p",
79
- // "closestQuality": false, // Allow the nearest available resolution
80
- // Video format: mp4, mkv, webm, mov. Lossless remux; codecs must fit the container.
81
- // "format": "mp4",
82
- // "recode": false, // Explicit video conversion; may lose quality
83
- // "playlistConcurrency": 2, // Concurrent playlist entries: 1 to 4
84
- // "playlistItems": "1-5", // Download only these playlist entries
85
- // "section": "*10:00-12:00", // Download only a time range
86
- // "open": false, // Open the completed file automatically
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
90
- // "skipExisting": true, // Skip previously saved downloads
91
- // "verify": false, // Check saved media container and tracks with FFprobe
92
- // "concurrentFragments": 8, // Concurrent fragments: 1 to 64; default 8
93
- // Values above 16 require confirmation or the explicit opt-in below.
94
- // "experimentalFragments": false, // true accepts experimental values 17-64
95
- // "cookies": "D:/cookies.txt", // Netscape-format cookies file
96
- // "cookiesFromBrowser": "firefox", // Use your browser's cookies
97
- // "sponsorblockRemove": "sponsor", // Remove matching SponsorBlock segments
98
- // "json": false, // Print machine-readable output for downloads
99
-
100
- ${SOURCE_OPTIONS_GUIDE}
101
-
102
- // Subtitles and additional information:
103
- // "subs": true, // Download subtitles; defaults to English
104
- // "autoSubs": false, // Include automatically generated subtitles
105
- // "subLangs": "de,en", // Enable subtitles for these languages
106
- // "subFormat": "srt/vtt/best", // Prefer these available subtitle formats
107
- // "embedSubs": true, // Embed subtitles in the video
108
- // "embedMetadata": true, // Embed the title, date and other metadata
109
- // "embedThumbnail": true, // Embed the thumbnail
110
-
111
- // The default profile is used automatically unless you select another profile.
112
- // Use veo profile NAME to make any named profile the default for future commands.
113
- // The active choice is stored at the top level: "activeProfile": "music",
114
- ${PROFILE_OPTIONS_GUIDE}
115
- // Select other profiles with --profile NAME or in the interactive wizard.
116
- // Example: veo "https://example.com/video.mp4" --profile music
117
- "profiles": {
118
- "default": {
119
- // "color": true, // Set false to disable terminal styling for this profile.
120
- // Add everyday defaults here, for example: "quality": "1080p"
121
- // Empty means use the global settings above and veo's built-in defaults.
122
- },
123
- "kompatibel": {
124
- "audio": false,
125
- "format": "mp4",
126
- "compatible": true,
127
- "recode": false
128
- },
129
- "music": {
130
- // Audio only. Formats: mp3, m4a, aac, opus, flac, wav.
131
- "audio": true,
132
- "format": "mp3"
133
- },
134
- "archive": {
135
- "quality": "1080p",
136
- "embedMetadata": true,
137
- "subLangs": "de,en"
138
- }
139
- }
140
- }
141
- `;
142
-
143
- // The guide revision follows its content so the editor can offer an update.
144
- const TEMPLATE_REVISION = createHash('sha256').update(TEMPLATE_SOURCE).digest('hex').slice(0, 12);
145
- const REFERENCE_BEGIN = '// veo: template reference begin';
146
- const REFERENCE_END = '// veo: template reference end';
147
- export const CONFIG_TEMPLATE = `{
148
- "profiles": {
149
- "default": {},
150
- "kompatibel": { "audio": false, "format": "mp4", "compatible": true, "recode": false },
151
- "music": { "audio": true, "format": "mp3" },
152
- "archive": { "quality": "1080p", "embedMetadata": true, "subLangs": "de,en" }
153
- }
154
- }
155
- `;
156
-
157
- // Kept in the program. It is inserted into a config only when requested.
158
- export const CONFIG_GUIDE = `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}\n// Current veo options. Copy only the settings you want into the JSON below.\n${TEMPLATE_SOURCE.split('\n').slice(1).map(line => line.startsWith('//') ? line : `// ${line}`).join('\n').trimEnd()}\n${REFERENCE_END}\n`;
159
-
160
- function generatedRange(text) {
161
- const begin = text.indexOf(REFERENCE_BEGIN);
162
- const end = begin < 0 ? -1 : text.indexOf(REFERENCE_END, begin);
163
- if (begin < 0) {
164
- const legacyHeader = /^\uFEFF?\/\/ veo: commented configuration template\r?\n\/\/ Reference: copy any examples/;
165
- const settingsMarker = '// Your existing settings:';
166
- const markerAt = legacyHeader.test(text) ? text.indexOf(settingsMarker) : -1;
167
- if (markerAt < 0) return null;
168
- const lineEnd = text.indexOf('\n', markerAt);
169
- return { start: 0, stop: lineEnd < 0 ? text.length : lineEnd + 1, revision: 'legacy' };
170
- }
171
- if (end < 0) return null;
172
- let start = begin;
173
- const before = text.slice(0, begin);
174
- if (/^\uFEFF?\/\/ veo: commented configuration template\r?\n$/.test(before)) start = 0;
175
- const lineEnd = text.indexOf('\n', end);
176
- let stop = lineEnd < 0 ? text.length : lineEnd + 1;
177
- if (text.slice(stop).startsWith('// Your existing settings:\n')) stop += '// Your existing settings:\n'.length;
178
- return { start, stop, revision: text.slice(begin, text.indexOf('\n', begin) < 0 ? text.length : text.indexOf('\n', begin)) };
179
- }
180
-
181
- export function configGuideState(text) {
182
- const range = generatedRange(text);
183
- if (!range) return text.includes(REFERENCE_BEGIN) || text.includes('// Reference: copy any examples') ? 'incomplete' : 'absent';
184
- return range.revision === `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}` ? 'current' : 'outdated';
185
- }
186
-
187
- export function addConfigGuide(text) {
188
- const range = generatedRange(text);
189
- if (!range && configGuideState(text) === 'incomplete') return text;
190
- const bom = text.startsWith('\uFEFF') ? '\uFEFF' : '';
191
- if (range) return (range.start === 0 ? bom : text.slice(0, range.start)) + CONFIG_GUIDE + text.slice(range.stop);
192
- return bom + CONFIG_GUIDE + text.slice(bom.length);
193
- }
194
-
195
- export function removeConfigGuide(text) {
196
- const range = generatedRange(text);
197
- return range ? (range.start === 0 && text.startsWith('\uFEFF') ? '\uFEFF' : text.slice(0, range.start)) + text.slice(range.stop) : text;
198
- }
199
-
200
- // Remove comments only outside JSON strings. Whitespace replacement preserves
201
- // token boundaries, so malformed input cannot become valid by joining tokens.
202
- export function stripConfigComments(text) {
203
- text = text.replace(/^\uFEFF/, '');
204
- let result = '', quoted = false;
205
- for (let i = 0; i < text.length; i++) {
206
- const char = text[i];
207
- if (quoted) {
208
- result += char;
209
- if (char === '\\' && i + 1 < text.length) result += text[++i];
210
- else if (char === '"') quoted = false;
211
- } else if (char === '"') { quoted = true; result += char; }
212
- else if (char === '/' && text[i + 1] === '/') {
213
- result += ' '; i++;
214
- while (i + 1 < text.length && !'\r\n'.includes(text[i + 1])) { result += ' '; i++; }
215
- } else if (char === '/' && text[i + 1] === '*') {
216
- const commentStart = i;
217
- result += ' '; i++;
218
- let closed = false;
219
- while (++i < text.length) {
220
- if (text[i] === '*' && text[i + 1] === '/') { result += ' '; i++; closed = true; break; }
221
- result += '\r\n'.includes(text[i]) ? text[i] : ' ';
222
- }
223
- if (!closed) throw Object.assign(new Error('Unterminated block comment; add */ to close it.'), { position: commentStart });
224
- } else result += char;
225
- }
226
- return result;
227
- }
package/src/config.js DELETED
@@ -1,307 +0,0 @@
1
- import { commandOutput } from './output.js';
2
- import { folderLink } from './path-links.js';
3
- import path from 'node:path';
4
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
- import { spawn } from 'node:child_process';
6
- import { configBase } from './paths.js';
7
- import { configSyntaxError } from './config-errors.js';
8
- import { CONFIG_TEMPLATE, CONFIG_GUIDE, stripConfigComments } from './config-template.js';
9
-
10
- /**
11
- * Keys accepted in the config file, with the CLI flag they act as a default for.
12
- * Everything here is a default only: an explicit command-line flag always wins.
13
- */
14
- export const CONFIG_KEYS = Object.freeze({
15
- concurrentDownloads: 'number',
16
- adaptiveConcurrency: 'boolean',
17
- filenameTemplate: 'string',
18
- folderTemplate: 'string',
19
- checkSpace: 'boolean',
20
- timings: 'boolean',
21
- color: 'boolean',
22
- output: 'string',
23
- quality: 'string',
24
- format: 'string',
25
- compatible: 'boolean',
26
- recode: 'boolean',
27
- playlistConcurrency: 'number',
28
- rename: 'string',
29
- audio: 'boolean',
30
- open: 'boolean',
31
- resume: 'boolean',
32
- incognito: 'boolean',
33
- neutralFilename: 'boolean',
34
- closestQuality: 'boolean',
35
- cookies: 'string',
36
- cookiesFromBrowser: 'string',
37
- playlist: 'boolean',
38
- concurrentFragments: 'number',
39
- experimentalFragments: 'boolean',
40
- subs: 'boolean',
41
- autoSubs: 'boolean',
42
- subLangs: 'string',
43
- subFormat: 'string',
44
- verify: 'boolean',
45
- embedSubs: 'boolean',
46
- embedMetadata: 'boolean',
47
- embedThumbnail: 'boolean',
48
- sponsorblockRemove: 'string',
49
- section: 'string',
50
- json: 'boolean',
51
- skipExisting: 'boolean',
52
- playlistItems: 'string',
53
- deepScan: 'boolean',
54
- timeout: 'string',
55
- listSources: 'boolean',
56
- autoListSources: 'boolean',
57
- });
58
-
59
- export const RESERVED_PROFILE_NAMES = Object.freeze(['list', 'reset']);
60
-
61
- export function configFile({ env = process.env, platform = process.platform } = {}) {
62
- const override = env.VEO_CONFIG;
63
- if (typeof override === 'string' && override.trim()) {
64
- if (override.includes('\0')) throw new Error('VEO_CONFIG must be a filesystem path.');
65
- return path.resolve(override.trim());
66
- }
67
- return path.join(configBase({ env, platform }), 'config.json');
68
- }
69
-
70
- function typeOf(value) {
71
- if (value === null) return 'null';
72
- if (Array.isArray(value)) return 'array';
73
- return typeof value;
74
- }
75
-
76
- /**
77
- * Read optional user defaults. A missing file is not an error; a malformed one
78
- * is reported loudly, because silently ignoring a typo would be worse.
79
- */
80
- export async function loadConfig({ env = process.env, file } = {}) {
81
- const target = file || configFile({ env });
82
- let text;
83
- try {
84
- text = await readFile(target, 'utf8');
85
- } catch (error) {
86
- if (error.code === 'ENOENT') return { file: target, exists: false, config: {}, warnings: [] };
87
- throw new Error(`Cannot read the veo config file ${target}: ${error.message}`);
88
- }
89
- return parseConfigText(text, target);
90
- }
91
-
92
- export function parseConfigText(text, target = 'config') {
93
- let data;
94
- let clean = text.replace(/^\uFEFF/, '');
95
- try {
96
- clean = stripConfigComments(text);
97
- data = JSON.parse(clean);
98
- } catch (error) {
99
- throw configSyntaxError(clean, target, error);
100
- }
101
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
102
- throw new Error(`The veo config file must contain a JSON object: ${target}`);
103
- }
104
- const warnings = [];
105
- const config = {};
106
- for (const [key, value] of Object.entries(data)) {
107
- if (key === '$schema') continue;
108
- if (key === 'activeProfile') {
109
- if (typeOf(value) !== 'string' || !value) throw new Error(`Config key "activeProfile" must be a non-empty string (${target}).`);
110
- config.activeProfile = value;
111
- continue;
112
- }
113
- if (key === 'profiles') {
114
- if (!value || typeOf(value) !== 'object') throw new Error('Config profiles must be an object.');
115
- config.profiles = Object.create(null);
116
- for (const [name, profile] of Object.entries(value)) {
117
- if (RESERVED_PROFILE_NAMES.includes(name)) throw new Error(`Profile name "${name}" is reserved for veo profile ${name}. Rename it in ${target}.`);
118
- if (!profile || typeOf(profile) !== 'object') throw new Error(`Profile "${name}" must be an object.`);
119
- const checked = {};
120
- for (const [setting, item] of Object.entries(profile)) {
121
- if (!Object.hasOwn(CONFIG_KEYS, setting)) throw new Error(`Unknown setting "${setting}" in profile "${name}".`);
122
- if (typeOf(item) !== CONFIG_KEYS[setting]) throw new Error(`Profile "${name}": "${setting}" must be a ${CONFIG_KEYS[setting]}.`);
123
- checked[setting] = item;
124
- }
125
- config.profiles[name] = checked;
126
- }
127
- continue;
128
- }
129
- const expected = CONFIG_KEYS[key];
130
- if (!expected) {
131
- warnings.push(`Unknown config key "${key}" in ${target} was ignored.`);
132
- continue;
133
- }
134
- if (typeOf(value) !== expected) {
135
- throw new Error(`Config key "${key}" must be a ${expected}, not a ${typeOf(value)} (${target}).`);
136
- }
137
- config[key] = value;
138
- }
139
- if (config.activeProfile && !Object.hasOwn(config.profiles || {}, config.activeProfile)) {
140
- throw new Error(`Unknown active profile "${config.activeProfile}" in ${target}. Available: ${Object.keys(config.profiles || {}).join(', ') || 'none'}.`);
141
- }
142
- return { file: target, exists: true, config, warnings };
143
- }
144
-
145
- export function selectedProfileName(config, name) {
146
- return name || config.activeProfile || (Object.hasOwn(config.profiles || {}, 'default') ? 'default' : undefined);
147
- }
148
-
149
- export function applyProfile(config, name) {
150
- const { profiles, activeProfile, ...defaults } = config;
151
- name = selectedProfileName(config, name);
152
- if (!name) return defaults;
153
- if (!profiles || !Object.hasOwn(profiles, name)) throw new Error(`Unknown profile "${name}". Available: ${Object.keys(profiles || {}).join(', ') || 'none'}. Use veo config edit.`);
154
- return { ...defaults, ...profiles[name] };
155
- }
156
-
157
- export async function profileMain(args, { file = configFile(), output = process.stdout } = {}) {
158
- if (args.length > 1 || args[0]?.startsWith('-')) throw new Error('Usage: veo profile [list|reset|NAME]');
159
- const loaded = await loadConfig({ file });
160
- if (!args.length) {
161
- output.write(`Active profile: ${selectedProfileName(loaded.config) || 'global (no profile)'}\n`);
162
- output.write(`Available: ${Object.keys(loaded.config.profiles || {}).join(', ') || 'none'}\n`);
163
- return 0;
164
- }
165
- if (args[0] === 'list' && args.length === 1) {
166
- const names = Object.keys(loaded.config.profiles || {});
167
- if (!names.length) output.write('No profiles configured. Use veo config edit.\n');
168
- else {
169
- const active = selectedProfileName(loaded.config);
170
- output.write(`Profiles:\n${active ? '' : '* global (no profile)\n'}${names.map(name => `${name === active ? '* ' : ' '}${name}${name === active ? ' (active)' : ''}`).join('\n')}\n`);
171
- }
172
- return 0;
173
- }
174
- const reset = args[0] === 'reset' && args.length === 1;
175
- const name = args[0];
176
- if (!reset) {
177
- applyProfile(loaded.config, name);
178
- await effectiveConfig(loaded.config, name);
179
- }
180
- if (!loaded.exists) {
181
- output.write('Active profile: global (no profile)\n');
182
- return 0;
183
- }
184
- const original = await readFile(file, 'utf8');
185
- // Token locations come from the comment-aware parser, preserving hand-written notes.
186
- const { configSource } = await import('./config-diagnostics.js');
187
- const { nodes } = configSource(original);
188
- const property = nodes.get(JSON.stringify(['activeProfile']));
189
- let updated;
190
- if (reset) {
191
- if (!property) updated = original;
192
- else {
193
- const clean = (original.startsWith('\uFEFF') ? ' ' : '') + stripConfigComments(original);
194
- let start = property.keyStart, end = property.end;
195
- while (/\s/.test(clean[end] || '')) end++;
196
- if (clean[end] === ',') end++;
197
- else {
198
- let before = start - 1;
199
- while (before >= 0 && /\s/.test(clean[before])) before--;
200
- if (clean[before] === ',') start = before;
201
- end = property.end;
202
- }
203
- updated = original.slice(0, start) + original.slice(end);
204
- }
205
- } else if (property) updated = original.slice(0, property.start) + JSON.stringify(name) + original.slice(property.end);
206
- else {
207
- const root = nodes.get('[]');
208
- const clean = (original.startsWith('\uFEFF') ? ' ' : '') + stripConfigComments(original);
209
- const first = root.start + 1 + clean.slice(root.start + 1, root.end - 1).search(/\S/);
210
- const empty = first === root.start;
211
- const newline = original.includes('\r\n') ? '\r\n' : '\n';
212
- const position = empty ? root.end - 1 : first;
213
- const indent = empty ? ' ' : /^[ \t]*$/.test(original.slice(original.lastIndexOf('\n', position - 1) + 1, position))
214
- ? original.slice(original.lastIndexOf('\n', position - 1) + 1, position) : '';
215
- updated = original.slice(0, position) + (empty ? `${newline}${indent}` : '') +
216
- `"activeProfile": ${JSON.stringify(name)}${empty ? `${newline}` : `,${newline}${indent}`}` + original.slice(position);
217
- }
218
- parseConfigText(updated, file);
219
- if (updated !== original) {
220
- if (await readFile(file, 'utf8') !== original) throw new Error('Config changed while switching profiles. Try again.');
221
- await writeFile(file, updated, 'utf8');
222
- }
223
- output.write(`Active profile: ${reset ? selectedProfileName(parseConfigText(updated, file).config) || 'global (no profile)' : name}\n`);
224
- return 0;
225
- }
226
-
227
- export async function prepareConfigEdit(file) {
228
- await mkdir(path.dirname(file), { recursive: true });
229
- try { await writeFile(file, CONFIG_TEMPLATE, { flag: 'wx', mode: 0o600 }); }
230
- catch (error) {
231
- if (error.code !== 'EEXIST') throw error;
232
- const original = await readFile(file, 'utf8');
233
- if (!original.replace(/^\uFEFF/, '').trim()) await writeFile(file, CONFIG_TEMPLATE, 'utf8');
234
- }
235
- }
236
-
237
- export async function configMain(args) {
238
- const editorColor = !args.includes('--no-color') && !Object.hasOwn(process.env, 'NO_COLOR');
239
- let stdout;
240
- [args, stdout] = commandOutput(args, process.stdout);
241
- if (args[0] === 'show' || args[0] === 'guide') stdout = process.stdout;
242
- const file = configFile();
243
- if (args[0] === 'reset') {
244
- if (args.length !== 1) throw new Error('Usage: veo config reset');
245
- return (await import('./config-reset.js')).resetConfig(file, { output: stdout });
246
- }
247
- if (['check', 'show'].includes(args[0])) {
248
- const { parseArgs } = await import('node:util');
249
- const { values, positionals } = parseArgs({ args: args.slice(1), allowPositionals: true, options: { profile: { type: 'string' } } });
250
- if (positionals.length) throw new Error('Usage: veo config check|show [--profile NAME]');
251
- const loaded = await loadConfig({ file });
252
- if (loaded.warnings.length) throw new Error(loaded.warnings.join('\n'));
253
- if (args[0] === 'show') {
254
- const effective = await effectiveConfig(loaded.config, values.profile);
255
- stdout.write(`${JSON.stringify(effective, null, 2)}\n`);
256
- } else {
257
- const names = values.profile ? [values.profile] : [undefined, ...Object.keys(loaded.config.profiles || {})];
258
- for (const name of names) {
259
- try { await effectiveConfig(loaded.config, name); }
260
- catch (error) { throw new Error(`Profile ${name || 'default/global'}: ${error.message}`); }
261
- }
262
- stdout.write(`Config OK: ${file} (${names.length} effective configurations checked).\n`);
263
- }
264
- return 0;
265
- }
266
- let editorMode;
267
- if (args[0] === 'edit') {
268
- if (args.length > 2 || (args[1] && !['--external', '--terminal'].includes(args[1]))) throw new Error('Usage: veo config edit [--external|--terminal]');
269
- editorMode = args[1]?.slice(2) || process.env.VEO_CONFIG_EDITOR || 'auto';
270
- if (!['auto', 'external', 'terminal'].includes(editorMode)) throw new Error('VEO_CONFIG_EDITOR must be auto, external or terminal.');
271
- } else if (args.length !== 1 || !['path', 'profiles', 'guide'].includes(args[0])) throw new Error('Usage: veo config edit|path|profiles|guide|check|show|reset');
272
- if (args[0] === 'path') { stdout.write(`${folderLink(stdout, file)}\n`); return 0; }
273
- if (args[0] === 'guide') { stdout.write(CONFIG_GUIDE); return 0; }
274
- if (args[0] === 'profiles') {
275
- const loaded = await loadConfig();
276
- stdout.write(`${Object.keys(loaded.config.profiles || {}).join('\n') || 'No profiles configured. Use veo config edit.'}\n`);
277
- return 0;
278
- }
279
- await prepareConfigEdit(file);
280
- if (editorMode === 'terminal' || (editorMode === 'auto' && !process.env.VISUAL && !process.env.EDITOR && process.stdin.isTTY && process.stdout.isTTY)) {
281
- const { editConfig } = await import('./config-editor.js');
282
- await editConfig(file, { color: editorColor });
283
- return 0;
284
- }
285
- // Treat the editor as an executable path, never as shell code.
286
- const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === 'win32' ? 'notepad.exe' : 'vi');
287
- await new Promise((resolve, reject) => {
288
- const child = spawn(editor, [file], { shell: false, stdio: 'inherit', windowsHide: true });
289
- child.on('error', () => reject(new Error(`Could not launch editor. Set EDITOR to an executable path or edit ${file}.`)));
290
- child.on('close', code => code === 0 ? resolve() : reject(new Error(`Editor exited with ${code}. Config: ${file}`)));
291
- });
292
- await loadConfig();
293
- return 0;
294
- }
295
-
296
- export async function effectiveConfig(config, profile) {
297
- const { parseCli } = await import('./cli.js');
298
- const selected = applyProfile(config, profile);
299
- // Inspection is offline and never prints credential file paths or browser profiles.
300
- const { cookies, cookiesFromBrowser, ...safe } = selected;
301
- const parsed = parseCli(['https://example.invalid/config-check'], { config: safe });
302
- const result = Object.fromEntries(Object.keys(CONFIG_KEYS).filter(key => parsed[key] !== undefined).map(key => [key, parsed[key]]));
303
- result.output = path.resolve(result.output);
304
- if (cookies !== undefined) result.cookies = '[configured]';
305
- if (cookiesFromBrowser !== undefined) result.cookiesFromBrowser = '[configured]';
306
- return result;
307
- }
package/src/disk-space.js DELETED
@@ -1,68 +0,0 @@
1
- import path from 'node:path';
2
- import { stat, statfs } from 'node:fs/promises';
3
-
4
- const reservations = new Map();
5
- export function estimateMediaBytes(metadata, options = {}) {
6
- const size = format => {
7
- const direct = format.filesize || format.filesize_approx;
8
- if (Number.isFinite(direct) && direct > 0) return direct;
9
- return Number.isFinite(format.tbr) && metadata.duration > 0 ? format.tbr * 1000 / 8 * metadata.duration : 0;
10
- };
11
- if (size(metadata)) return Math.ceil(size(metadata));
12
- const target = options.quality && options.quality !== 'best' ? parseInt(options.quality, 10) : Infinity;
13
- const formats = (metadata.formats || []).filter(format => !format.has_drm && (options.closestQuality || !format.height || format.height <= target));
14
- const video = formats.filter(format => format.vcodec !== 'none');
15
- const audio = formats.filter(format => format.vcodec === 'none' && format.acodec !== 'none');
16
- const bestVideo = Math.max(0, ...video.map(size));
17
- const bestAudio = Math.max(0, ...audio.map(size));
18
- if (options.audio) return bestAudio ? Math.ceil(bestAudio) : null;
19
- if (!bestVideo) return null;
20
- // Separate video needs an audio estimate too; otherwise the total is unknown.
21
- if (video.some(format => format.acodec === 'none') && !bestAudio) return null;
22
- return Math.ceil(bestVideo + bestAudio);
23
- }
24
-
25
- async function volume(directory) {
26
- let current = path.resolve(directory);
27
- while (true) {
28
- try {
29
- const info = await stat(current);
30
- if (info.isDirectory()) {
31
- const fs = await statfs(current);
32
- return { key: String(info.dev), free: fs.bavail * fs.bsize };
33
- }
34
- } catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error; }
35
- const parent = path.dirname(current);
36
- if (parent === current) throw new Error('No existing parent directory.');
37
- current = parent;
38
- }
39
- }
40
-
41
- export async function reserveSpace({ cache, destination, bytes, cached = false, reporter, inspect = volume } = {}) {
42
- let cacheVolume, outputVolume;
43
- try { [cacheVolume, outputVolume] = await Promise.all([inspect(cache), inspect(destination)]); }
44
- catch { reporter?.status('Free-space check unavailable on this filesystem; continuing.'); return () => {}; }
45
- if (!Number.isFinite(bytes) || bytes <= 0) {
46
- reporter?.status(`Size unknown; free space: cache ${(cacheVolume.free / 1073741824).toFixed(1)} GiB, destination ${(outputVolume.free / 1073741824).toFixed(1)} GiB.`);
47
- return () => {};
48
- }
49
- // Allow space for temporary merge output and destination copy. Conservative even
50
- // when the filesystem can hard-link; conversion sizes remain estimates.
51
- const needs = new Map();
52
- for (const [volume, amount] of [[cacheVolume, cached ? 0 : bytes * 2], [outputVolume, bytes]]) {
53
- const entry = needs.get(volume.key) || { free: volume.free, bytes: 0 };
54
- entry.free = Math.min(entry.free, volume.free); entry.bytes += amount;
55
- needs.set(volume.key, entry);
56
- }
57
- for (const [key, need] of needs) {
58
- const available = need.free - (reservations.get(key) || 0);
59
- if (available < need.bytes) throw new Error(`Not enough disk space: approximately ${(need.bytes / 1048576).toFixed(1)} MiB needed including temporary files; ${(Math.max(0, available) / 1048576).toFixed(1)} MiB available after active downloads.`);
60
- }
61
- for (const [key, need] of needs) reservations.set(key, (reservations.get(key) || 0) + need.bytes);
62
- let released = false;
63
- return () => {
64
- if (released) return;
65
- released = true;
66
- for (const [key, need] of needs) { const remaining = (reservations.get(key) || 0) - need.bytes; if (remaining > 0) reservations.set(key, remaining); else reservations.delete(key); }
67
- };
68
- }