veodl 1.8.2 → 1.9.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 +28 -0
- package/README.md +796 -725
- package/docs/AGENT_GUIDE.md +82 -66
- package/package.json +1 -2
- package/src/alias.js +258 -0
- package/src/cli.js +613 -454
- package/src/config-diagnostics.js +145 -144
- package/src/config-editor.js +285 -281
- package/src/config-template.js +224 -171
- package/src/config.js +217 -213
- package/src/doctor.js +1 -1
- package/src/downloader.js +601 -600
- package/src/interactive.js +115 -82
- package/src/output.js +1 -1
- package/src/runs.js +343 -333
- package/src/source-discovery.js +251 -0
- package/src/state.js +27 -27
- package/src/uninstall.js +222 -0
- package/src/updater.js +5 -3
|
@@ -1,144 +1,145 @@
|
|
|
1
|
-
import { CONFIG_KEYS, parseConfigText, effectiveConfig, applyProfile } from './config.js';
|
|
2
|
-
import { stripConfigComments } from './config-template.js';
|
|
3
|
-
import { QUALITIES, AUDIO_FORMATS, VIDEO_FORMATS } from './utils.js';
|
|
4
|
-
|
|
5
|
-
// Parse only after JSON.parse succeeds. Keep paths and source spans, including
|
|
6
|
-
// escaped property names and repeated settings in different profiles.
|
|
7
|
-
export function configSource(text) {
|
|
8
|
-
const clean = stripConfigComments(text);
|
|
9
|
-
const data = JSON.parse(clean), nodes = new Map();
|
|
10
|
-
const tokens = [...clean.matchAll(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\]:,]/g)];
|
|
11
|
-
let index = 0;
|
|
12
|
-
const bom = text.startsWith('\uFEFF') ? 1 : 0;
|
|
13
|
-
const visit = (path, keyToken) => {
|
|
14
|
-
const first = tokens[index++];
|
|
15
|
-
if (first[0] === '{') {
|
|
16
|
-
while (tokens[index][0] !== '}') {
|
|
17
|
-
const key = tokens[index++]; index++; // colon
|
|
18
|
-
visit([...path, JSON.parse(key[0])], key);
|
|
19
|
-
if (tokens[index][0] === ',') index++;
|
|
20
|
-
}
|
|
21
|
-
index++;
|
|
22
|
-
} else if (first[0] === '[') {
|
|
23
|
-
let item = 0;
|
|
24
|
-
while (tokens[index][0] !== ']') {
|
|
25
|
-
visit([...path, item++]);
|
|
26
|
-
if (tokens[index][0] === ',') index++;
|
|
27
|
-
}
|
|
28
|
-
index++;
|
|
29
|
-
}
|
|
30
|
-
const last = tokens[index - 1];
|
|
31
|
-
nodes.set(JSON.stringify(path), { path, start: first.index + bom, end: last.index + last[0].length + bom,
|
|
32
|
-
keyStart: keyToken?.index + bom, keyEnd: keyToken ? keyToken.index + keyToken[0].length + bom : undefined });
|
|
33
|
-
};
|
|
34
|
-
visit([]);
|
|
35
|
-
return { data, nodes };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function distance(a, b) {
|
|
39
|
-
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
40
|
-
for (let i = 0; i < a.length; i++) {
|
|
41
|
-
const next = [i + 1];
|
|
42
|
-
for (let j = 0; j < b.length; j++) next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (a[i] !== b[j])));
|
|
43
|
-
row = next;
|
|
44
|
-
}
|
|
45
|
-
return row[b.length];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const keys = Object.keys(CONFIG_KEYS);
|
|
49
|
-
function suggestion(key, choices) {
|
|
50
|
-
const ranked = choices.map(value => [value, distance(key.toLowerCase(), value.toLowerCase())]).sort((a, b) => a[1] - b[1]);
|
|
51
|
-
return ranked[0]?.[1] <= 2 ? ranked[0][0] : null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function located(text, node, message, property = false, choices = []) {
|
|
55
|
-
const start = (property ? node?.keyStart : node?.start) ?? 0;
|
|
56
|
-
const end = (property ? node?.keyEnd : node?.end) ?? start + 1;
|
|
57
|
-
return { message, start, end, line: text.slice(0, start).split('\n').length,
|
|
58
|
-
column: start - text.lastIndexOf('\n', start - 1), choices };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function valueChoices(key, config = {}) {
|
|
62
|
-
if (CONFIG_KEYS[key] === 'boolean') return [true, false];
|
|
63
|
-
if (key === 'quality') return QUALITIES;
|
|
64
|
-
if (key === 'format') return config.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
|
|
65
|
-
if (
|
|
66
|
-
if (
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
1
|
+
import { CONFIG_KEYS, parseConfigText, effectiveConfig, applyProfile } from './config.js';
|
|
2
|
+
import { stripConfigComments } from './config-template.js';
|
|
3
|
+
import { QUALITIES, AUDIO_FORMATS, VIDEO_FORMATS } from './utils.js';
|
|
4
|
+
|
|
5
|
+
// Parse only after JSON.parse succeeds. Keep paths and source spans, including
|
|
6
|
+
// escaped property names and repeated settings in different profiles.
|
|
7
|
+
export function configSource(text) {
|
|
8
|
+
const clean = stripConfigComments(text);
|
|
9
|
+
const data = JSON.parse(clean), nodes = new Map();
|
|
10
|
+
const tokens = [...clean.matchAll(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\]:,]/g)];
|
|
11
|
+
let index = 0;
|
|
12
|
+
const bom = text.startsWith('\uFEFF') ? 1 : 0;
|
|
13
|
+
const visit = (path, keyToken) => {
|
|
14
|
+
const first = tokens[index++];
|
|
15
|
+
if (first[0] === '{') {
|
|
16
|
+
while (tokens[index][0] !== '}') {
|
|
17
|
+
const key = tokens[index++]; index++; // colon
|
|
18
|
+
visit([...path, JSON.parse(key[0])], key);
|
|
19
|
+
if (tokens[index][0] === ',') index++;
|
|
20
|
+
}
|
|
21
|
+
index++;
|
|
22
|
+
} else if (first[0] === '[') {
|
|
23
|
+
let item = 0;
|
|
24
|
+
while (tokens[index][0] !== ']') {
|
|
25
|
+
visit([...path, item++]);
|
|
26
|
+
if (tokens[index][0] === ',') index++;
|
|
27
|
+
}
|
|
28
|
+
index++;
|
|
29
|
+
}
|
|
30
|
+
const last = tokens[index - 1];
|
|
31
|
+
nodes.set(JSON.stringify(path), { path, start: first.index + bom, end: last.index + last[0].length + bom,
|
|
32
|
+
keyStart: keyToken?.index + bom, keyEnd: keyToken ? keyToken.index + keyToken[0].length + bom : undefined });
|
|
33
|
+
};
|
|
34
|
+
visit([]);
|
|
35
|
+
return { data, nodes };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function distance(a, b) {
|
|
39
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
40
|
+
for (let i = 0; i < a.length; i++) {
|
|
41
|
+
const next = [i + 1];
|
|
42
|
+
for (let j = 0; j < b.length; j++) next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (a[i] !== b[j])));
|
|
43
|
+
row = next;
|
|
44
|
+
}
|
|
45
|
+
return row[b.length];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const keys = Object.keys(CONFIG_KEYS);
|
|
49
|
+
function suggestion(key, choices) {
|
|
50
|
+
const ranked = choices.map(value => [value, distance(key.toLowerCase(), value.toLowerCase())]).sort((a, b) => a[1] - b[1]);
|
|
51
|
+
return ranked[0]?.[1] <= 2 ? ranked[0][0] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function located(text, node, message, property = false, choices = []) {
|
|
55
|
+
const start = (property ? node?.keyStart : node?.start) ?? 0;
|
|
56
|
+
const end = (property ? node?.keyEnd : node?.end) ?? start + 1;
|
|
57
|
+
return { message, start, end, line: text.slice(0, start).split('\n').length,
|
|
58
|
+
column: start - text.lastIndexOf('\n', start - 1), choices };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function valueChoices(key, config = {}) {
|
|
62
|
+
if (CONFIG_KEYS[key] === 'boolean') return [true, false];
|
|
63
|
+
if (key === 'quality') return QUALITIES;
|
|
64
|
+
if (key === 'format') return config.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
|
|
65
|
+
if (key === 'timeout') return ['30s', '45s', '1m', '2m', '5m'];
|
|
66
|
+
if (['concurrentDownloads', 'playlistConcurrency'].includes(key)) return [1, 2, 3, 4];
|
|
67
|
+
if (key === 'concurrentFragments') return Array.from({ length: 16 }, (_, i) => i + 1);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function semanticIssue(text) {
|
|
72
|
+
const { data, nodes } = configSource(text);
|
|
73
|
+
const node = path => nodes.get(JSON.stringify(path));
|
|
74
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
75
|
+
if (!object(data)) return located(text, node([]), 'The config must contain a JSON object.');
|
|
76
|
+
const check = (settings, path) => {
|
|
77
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
78
|
+
if (!path.length && ['$schema', 'profiles'].includes(key)) continue;
|
|
79
|
+
const target = node([...path, key]);
|
|
80
|
+
if (!Object.hasOwn(CONFIG_KEYS, key)) {
|
|
81
|
+
const proposed = suggestion(key, path.length ? keys : [...keys, 'profiles', '$schema']);
|
|
82
|
+
return located(text, target, `Unknown setting "${key}".${proposed ? ` Did you mean "${proposed}"?` : ''}`, true, proposed ? [proposed] : []);
|
|
83
|
+
}
|
|
84
|
+
if (typeof value !== CONFIG_KEYS[key]) {
|
|
85
|
+
const choices = valueChoices(key, settings);
|
|
86
|
+
return located(text, target, `"${key}" must be a ${CONFIG_KEYS[key]}.${choices.length ? ` Choose: ${choices.join(', ')}.` : ''}`, false, choices);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
let issue = check(data, []);
|
|
92
|
+
if (issue) return issue;
|
|
93
|
+
if (Object.hasOwn(data, 'profiles')) {
|
|
94
|
+
if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
|
|
95
|
+
for (const [name, profile] of Object.entries(data.profiles)) {
|
|
96
|
+
if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
|
|
97
|
+
issue = check(profile, ['profiles', name]); if (issue) return issue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const { config } = parseConfigText(text);
|
|
101
|
+
for (const profile of [undefined, ...Object.keys(config.profiles || {})]) {
|
|
102
|
+
try { await effectiveConfig(config, profile); }
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = error.message;
|
|
105
|
+
let key = /Invalid quality/.test(message) ? 'quality' : /Invalid (?:audio|video) format/.test(message) ? 'format' :
|
|
106
|
+
/output directory/.test(message) ? 'output' : /custom filename/.test(message) ? 'rename' :
|
|
107
|
+
/--([a-z-]+)/.exec(message)?.[1]?.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
108
|
+
const name = profile ?? (config.profiles?.default ? 'default' : undefined);
|
|
109
|
+
const selected = applyProfile(config, profile);
|
|
110
|
+
// Template errors do not name the CLI option. Locate the failing template.
|
|
111
|
+
if (!key) {
|
|
112
|
+
const { validateTemplate } = await import('./naming.js');
|
|
113
|
+
for (const candidate of ['filenameTemplate', 'folderTemplate']) {
|
|
114
|
+
if (selected[candidate] !== undefined) {
|
|
115
|
+
try { validateTemplate(selected[candidate], { folders: candidate === 'folderTemplate' }); }
|
|
116
|
+
catch { key = candidate; break; }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const target = name !== undefined && Object.hasOwn(config.profiles[name], key) ? node(['profiles', name, key]) : node([key]);
|
|
121
|
+
const value = ['quality', 'format'].includes(key) ? `"${key}": unsupported value ${JSON.stringify(selected[key])}. ` : '';
|
|
122
|
+
return located(text, target || node([]), `${name === undefined ? '' : `Profile "${name}": `}${value}${message}`, false, valueChoices(key, selected));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function editorCompletions(text, cursor) {
|
|
129
|
+
try {
|
|
130
|
+
const { data, nodes } = configSource(text);
|
|
131
|
+
for (const item of nodes.values()) {
|
|
132
|
+
const path = item.path;
|
|
133
|
+
if (!(path.length === 1 || (path.length === 3 && path[0] === 'profiles'))) continue;
|
|
134
|
+
const key = path.at(-1), property = cursor >= item.keyStart && cursor <= item.keyEnd;
|
|
135
|
+
if (!property && !(cursor >= item.start && cursor <= item.end)) continue;
|
|
136
|
+
const profile = path.length === 3 ? path[1] : undefined;
|
|
137
|
+
const choices = property ? (path.length === 1 ? [...keys, 'profiles', '$schema'] : keys) : valueChoices(key, applyProfile(data, profile));
|
|
138
|
+
if (!choices.length) return null;
|
|
139
|
+
const proposed = property ? suggestion(key, choices) : null;
|
|
140
|
+
return { start: property ? item.keyStart : item.start, end: property ? item.keyEnd : item.end,
|
|
141
|
+
choices: proposed ? [proposed, ...choices.filter(value => value !== proposed)] : choices };
|
|
142
|
+
}
|
|
143
|
+
} catch { /* Syntax must be valid before offering safe token replacements. */ }
|
|
144
|
+
return null;
|
|
145
|
+
}
|