veodl 1.8.1
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/AGENTS.md +11 -0
- package/CHANGELOG.md +264 -0
- package/LICENSE +21 -0
- package/README.md +725 -0
- package/bin/veo.js +4 -0
- package/docs/AGENT_GUIDE.md +66 -0
- package/package.json +59 -0
- package/src/backend-update.js +191 -0
- package/src/backend.js +520 -0
- package/src/cli.js +454 -0
- package/src/compatibility.js +39 -0
- package/src/config-clipboard.js +67 -0
- package/src/config-diagnostics.js +144 -0
- package/src/config-editor.js +281 -0
- package/src/config-errors.js +64 -0
- package/src/config-reset.js +39 -0
- package/src/config-template.js +171 -0
- package/src/config.js +213 -0
- package/src/disk-space.js +68 -0
- package/src/doctor.js +302 -0
- package/src/download-cache.js +31 -0
- package/src/downloader.js +600 -0
- package/src/execution.js +68 -0
- package/src/flush.js +66 -0
- package/src/history.js +173 -0
- package/src/inspect-media.js +168 -0
- package/src/interactive.js +82 -0
- package/src/jobs.js +172 -0
- package/src/legacy-config-comments.js +106 -0
- package/src/naming.js +50 -0
- package/src/open-file.js +15 -0
- package/src/output.js +61 -0
- package/src/paths.js +26 -0
- package/src/playlist.js +31 -0
- package/src/progress.js +152 -0
- package/src/run-archive.js +148 -0
- package/src/runs.js +333 -0
- package/src/state.js +27 -0
- package/src/stats.js +63 -0
- package/src/terminal-title.js +19 -0
- package/src/tool-setup.js +151 -0
- package/src/updater.js +227 -0
- package/src/utils.js +208 -0
- package/src/version.js +38 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { translateLegacyConfigComments } from './legacy-config-comments.js';
|
|
2
|
+
|
|
3
|
+
export const TEMPLATE_MARKER = '// veo: commented configuration template';
|
|
4
|
+
|
|
5
|
+
const PROFILE_OPTIONS_MARKER = '// veo: profile download options v4';
|
|
6
|
+
export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
|
|
7
|
+
// Any global option can also be placed inside a profile.
|
|
8
|
+
// Copy the settings you want into "profiles" > "default" or a named profile.
|
|
9
|
+
// Remove the leading // on copied settings; separate entries with commas.
|
|
10
|
+
// Speed without reducing the selected video quality:
|
|
11
|
+
// "quality": "best",
|
|
12
|
+
// "concurrentDownloads": 2, // Parallel URLs/batch items: 1-4; default 2
|
|
13
|
+
// "adaptiveConcurrency": true, // Reduce parallelism and retry transient errors
|
|
14
|
+
// "concurrentFragments": 8, // DASH/HLS fragments: 1-16; default 8
|
|
15
|
+
// "playlist": true, // Enable only for playlist downloads
|
|
16
|
+
// "playlistConcurrency": 2, // Simultaneous entries: 1-4; default 2
|
|
17
|
+
// "checkSpace": true, // Estimate free cache/output space first
|
|
18
|
+
// "timings": true, // Show time spent in each phase
|
|
19
|
+
// "color": true, // Gray details and colored results; false = plain text
|
|
20
|
+
// Applies to veo's own terminal output, including stats/history/doctor.
|
|
21
|
+
// Each profile can set color independently; NO_COLOR always disables styling.
|
|
22
|
+
// Example inside profiles: "plain": { "color": false }
|
|
23
|
+
// Use: veo stats --profile plain or veo URL --profile plain
|
|
24
|
+
// "folderTemplate": "{channel}/{year}",
|
|
25
|
+
// "filenameTemplate": "{index} - {title}", // Without extension; do not combine with rename
|
|
26
|
+
// "resume": true,
|
|
27
|
+
// "skipExisting": true
|
|
28
|
+
// Lossless container change (the selected codecs must fit the container):
|
|
29
|
+
// "format": "mkv",
|
|
30
|
+
// "compatible": false, // Opt-in H.264/AAC MP4; converts only when needed
|
|
31
|
+
// "recode": false
|
|
32
|
+
// Compatibility profile to add inside profiles:
|
|
33
|
+
// "kompatibel": { "audio": false, "format": "mp4", "compatible": true, "recode": false }
|
|
34
|
+
// Use: veo URL --profile kompatibel (or simply veo URL --compatible).
|
|
35
|
+
// Optional conversion instead: "format": "webm", "recode": true
|
|
36
|
+
// Conversion can be slower and lose quality; recode is for video only.
|
|
37
|
+
// Sequential downloads: "concurrentDownloads": 1, "concurrentFragments": 1, "playlistConcurrency": 1
|
|
38
|
+
// Example named profile inside the profiles object:
|
|
39
|
+
// "fast": { "quality": "best", "concurrentFragments": 8, "playlistConcurrency": 2, "resume": true }
|
|
40
|
+
// Use with: veo URL --profile fast (add --playlist for a playlist URL).
|
|
41
|
+
// Inspect: veo config show --profile fast | Validate: veo config check
|
|
42
|
+
// Template fields: {title}, {id}, {channel}, {year}, {playlist}, {index}.
|
|
43
|
+
// Missing values use Unknown channel/year, No playlist, unknown ID, index 001.
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
export const CONFIG_TEMPLATE = String.raw`${TEMPLATE_MARKER}
|
|
47
|
+
// Save and close the editor. Settings apply the next time you run veo.
|
|
48
|
+
// Comments using // or /* ... */ are supported. Command-line options take priority.
|
|
49
|
+
// To enable an option, remove its leading // and adjust the value.
|
|
50
|
+
// Separate active entries with commas. Do not add a comma after the last entry.
|
|
51
|
+
{
|
|
52
|
+
// Output directory: use / or double backslashes, for example "D:\\Videos".
|
|
53
|
+
// Without output, downloads are saved in the current working directory.
|
|
54
|
+
// "output": "D:/Videos",
|
|
55
|
+
// Filename without extension. Every * inserts the original video title.
|
|
56
|
+
// "rename": "movie_*",
|
|
57
|
+
|
|
58
|
+
// Maximum video resolution: best, 2160p, 1440p, 1080p, 720p, ...
|
|
59
|
+
// "quality": "1080p",
|
|
60
|
+
// Video format: mp4, mkv, webm, mov. Lossless remux; codecs must fit the container.
|
|
61
|
+
// "format": "mp4",
|
|
62
|
+
// "recode": false, // Explicit video conversion; may lose quality
|
|
63
|
+
// "playlistConcurrency": 2, // Concurrent playlist entries: 1 to 4
|
|
64
|
+
// "open": false, // Open the completed file automatically
|
|
65
|
+
// "resume": true, // Resume interrupted downloads
|
|
66
|
+
// "skipExisting": true, // Skip previously saved downloads
|
|
67
|
+
// "concurrentFragments": 8, // Concurrent fragments: 1 to 16; default 8
|
|
68
|
+
|
|
69
|
+
// Subtitles and additional information:
|
|
70
|
+
// "subLangs": "de,en", // Enable subtitles for these languages
|
|
71
|
+
// "embedSubs": true, // Embed subtitles in the video
|
|
72
|
+
// "embedMetadata": true, // Embed the title, date and other metadata
|
|
73
|
+
// "embedThumbnail": true, // Embed the thumbnail
|
|
74
|
+
|
|
75
|
+
// The default profile is used automatically unless you select another profile.
|
|
76
|
+
${PROFILE_OPTIONS_GUIDE}
|
|
77
|
+
// Select other profiles with --profile NAME or in the interactive wizard.
|
|
78
|
+
// Example: veo "https://example.com/video.mp4" --profile music
|
|
79
|
+
"profiles": {
|
|
80
|
+
"default": {
|
|
81
|
+
// "color": true, // Set false to disable terminal styling for this profile.
|
|
82
|
+
// Add everyday defaults here, for example: "quality": "1080p"
|
|
83
|
+
// Empty means use the global settings above and veo's built-in defaults.
|
|
84
|
+
},
|
|
85
|
+
"kompatibel": {
|
|
86
|
+
"audio": false,
|
|
87
|
+
"format": "mp4",
|
|
88
|
+
"compatible": true,
|
|
89
|
+
"recode": false
|
|
90
|
+
},
|
|
91
|
+
"music": {
|
|
92
|
+
// Audio only. Formats: mp3, m4a, aac, opus, flac, wav.
|
|
93
|
+
"audio": true,
|
|
94
|
+
"format": "mp3"
|
|
95
|
+
},
|
|
96
|
+
"archive": {
|
|
97
|
+
"quality": "1080p",
|
|
98
|
+
"embedMetadata": true,
|
|
99
|
+
"subLangs": "de,en"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
// Remove comments only outside JSON strings. Whitespace replacement preserves
|
|
106
|
+
// token boundaries, so malformed input cannot become valid by joining tokens.
|
|
107
|
+
export function stripConfigComments(text) {
|
|
108
|
+
text = text.replace(/^\uFEFF/, '');
|
|
109
|
+
let result = '', quoted = false;
|
|
110
|
+
for (let i = 0; i < text.length; i++) {
|
|
111
|
+
const char = text[i];
|
|
112
|
+
if (quoted) {
|
|
113
|
+
result += char;
|
|
114
|
+
if (char === '\\' && i + 1 < text.length) result += text[++i];
|
|
115
|
+
else if (char === '"') quoted = false;
|
|
116
|
+
} else if (char === '"') { quoted = true; result += char; }
|
|
117
|
+
else if (char === '/' && text[i + 1] === '/') {
|
|
118
|
+
result += ' '; i++;
|
|
119
|
+
while (i + 1 < text.length && !'\r\n'.includes(text[i + 1])) { result += ' '; i++; }
|
|
120
|
+
} else if (char === '/' && text[i + 1] === '*') {
|
|
121
|
+
const commentStart = i;
|
|
122
|
+
result += ' '; i++;
|
|
123
|
+
let closed = false;
|
|
124
|
+
while (++i < text.length) {
|
|
125
|
+
if (text[i] === '*' && text[i + 1] === '/') { result += ' '; i++; closed = true; break; }
|
|
126
|
+
result += '\r\n'.includes(text[i]) ? text[i] : ' ';
|
|
127
|
+
}
|
|
128
|
+
if (!closed) throw Object.assign(new Error('Unterminated block comment; add */ to close it.'), { position: commentStart });
|
|
129
|
+
} else result += char;
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function withConfigTemplate(text) {
|
|
135
|
+
text = translateLegacyConfigComments(text);
|
|
136
|
+
text = addDefaultProfile(text);
|
|
137
|
+
if (!text.replace(/^\uFEFF/, '').trim()) return CONFIG_TEMPLATE;
|
|
138
|
+
if (text.replace(/^\uFEFF/, '').startsWith(TEMPLATE_MARKER)) {
|
|
139
|
+
// Upgrade the commented reference in older configs without changing settings.
|
|
140
|
+
if (!text.includes(PROFILE_OPTIONS_MARKER)) return text.replace(TEMPLATE_MARKER, `${TEMPLATE_MARKER}\n${PROFILE_OPTIONS_GUIDE}`);
|
|
141
|
+
return text;
|
|
142
|
+
}
|
|
143
|
+
// Keep existing settings and formatting byte-for-byte after a commented guide.
|
|
144
|
+
return `${TEMPLATE_MARKER}\n// Reference: copy any examples you need into your existing configuration below.\n${CONFIG_TEMPLATE.split('\n').slice(1).map(line => `// ${line}`).join('\n')}\n// Your existing settings:\n${text.replace(/^\uFEFF/, '')}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function addDefaultProfile(text) {
|
|
148
|
+
// Insert only the missing profile, preserving user comments and formatting.
|
|
149
|
+
let clean, config;
|
|
150
|
+
try { clean = stripConfigComments(text); config = JSON.parse(clean); } catch { return text; }
|
|
151
|
+
if (!config || Array.isArray(config) || typeof config !== 'object') return text;
|
|
152
|
+
if (config.profiles && Object.hasOwn(config.profiles, 'default')) return text;
|
|
153
|
+
if (config.profiles !== undefined && (!config.profiles || Array.isArray(config.profiles) || typeof config.profiles !== 'object')) return text;
|
|
154
|
+
text = text.replace(/^\uFEFF/, '');
|
|
155
|
+
// Track depth to find the top-level profiles object, never a profile named profiles.
|
|
156
|
+
let depth = 0;
|
|
157
|
+
const tokens = /"(?:\\.|[^"\\])*"|[{}]/g;
|
|
158
|
+
for (const token of clean.matchAll(tokens)) {
|
|
159
|
+
if (token[0] === '{') depth++;
|
|
160
|
+
else if (token[0] === '}') depth--;
|
|
161
|
+
else if (depth === 1 && token[0] === '"profiles"') {
|
|
162
|
+
const after = token.index + token[0].length;
|
|
163
|
+
const match = /^\s*:\s*\{/.exec(clean.slice(after));
|
|
164
|
+
if (!match) continue;
|
|
165
|
+
const index = after + match[0].length;
|
|
166
|
+
return text.slice(0, index) + '\n // Used automatically when no other profile is selected.\n "default": {}' + (Object.keys(config.profiles).length ? ',' : '') + '\n' + text.slice(index);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const index = clean.indexOf('{') + 1;
|
|
170
|
+
return text.slice(0, index) + '\n // Used automatically when no other profile is selected.\n "profiles": { "default": {} }' + (Object.keys(config).length ? ',' : '') + '\n' + text.slice(index);
|
|
171
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { commandOutput } from './output.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { configBase } from './paths.js';
|
|
6
|
+
import { configSyntaxError } from './config-errors.js';
|
|
7
|
+
import { CONFIG_TEMPLATE, stripConfigComments, withConfigTemplate } from './config-template.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keys accepted in the config file, with the CLI flag they act as a default for.
|
|
11
|
+
* Everything here is a default only: an explicit command-line flag always wins.
|
|
12
|
+
*/
|
|
13
|
+
export const CONFIG_KEYS = Object.freeze({
|
|
14
|
+
concurrentDownloads: 'number',
|
|
15
|
+
adaptiveConcurrency: 'boolean',
|
|
16
|
+
filenameTemplate: 'string',
|
|
17
|
+
folderTemplate: 'string',
|
|
18
|
+
checkSpace: 'boolean',
|
|
19
|
+
timings: 'boolean',
|
|
20
|
+
color: 'boolean',
|
|
21
|
+
output: 'string',
|
|
22
|
+
quality: 'string',
|
|
23
|
+
format: 'string',
|
|
24
|
+
compatible: 'boolean',
|
|
25
|
+
recode: 'boolean',
|
|
26
|
+
playlistConcurrency: 'number',
|
|
27
|
+
rename: 'string',
|
|
28
|
+
audio: 'boolean',
|
|
29
|
+
open: 'boolean',
|
|
30
|
+
resume: 'boolean',
|
|
31
|
+
closestQuality: 'boolean',
|
|
32
|
+
cookies: 'string',
|
|
33
|
+
cookiesFromBrowser: 'string',
|
|
34
|
+
playlist: 'boolean',
|
|
35
|
+
concurrentFragments: 'number',
|
|
36
|
+
subs: 'boolean',
|
|
37
|
+
subLangs: 'string',
|
|
38
|
+
embedSubs: 'boolean',
|
|
39
|
+
embedMetadata: 'boolean',
|
|
40
|
+
embedThumbnail: 'boolean',
|
|
41
|
+
sponsorblockRemove: 'string',
|
|
42
|
+
section: 'string',
|
|
43
|
+
json: 'boolean',
|
|
44
|
+
skipExisting: 'boolean',
|
|
45
|
+
playlistItems: 'string',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function configFile({ env = process.env } = {}) {
|
|
49
|
+
const override = env.VEO_CONFIG;
|
|
50
|
+
if (typeof override === 'string' && override.trim()) {
|
|
51
|
+
if (override.includes('\0')) throw new Error('VEO_CONFIG must be a filesystem path.');
|
|
52
|
+
return path.resolve(override.trim());
|
|
53
|
+
}
|
|
54
|
+
return path.join(configBase({ env }), 'config.json');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function typeOf(value) {
|
|
58
|
+
if (value === null) return 'null';
|
|
59
|
+
if (Array.isArray(value)) return 'array';
|
|
60
|
+
return typeof value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read optional user defaults. A missing file is not an error; a malformed one
|
|
65
|
+
* is reported loudly, because silently ignoring a typo would be worse.
|
|
66
|
+
*/
|
|
67
|
+
export async function loadConfig({ env = process.env, file } = {}) {
|
|
68
|
+
const target = file || configFile({ env });
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = await readFile(target, 'utf8');
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error.code === 'ENOENT') return { file: target, exists: false, config: {}, warnings: [] };
|
|
74
|
+
throw new Error(`Cannot read the veo config file ${target}: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
return parseConfigText(text, target);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseConfigText(text, target = 'config') {
|
|
80
|
+
let data;
|
|
81
|
+
let clean = text.replace(/^\uFEFF/, '');
|
|
82
|
+
try {
|
|
83
|
+
clean = stripConfigComments(text);
|
|
84
|
+
data = JSON.parse(clean);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw configSyntaxError(clean, target, error);
|
|
87
|
+
}
|
|
88
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
89
|
+
throw new Error(`The veo config file must contain a JSON object: ${target}`);
|
|
90
|
+
}
|
|
91
|
+
const warnings = [];
|
|
92
|
+
const config = {};
|
|
93
|
+
for (const [key, value] of Object.entries(data)) {
|
|
94
|
+
if (key === '$schema') continue;
|
|
95
|
+
if (key === 'profiles') {
|
|
96
|
+
if (!value || typeOf(value) !== 'object') throw new Error('Config profiles must be an object.');
|
|
97
|
+
config.profiles = Object.create(null);
|
|
98
|
+
for (const [name, profile] of Object.entries(value)) {
|
|
99
|
+
if (!profile || typeOf(profile) !== 'object') throw new Error(`Profile "${name}" must be an object.`);
|
|
100
|
+
const checked = {};
|
|
101
|
+
for (const [setting, item] of Object.entries(profile)) {
|
|
102
|
+
if (!Object.hasOwn(CONFIG_KEYS, setting)) throw new Error(`Unknown setting "${setting}" in profile "${name}".`);
|
|
103
|
+
if (typeOf(item) !== CONFIG_KEYS[setting]) throw new Error(`Profile "${name}": "${setting}" must be a ${CONFIG_KEYS[setting]}.`);
|
|
104
|
+
checked[setting] = item;
|
|
105
|
+
}
|
|
106
|
+
config.profiles[name] = checked;
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const expected = CONFIG_KEYS[key];
|
|
111
|
+
if (!expected) {
|
|
112
|
+
warnings.push(`Unknown config key "${key}" in ${target} was ignored.`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (typeOf(value) !== expected) {
|
|
116
|
+
throw new Error(`Config key "${key}" must be a ${expected}, not a ${typeOf(value)} (${target}).`);
|
|
117
|
+
}
|
|
118
|
+
config[key] = value;
|
|
119
|
+
}
|
|
120
|
+
return { file: target, exists: true, config, warnings };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function applyProfile(config, name) {
|
|
124
|
+
const { profiles, ...defaults } = config;
|
|
125
|
+
if (!name) {
|
|
126
|
+
if (!profiles || !Object.hasOwn(profiles, 'default')) return defaults;
|
|
127
|
+
name = 'default';
|
|
128
|
+
}
|
|
129
|
+
if (!profiles || !Object.hasOwn(profiles, name)) throw new Error(`Unknown profile "${name}". Available: ${Object.keys(profiles || {}).join(', ') || 'none'}. Use veo config edit.`);
|
|
130
|
+
return { ...defaults, ...profiles[name] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function prepareConfigEdit(file) {
|
|
134
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
135
|
+
try { await writeFile(file, CONFIG_TEMPLATE, { flag: 'wx', mode: 0o600 }); }
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code !== 'EEXIST') throw error;
|
|
138
|
+
const original = await readFile(file, 'utf8');
|
|
139
|
+
const annotated = withConfigTemplate(original);
|
|
140
|
+
if (annotated !== original) await writeFile(file, annotated, 'utf8');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function configMain(args) {
|
|
145
|
+
const editorColor = !args.includes('--no-color') && !Object.hasOwn(process.env, 'NO_COLOR');
|
|
146
|
+
let stdout;
|
|
147
|
+
[args, stdout] = commandOutput(args, process.stdout);
|
|
148
|
+
if (args[0] === 'show') stdout = process.stdout;
|
|
149
|
+
const file = configFile();
|
|
150
|
+
if (args[0] === 'reset') {
|
|
151
|
+
if (args.length !== 1) throw new Error('Usage: veo config reset');
|
|
152
|
+
return (await import('./config-reset.js')).resetConfig(file, { output: stdout });
|
|
153
|
+
}
|
|
154
|
+
if (['check', 'show'].includes(args[0])) {
|
|
155
|
+
const { parseArgs } = await import('node:util');
|
|
156
|
+
const { values, positionals } = parseArgs({ args: args.slice(1), allowPositionals: true, options: { profile: { type: 'string' } } });
|
|
157
|
+
if (positionals.length) throw new Error('Usage: veo config check|show [--profile NAME]');
|
|
158
|
+
const loaded = await loadConfig({ file });
|
|
159
|
+
if (loaded.warnings.length) throw new Error(loaded.warnings.join('\n'));
|
|
160
|
+
if (args[0] === 'show') {
|
|
161
|
+
const effective = await effectiveConfig(loaded.config, values.profile);
|
|
162
|
+
stdout.write(`${JSON.stringify(effective, null, 2)}\n`);
|
|
163
|
+
} else {
|
|
164
|
+
const names = values.profile ? [values.profile] : [undefined, ...Object.keys(loaded.config.profiles || {})];
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
try { await effectiveConfig(loaded.config, name); }
|
|
167
|
+
catch (error) { throw new Error(`Profile ${name || 'default/global'}: ${error.message}`); }
|
|
168
|
+
}
|
|
169
|
+
stdout.write(`Config OK: ${file} (${names.length} effective configurations checked).\n`);
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
let editorMode;
|
|
174
|
+
if (args[0] === 'edit') {
|
|
175
|
+
if (args.length > 2 || (args[1] && !['--external', '--terminal'].includes(args[1]))) throw new Error('Usage: veo config edit [--external|--terminal]');
|
|
176
|
+
editorMode = args[1]?.slice(2) || process.env.VEO_CONFIG_EDITOR || 'auto';
|
|
177
|
+
if (!['auto', 'external', 'terminal'].includes(editorMode)) throw new Error('VEO_CONFIG_EDITOR must be auto, external or terminal.');
|
|
178
|
+
} else if (args.length !== 1 || !['path', 'profiles'].includes(args[0])) throw new Error('Usage: veo config edit|path|profiles|check|show|reset');
|
|
179
|
+
if (args[0] === 'path') { stdout.write(`${file}\n`); return 0; }
|
|
180
|
+
if (args[0] === 'profiles') {
|
|
181
|
+
const loaded = await loadConfig();
|
|
182
|
+
stdout.write(`${Object.keys(loaded.config.profiles || {}).join('\n') || 'No profiles configured. Use veo config edit.'}\n`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
await prepareConfigEdit(file);
|
|
186
|
+
if (editorMode === 'terminal' || (editorMode === 'auto' && !process.env.VISUAL && !process.env.EDITOR && process.stdin.isTTY && process.stdout.isTTY)) {
|
|
187
|
+
const { editConfig } = await import('./config-editor.js');
|
|
188
|
+
await editConfig(file, { color: editorColor });
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
// Treat the editor as an executable path, never as shell code.
|
|
192
|
+
const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === 'win32' ? 'notepad.exe' : 'vi');
|
|
193
|
+
await new Promise((resolve, reject) => {
|
|
194
|
+
const child = spawn(editor, [file], { shell: false, stdio: 'inherit', windowsHide: true });
|
|
195
|
+
child.on('error', () => reject(new Error(`Could not launch editor. Set EDITOR to an executable path or edit ${file}.`)));
|
|
196
|
+
child.on('close', code => code === 0 ? resolve() : reject(new Error(`Editor exited with ${code}. Config: ${file}`)));
|
|
197
|
+
});
|
|
198
|
+
await loadConfig();
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function effectiveConfig(config, profile) {
|
|
203
|
+
const { parseCli } = await import('./cli.js');
|
|
204
|
+
const selected = applyProfile(config, profile);
|
|
205
|
+
// Inspection is offline and never prints credential file paths or browser profiles.
|
|
206
|
+
const { cookies, cookiesFromBrowser, ...safe } = selected;
|
|
207
|
+
const parsed = parseCli(['https://example.invalid/config-check'], { config: safe });
|
|
208
|
+
const result = Object.fromEntries(Object.keys(CONFIG_KEYS).filter(key => parsed[key] !== undefined).map(key => [key, parsed[key]]));
|
|
209
|
+
result.output = path.resolve(result.output);
|
|
210
|
+
if (cookies !== undefined) result.cookies = '[configured]';
|
|
211
|
+
if (cookiesFromBrowser !== undefined) result.cookiesFromBrowser = '[configured]';
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
}
|