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
package/src/execution.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
// Workers finish active work before settling, including cancellation and failures.
|
|
4
|
+
export async function runPool(items, limit, work, { signal } = {}) {
|
|
5
|
+
let next = 0;
|
|
6
|
+
const maximum = typeof limit === 'function' ? limit : () => limit;
|
|
7
|
+
const workers = Array.from({ length: Math.min(maximum(), items.length) }, (_, worker) => (async () => {
|
|
8
|
+
while (next < items.length && worker < maximum()) {
|
|
9
|
+
signal?.throwIfAborted();
|
|
10
|
+
const index = next++;
|
|
11
|
+
await work(items[index], index);
|
|
12
|
+
}
|
|
13
|
+
})());
|
|
14
|
+
const results = await Promise.allSettled(workers);
|
|
15
|
+
const failure = results.find(result => result.status === 'rejected');
|
|
16
|
+
if (failure) throw failure.reason;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function serialQueue() {
|
|
20
|
+
let tail = Promise.resolve();
|
|
21
|
+
return work => {
|
|
22
|
+
const result = tail.then(work);
|
|
23
|
+
tail = result.catch(() => {});
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const reducedLimit = (limit, state) => Math.max(1, Math.floor(limit / (state?.divisor || 1)));
|
|
29
|
+
// yt-dlp reports a failed concurrent fragment fetch as a local ENOENT on the
|
|
30
|
+
// fragment temp file (e.g. "...media.mp4.part-Frag29"), hiding the server-side
|
|
31
|
+
// hiccup or expired segment URL behind it. Those are worth retrying with
|
|
32
|
+
// reduced fragment parallelism and a refetched playlist; anything else yt-dlp
|
|
33
|
+
// reports as "Unable to download video" (unavailable, private, ...) still
|
|
34
|
+
// fails fast so permanent failures do not burn repeated full downloads.
|
|
35
|
+
export function retryable(error) {
|
|
36
|
+
return /HTTP(?: Error)?\s*(?:429|5\d\d)|too many requests|timed? ?out|ECONNRESET|ECONNREFUSED|ETIMEDOUT|temporary failure|connection (?:reset|aborted)|\.part-Frag\d+/i.test(error?.message || '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function adaptiveRun(run, { enabled = true, state = { divisor: 1 }, signal, reporter, wait = delay, timer } = {}) {
|
|
40
|
+
for (let attempt = 0; ; attempt++) {
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
try { return await run(attempt); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!enabled || signal?.aborted || !retryable(error) || attempt >= 2) throw error;
|
|
45
|
+
state.divisor = Math.min(16, (state.divisor || 1) * 2);
|
|
46
|
+
const ms = 2000 * 2 ** attempt;
|
|
47
|
+
reporter?.status(`Source busy or connection interrupted; reducing parallelism and retrying in ${ms / 1000}s (${attempt + 1}/2).`);
|
|
48
|
+
const previous = timer?.phase;
|
|
49
|
+
timer?.switch('retryWait');
|
|
50
|
+
try { await wait(ms, undefined, { signal }); } finally { if (previous) timer?.switch(previous); }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function phaseTimer(now = () => performance.now()) {
|
|
56
|
+
const totals = {};
|
|
57
|
+
let phase = 'setup', start = now();
|
|
58
|
+
return {
|
|
59
|
+
get phase() { return phase; },
|
|
60
|
+
switch(next) { const end = now(); totals[phase] = (totals[phase] || 0) + end - start; phase = next; start = end; },
|
|
61
|
+
result() { this.switch(phase); return Object.fromEntries(Object.entries(totals).map(([key, value]) => [key, Math.round(value)])); },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatTimings(timings) {
|
|
66
|
+
const labels = { setup: 'setup', metadata: 'metadata', download: 'download/backend', processing: 'merge/processing', saving: 'saving', retryWait: 'retry wait' };
|
|
67
|
+
return 'Timing: ' + Object.entries(timings).map(([phase, ms]) => `${labels[phase] || phase} ${(ms / 1000).toFixed(1)}s`).join(' | ');
|
|
68
|
+
}
|
package/src/flush.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { commandOutput } from './output.js';
|
|
2
|
+
import { lstat, mkdir, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { cacheBase } from './paths.js';
|
|
5
|
+
import { listRuns, waitForExit } from './runs.js';
|
|
6
|
+
|
|
7
|
+
const exists = file => lstat(file).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
|
|
8
|
+
const ID = '[a-f0-9-]{36}';
|
|
9
|
+
|
|
10
|
+
async function children(root, name) {
|
|
11
|
+
const directory = path.resolve(root, name);
|
|
12
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
13
|
+
if (!info) return [];
|
|
14
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Refusing to clean a redirected cache directory: ${directory}`);
|
|
15
|
+
return (await readdir(directory, { withFileTypes: true })).map(entry => ({ entry, file: path.join(directory, entry.name) }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function flush({ root = cacheBase(), timeoutMs = 30000, status = () => {}, stats = false } = {}) {
|
|
19
|
+
root = path.resolve(root);
|
|
20
|
+
await mkdir(root, { recursive: true });
|
|
21
|
+
const gate = path.join(root, '.flush');
|
|
22
|
+
try { await mkdir(gate); }
|
|
23
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error(`Another flush is active, or an interrupted flush left ${gate}. Remove this empty lock directory only when no flush is running.`); throw error; }
|
|
24
|
+
let stopped = 0, downloads = 0, jobs = 0, skipped = 0;
|
|
25
|
+
try {
|
|
26
|
+
const runs = await listRuns(root);
|
|
27
|
+
const live = runs.filter(run => run.alive);
|
|
28
|
+
for (const run of live) { await writeFile(`${run.file}.cancel`, 'flush'); stopped++; }
|
|
29
|
+
status(`Stopping ${stopped} active veo run(s)…`);
|
|
30
|
+
if (!await waitForExit(live, timeoutMs)) {
|
|
31
|
+
throw new Error('Some veo runs did not stop in time. No download or job files were removed. Try veo flush again after they stop.');
|
|
32
|
+
}
|
|
33
|
+
for (const { entry, file } of await children(root, 'downloads')) {
|
|
34
|
+
if (!entry.isDirectory() || !/^\.veo-part-[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
35
|
+
// Unregistered older versions might still own these locks.
|
|
36
|
+
if (await exists(path.join(file, '.lock'))) { skipped++; continue; }
|
|
37
|
+
await rm(file, { recursive: true, force: true });
|
|
38
|
+
downloads++;
|
|
39
|
+
}
|
|
40
|
+
for (const { entry, file } of await children(root, 'jobs')) {
|
|
41
|
+
if (skipped) continue; // Older runs may still be updating their retry jobs.
|
|
42
|
+
if (!entry.isFile() || !new RegExp(`^\\d+-${ID}\\.json(?:\\.${ID}\\.tmp)?$`).test(entry.name)) continue;
|
|
43
|
+
await rm(file, { force: true }); jobs++;
|
|
44
|
+
}
|
|
45
|
+
for (const run of runs) {
|
|
46
|
+
await rm(run.file, { force: true });
|
|
47
|
+
await rm(`${run.file}.cancel`, { force: true });
|
|
48
|
+
}
|
|
49
|
+
if (stats) await (await import('./stats.js')).resetStats(root);
|
|
50
|
+
return { stopped, downloads, jobs, skipped };
|
|
51
|
+
} finally { await rmdir(gate); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function flushMain(args = [], { stdout = process.stdout, ...options } = {}) {
|
|
55
|
+
[args, stdout] = commandOutput(args, stdout);
|
|
56
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
57
|
+
stdout.write('veo flush [--stats]\n\nStop registered veo runs and remove local downloads (including retained and resume data)\nand retry job JSON files. Saved media, config, history and backend tools are kept.\nStatistics are kept unless --stats is supplied to reset them.\nLocked downloads from older or interrupted processes are skipped.\n');
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
if (args.length && (args.length !== 1 || args[0] !== '--stats')) throw new Error('Usage: veo flush [--stats] (or veo flush --help)');
|
|
61
|
+
const result = await flush({ ...options, stats: args.includes('--stats'), status: text => stdout.write(`${text}\n`) });
|
|
62
|
+
if (args.includes('--stats')) stdout.write('Statistics reset.\n');
|
|
63
|
+
stdout.write(`Flushed: ${result.downloads} local download folder(s), ${result.jobs} job file(s); ${result.stopped} run(s) stopped.\n`);
|
|
64
|
+
if (result.skipped) stdout.write(`Skipped ${result.skipped} locked folder(s) and kept retry jobs; check older veo processes before removing their locks.\n`);
|
|
65
|
+
return result.skipped ? 1 : 0;
|
|
66
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { commandOutput } from './output.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { cacheBase } from './paths.js';
|
|
6
|
+
import { readJson, writeJson } from './state.js';
|
|
7
|
+
import { cleanText, localStamp } from './utils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `veo history` shows the most recent download attempts. Every finished item is
|
|
11
|
+
* written as its own small file named after its millisecond timestamp, so
|
|
12
|
+
* parallel veo runs never rewrite each other's records and the newest entries
|
|
13
|
+
* can be listed without parsing an ever-growing log.
|
|
14
|
+
*/
|
|
15
|
+
export const HISTORY_LIMIT = 5;
|
|
16
|
+
const SHOWN_FILES = 5;
|
|
17
|
+
const MAX_FILES = 200;
|
|
18
|
+
const STATUSES = new Set(['saved', 'skipped', 'failed', 'cancelled']);
|
|
19
|
+
const NAME = /^(\d{13})-[a-f0-9-]{36}\.json$/;
|
|
20
|
+
|
|
21
|
+
export const HISTORY_HELP = `veo history [--json] [--limit <n>] [--failed]
|
|
22
|
+
|
|
23
|
+
Show the last ${HISTORY_LIMIT} download attempts by default, newest first, with title, status, media
|
|
24
|
+
type, date, URL and saved files. Saved, skipped, failed and cancelled items are
|
|
25
|
+
recorded; playlist entries and retried attempts count individually.
|
|
26
|
+
Use --limit to show 1-1000 attempts, and --failed to show only failed or cancelled
|
|
27
|
+
attempts. Use --json for scripting. Failed entries show their retry command while
|
|
28
|
+
the job file is available. History is kept in the per-user veo cache
|
|
29
|
+
and survives veo flush. Active downloads appear once they finish.
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** Directory holding one JSON record per finished download attempt. */
|
|
33
|
+
export function historyRoot(root = cacheBase()) {
|
|
34
|
+
return path.join(path.resolve(root), 'history');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function valid(record) {
|
|
38
|
+
return record?.version === 1
|
|
39
|
+
&& Number.isFinite(Date.parse(record.at))
|
|
40
|
+
&& typeof record.url === 'string' && Boolean(record.url)
|
|
41
|
+
&& typeof record.title === 'string'
|
|
42
|
+
&& STATUSES.has(record.status)
|
|
43
|
+
&& ['video', 'audio'].includes(record.media)
|
|
44
|
+
&& [record.quality, record.format, record.error].every(value => value === null || typeof value === 'string')
|
|
45
|
+
&& (record.job === undefined || record.job === null || typeof record.job === 'string')
|
|
46
|
+
&& (record.runId === undefined || record.runId === null || /^[0-9a-z]{6}$/.test(record.runId))
|
|
47
|
+
&& Array.isArray(record.files) && record.files.every(file => typeof file === 'string' && Boolean(file));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Only strings can become history text; a missing title must not become "undefined".
|
|
51
|
+
function text(value, limit) {
|
|
52
|
+
if (typeof value !== 'string') return '';
|
|
53
|
+
const cleaned = cleanText(value);
|
|
54
|
+
return limit === undefined ? cleaned : cleaned.slice(0, limit);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Records must never write into the output directory, contain terminal escapes
|
|
59
|
+
* or grow without bound, so untrusted titles, paths and messages are cleaned and
|
|
60
|
+
* the file list is capped here.
|
|
61
|
+
*/
|
|
62
|
+
export function createHistoryRecorder(root = cacheBase(), { now = Date.now } = {}) {
|
|
63
|
+
const directory = historyRoot(root);
|
|
64
|
+
let previous = 0;
|
|
65
|
+
return async item => {
|
|
66
|
+
// Strictly increasing names keep "newest first" deterministic even when
|
|
67
|
+
// several items finish inside the same millisecond.
|
|
68
|
+
const at = Math.max(now(), previous + 1);
|
|
69
|
+
previous = at;
|
|
70
|
+
const audio = Boolean(item.audio);
|
|
71
|
+
const record = {
|
|
72
|
+
version: 1,
|
|
73
|
+
at: new Date(at).toISOString(),
|
|
74
|
+
url: text(item.url),
|
|
75
|
+
title: text(item.title) || text(item.url),
|
|
76
|
+
status: STATUSES.has(item.status) ? item.status : 'failed',
|
|
77
|
+
media: audio ? 'audio' : 'video',
|
|
78
|
+
quality: audio ? null : text(item.quality) || null,
|
|
79
|
+
format: text(item.format) || (audio ? 'mp3' : null),
|
|
80
|
+
files: (Array.isArray(item.files) ? item.files : []).slice(0, MAX_FILES).map(file => text(file)).filter(Boolean),
|
|
81
|
+
error: text(item.error, 400) || null,
|
|
82
|
+
...(item.job ? { job: text(item.job) } : {}),
|
|
83
|
+
...(item.runId ? { runId: text(item.runId) } : {}),
|
|
84
|
+
elapsedMs: Number.isFinite(item.elapsedMs) && item.elapsedMs >= 0 ? Math.round(item.elapsedMs) : 0,
|
|
85
|
+
};
|
|
86
|
+
await writeJson(path.join(directory, `${at}-${randomUUID()}.json`), record);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The newest `limit` records, newest first. Missing history is not an error. */
|
|
91
|
+
export async function readHistory(root = cacheBase(), limit = HISTORY_LIMIT, { failed = false } = {}) {
|
|
92
|
+
const directory = historyRoot(root);
|
|
93
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
94
|
+
if (!info) return [];
|
|
95
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Invalid history directory: ${directory}`);
|
|
96
|
+
const names = (await readdir(directory, { withFileTypes: true }))
|
|
97
|
+
.filter(entry => entry.isFile() && NAME.test(entry.name))
|
|
98
|
+
.map(entry => entry.name)
|
|
99
|
+
.sort((a, b) => Number(b.slice(0, 13)) - Number(a.slice(0, 13)));
|
|
100
|
+
const records = [];
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
if (records.length >= limit) break;
|
|
103
|
+
const file = path.join(directory, name);
|
|
104
|
+
const record = await readJson(file, null);
|
|
105
|
+
if (!valid(record)) throw new Error(`Invalid history file: ${file}`);
|
|
106
|
+
if (failed && !['failed', 'cancelled'].includes(record.status)) continue;
|
|
107
|
+
records.push(record);
|
|
108
|
+
}
|
|
109
|
+
return records;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describeDuration(ms) {
|
|
113
|
+
if (ms < 1000) return `${ms} ms`;
|
|
114
|
+
const seconds = ms / 1000;
|
|
115
|
+
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
|
116
|
+
return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function describeMedia(entry) {
|
|
120
|
+
const detail = entry.media === 'audio' ? entry.format : entry.quality;
|
|
121
|
+
return detail ? `${entry.media}, ${detail}` : entry.media;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function formatHistory(entries, limit = HISTORY_LIMIT, { failed = false } = {}) {
|
|
125
|
+
if (!entries.length) return failed ? 'veo history\n\nNo failed or cancelled downloads recorded.\n' : 'veo history\n\nNo downloads recorded yet. Download something with veo first.\n';
|
|
126
|
+
const lines = [`veo history (last ${limit}${failed ? ' failed/cancelled' : ''}, newest first)`, ''];
|
|
127
|
+
for (const [index, entry] of entries.entries()) {
|
|
128
|
+
lines.push(`${index + 1}. ${entry.title}`);
|
|
129
|
+
lines.push(` Status: ${entry.status}${entry.status === 'skipped' ? ' (already on disk)' : ''}`);
|
|
130
|
+
if (entry.runId) lines.push(` Run ID: ${entry.runId} (veo inspect run ${entry.runId})`);
|
|
131
|
+
lines.push(` Media: ${describeMedia(entry)}`);
|
|
132
|
+
lines.push(` Date: ${localStamp(entry.at)}${entry.elapsedMs ? ` (${describeDuration(entry.elapsedMs)})` : ''}`);
|
|
133
|
+
lines.push(` URL: ${entry.url}`);
|
|
134
|
+
if (entry.error) lines.push(` Error: ${entry.error}`);
|
|
135
|
+
if (entry.job) lines.push(` Retry: veo --retry-failed "${entry.job}"`);
|
|
136
|
+
for (const [position, file] of entry.files.slice(0, SHOWN_FILES).entries()) {
|
|
137
|
+
lines.push(`${position ? ' ' : ' Saved: '}${file}`);
|
|
138
|
+
}
|
|
139
|
+
if (entry.files.length > SHOWN_FILES) lines.push(` … and ${entry.files.length - SHOWN_FILES} more file(s); use veo history --json for the full list.`);
|
|
140
|
+
if (index !== entries.length - 1) lines.push('');
|
|
141
|
+
}
|
|
142
|
+
return `${lines.join('\n')}\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function historyMain(args = [], { stdout = process.stdout, root = cacheBase(), limit = HISTORY_LIMIT } = {}) {
|
|
146
|
+
[args, stdout] = commandOutput(args, stdout);
|
|
147
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
148
|
+
stdout.write(HISTORY_HELP);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
let json = false, failed = false;
|
|
152
|
+
for (let index = 0; index < args.length; index++) {
|
|
153
|
+
const arg = args[index];
|
|
154
|
+
if (arg === '--json' && !json) json = true;
|
|
155
|
+
else if (arg === '--failed' && !failed) failed = true;
|
|
156
|
+
else if (arg === '--limit') {
|
|
157
|
+
const value = args[++index];
|
|
158
|
+
if (!value || !/^[1-9]\d*$/.test(value) || Number(value) > 1000) throw new Error('--limit must be a whole number between 1 and 1000.');
|
|
159
|
+
limit = Number(value);
|
|
160
|
+
} else throw new Error('Usage: veo history [--json] [--limit <n>] [--failed]');
|
|
161
|
+
}
|
|
162
|
+
const entries = await readHistory(root, limit, { failed });
|
|
163
|
+
if (json) stdout.write(`${JSON.stringify({ count: entries.length, entries })}\n`);
|
|
164
|
+
else {
|
|
165
|
+
const visible = await Promise.all(entries.map(async entry => {
|
|
166
|
+
if (!entry.job) return entry;
|
|
167
|
+
const info = await lstat(entry.job).catch(() => null);
|
|
168
|
+
return info?.isFile() && !info.isSymbolicLink() ? entry : { ...entry, job: null };
|
|
169
|
+
}));
|
|
170
|
+
stdout.write(formatHistory(visible, limit, { failed }));
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { backendCacheDirectory, exeSuffix, resolveMediaTools } from './backend.js';
|
|
5
|
+
import { commandOutput } from './output.js';
|
|
6
|
+
import { locateRunFiles, readRunArchive, RUN_ARCHIVE_ID } from './run-archive.js';
|
|
7
|
+
|
|
8
|
+
const SIGNAL_THRESHOLD_DBFS = -60;
|
|
9
|
+
const OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export const INSPECT_HELP = `veo inspect <media-file> [--check-audio] [--json]
|
|
12
|
+
veo inspect run <id> [--check-audio] [--search <directory>] [--json]
|
|
13
|
+
|
|
14
|
+
Read local media metadata with ffprobe. --check-audio decodes every audio track
|
|
15
|
+
from start to end and measures its peak level with FFmpeg. A signal is detected
|
|
16
|
+
when the peak is above -60 dBFS; this is a level check, not a listening test.
|
|
17
|
+
Inspect a finished run by its persistent id. Missing media is searched by its
|
|
18
|
+
file fingerprint under the original output directory; --search adds another
|
|
19
|
+
directory after a move. Missing files are reported before media probing.
|
|
20
|
+
Inspection uses locally available media tools and does not contact the network.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export function runMediaTool(executable, args, { signal, env } = {}) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const child = spawn(executable, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], signal, ...(env ? { env } : {}) });
|
|
26
|
+
let stdout = '', stderr = '', overflow = false;
|
|
27
|
+
const append = (current, chunk) => {
|
|
28
|
+
if (current.length + chunk.length > OUTPUT_LIMIT) { overflow = true; child.kill(); return current; }
|
|
29
|
+
return current + chunk;
|
|
30
|
+
};
|
|
31
|
+
child.stdout.on('data', chunk => { stdout = append(stdout, chunk.toString()); });
|
|
32
|
+
child.stderr.on('data', chunk => { stderr = append(stderr, chunk.toString()); });
|
|
33
|
+
child.on('error', reject);
|
|
34
|
+
child.on('close', code => {
|
|
35
|
+
if (overflow) reject(new Error('Media tool output exceeded the supported size.'));
|
|
36
|
+
else if (signal?.aborted) reject(new DOMException('Cancelled', 'AbortError'));
|
|
37
|
+
else if (code !== 0) reject(new Error(stderr.trim().split(/\r?\n/).at(-1) || `Media tool exited with code ${code}.`));
|
|
38
|
+
else resolve({ stdout, stderr });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function localMediaTools() {
|
|
44
|
+
const directory = backendCacheDirectory();
|
|
45
|
+
await mkdir(directory, { recursive: true });
|
|
46
|
+
const location = await resolveMediaTools({ directory, offline: true });
|
|
47
|
+
return { ffmpeg: path.join(location, `ffmpeg${exeSuffix()}`), ffprobe: path.join(location, `ffprobe${exeSuffix()}`) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function number(value) {
|
|
51
|
+
if (value === undefined || value === null || value === '') return null;
|
|
52
|
+
const parsed = Number(value);
|
|
53
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parsePeak(output) {
|
|
57
|
+
const match = output.match(/max_volume:\s*(-inf|-?\d+(?:\.\d+)?)\s*dB/i);
|
|
58
|
+
if (!match) throw new Error('FFmpeg did not report an audio peak level.');
|
|
59
|
+
return match[1].toLowerCase() === '-inf' ? null : Number(match[1]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function inspectMedia(file, { checkAudio = false, signal, runner = runMediaTool, tools = localMediaTools } = {}) {
|
|
63
|
+
const target = path.resolve(file);
|
|
64
|
+
if (!(await stat(target)).isFile()) throw new Error(`Not a regular media file: ${target}`);
|
|
65
|
+
const { ffprobe, ffmpeg } = await tools();
|
|
66
|
+
const probe = await runner(ffprobe, ['-v', 'error', '-show_format', '-show_streams', '-of', 'json', '-i', target], { signal });
|
|
67
|
+
let metadata;
|
|
68
|
+
try { metadata = JSON.parse(probe.stdout); }
|
|
69
|
+
catch { throw new Error(`ffprobe returned invalid metadata for ${target}.`); }
|
|
70
|
+
if (!Array.isArray(metadata.streams)) throw new Error(`ffprobe found no stream metadata for ${target}.`);
|
|
71
|
+
const streams = metadata.streams.map(stream => ({
|
|
72
|
+
index: stream.index, type: stream.codec_type || null, codec: stream.codec_name || null,
|
|
73
|
+
language: stream.tags?.language || null, width: number(stream.width), height: number(stream.height),
|
|
74
|
+
channels: number(stream.channels), sampleRate: number(stream.sample_rate),
|
|
75
|
+
}));
|
|
76
|
+
const audioTracks = streams.filter(stream => stream.type === 'audio');
|
|
77
|
+
const result = { file: target, format: metadata.format?.format_name || null,
|
|
78
|
+
durationSeconds: number(metadata.format?.duration), sizeBytes: number(metadata.format?.size),
|
|
79
|
+
streams, hasAudioTrack: audioTracks.length > 0, hasVideoTrack: streams.some(stream => stream.type === 'video'),
|
|
80
|
+
audioCheck: null };
|
|
81
|
+
if (checkAudio) {
|
|
82
|
+
const tracks = [];
|
|
83
|
+
for (const [audioIndex, stream] of audioTracks.entries()) {
|
|
84
|
+
const report = await runner(ffmpeg, ['-hide_banner', '-nostdin', '-nostats', '-i', target,
|
|
85
|
+
'-map', `0:a:${audioIndex}`, '-vn', '-sn', '-dn', '-af', 'volumedetect', '-f', 'null', '-'], { signal });
|
|
86
|
+
const maxDbfs = parsePeak(report.stderr);
|
|
87
|
+
tracks.push({ streamIndex: stream.index, maxDbfs, hasSignal: maxDbfs !== null && maxDbfs > SIGNAL_THRESHOLD_DBFS });
|
|
88
|
+
}
|
|
89
|
+
result.audioCheck = { checked: true, scope: 'full_file', thresholdDbfs: SIGNAL_THRESHOLD_DBFS,
|
|
90
|
+
hasSignal: tracks.some(track => track.hasSignal), tracks };
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function inspectRun(id, { checkAudio = false, searchRoot, signal, root, inspect = inspectMedia } = {}) {
|
|
96
|
+
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
|
+
const archive = await readRunArchive(id, root);
|
|
98
|
+
const located = await locateRunFiles(archive, { searchRoot, signal });
|
|
99
|
+
const items = [];
|
|
100
|
+
let missingFiles = 0;
|
|
101
|
+
for (const item of located) {
|
|
102
|
+
const files = [];
|
|
103
|
+
for (const file of item.files) {
|
|
104
|
+
if (!file.found) missingFiles++;
|
|
105
|
+
let metadata = null;
|
|
106
|
+
if (file.found && file.media) {
|
|
107
|
+
// locateRunFiles verifies that this is a regular file before any probe.
|
|
108
|
+
try { metadata = await inspect(file.path, { checkAudio, signal }); }
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error.code !== 'ENOENT') throw error;
|
|
111
|
+
file.found = false; file.path = null; missingFiles++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
files.push({ ...file, metadata });
|
|
115
|
+
}
|
|
116
|
+
items.push({ ...item, files });
|
|
117
|
+
}
|
|
118
|
+
return { runId: archive.id, status: archive.status, startedAt: archive.startedAt,
|
|
119
|
+
finishedAt: archive.finishedAt, missingFiles, items };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function inspectMain(args = [], { stdout = process.stdout, inspect = inspectMedia, inspectRunImpl = inspectRun } = {}) {
|
|
123
|
+
[args, stdout] = commandOutput(args, stdout);
|
|
124
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { stdout.write(INSPECT_HELP); return 0; }
|
|
125
|
+
const json = args.includes('--json');
|
|
126
|
+
const checkAudio = args.includes('--check-audio');
|
|
127
|
+
const runMode = args[0] === 'run';
|
|
128
|
+
let searchRoot;
|
|
129
|
+
const positionals = [];
|
|
130
|
+
for (let index = 0; index < args.length; index++) {
|
|
131
|
+
const arg = args[index];
|
|
132
|
+
if (['--json', '--check-audio'].includes(arg)) continue;
|
|
133
|
+
if (arg === '--search' && runMode && !searchRoot) { searchRoot = args[++index]; continue; }
|
|
134
|
+
positionals.push(arg);
|
|
135
|
+
}
|
|
136
|
+
if (runMode ? positionals.length !== 2 || positionals[0] !== 'run' || !RUN_ARCHIVE_ID.test(positionals[1]) || args.includes('--search') && (!searchRoot || searchRoot.startsWith('--'))
|
|
137
|
+
: positionals.length !== 1 || positionals[0].startsWith('--')) {
|
|
138
|
+
throw new Error('Usage: veo inspect <media-file> [--check-audio] [--json] | veo inspect run <id> [--check-audio] [--search <directory>] [--json]');
|
|
139
|
+
}
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const cancel = () => controller.abort();
|
|
142
|
+
process.once('SIGINT', cancel);
|
|
143
|
+
process.once('SIGTERM', cancel);
|
|
144
|
+
try {
|
|
145
|
+
const result = runMode
|
|
146
|
+
? await inspectRunImpl(positionals[1], { checkAudio, searchRoot, signal: controller.signal })
|
|
147
|
+
: await inspect(positionals[0], { checkAudio, signal: controller.signal });
|
|
148
|
+
if (json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
149
|
+
else if (runMode) {
|
|
150
|
+
stdout.write(`Run: ${result.runId} (${result.status})\n`);
|
|
151
|
+
for (const item of result.items) for (const file of item.files) {
|
|
152
|
+
stdout.write(`${file.found ? 'File' : 'Missing'}: ${file.path || file.originalPath}${file.renamed ? ` (renamed from ${file.originalPath})` : ''}\n`);
|
|
153
|
+
if (file.metadata) stdout.write(` Format: ${file.metadata.format || 'unknown'}; audio tracks: ${file.metadata.streams.filter(stream => stream.type === 'audio').length}${file.metadata.audioCheck ? `; signal: ${file.metadata.audioCheck.hasSignal ? 'detected' : 'not detected'}` : ''}\n`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
stdout.write(`File: ${result.file}\n`);
|
|
158
|
+
stdout.write(`Format: ${result.format || 'unknown'}; duration: ${result.durationSeconds ?? 'unknown'} s\n`);
|
|
159
|
+
stdout.write(`Streams: ${result.streams.map(stream => `${stream.type || 'unknown'} ${stream.codec || 'unknown'}`).join(', ') || 'none'}\n`);
|
|
160
|
+
stdout.write(`Audio tracks: ${result.streams.filter(stream => stream.type === 'audio').length}\n`);
|
|
161
|
+
if (result.audioCheck) stdout.write(`Audio signal: ${result.audioCheck.hasSignal ? 'detected' : 'not detected'} (threshold ${SIGNAL_THRESHOLD_DBFS} dBFS, full file)\n`);
|
|
162
|
+
}
|
|
163
|
+
return runMode && result.missingFiles ? 1 : 0;
|
|
164
|
+
} finally {
|
|
165
|
+
process.removeListener('SIGINT', cancel);
|
|
166
|
+
process.removeListener('SIGTERM', cancel);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { outputStream, formatOutput } from './output.js';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { availableHeights, cleanText, validateUrl } from './utils.js';
|
|
4
|
+
import { applyProfile } from './config.js';
|
|
5
|
+
import { fetchMetadata, prepareBackend, runBackend } from './downloader.js';
|
|
6
|
+
import { describeEstimate, selectedEntries, sizeEstimate, validateItems } from './playlist.js';
|
|
7
|
+
|
|
8
|
+
export async function interactiveArgs(config, { signal, input = process.stdin, output = process.stderr, ask, inspect } = {}) {
|
|
9
|
+
const terminalOutput = output;
|
|
10
|
+
output = outputStream(output);
|
|
11
|
+
const rl = ask ? null : createInterface({ input, output: terminalOutput });
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
signal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
14
|
+
rl?.on('SIGINT', () => controller.abort());
|
|
15
|
+
const question = ask || (prompt => rl.question(formatOutput(prompt, terminalOutput), { signal }));
|
|
16
|
+
const choose = async (prompt, allowed, fallback) => {
|
|
17
|
+
while (true) {
|
|
18
|
+
const answer = (await question(prompt)).trim() || fallback;
|
|
19
|
+
if (allowed.includes(answer)) return answer;
|
|
20
|
+
output.write(`Choose: ${allowed.join(', ')}\n`);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
const args = [];
|
|
25
|
+
const profiles = Object.keys(config.profiles || {});
|
|
26
|
+
let defaults = applyProfile(config);
|
|
27
|
+
if (profiles.length) {
|
|
28
|
+
const hasDefault = profiles.includes('default');
|
|
29
|
+
const choices = hasDefault ? profiles : [...profiles, 'none'];
|
|
30
|
+
const fallback = hasDefault ? 'default' : 'none';
|
|
31
|
+
const profile = await choose(`Profile (${choices.join(', ')}) [${fallback}]: `, choices, fallback);
|
|
32
|
+
if (profile !== 'none') { args.push('--profile', profile); defaults = applyProfile(config, profile); }
|
|
33
|
+
}
|
|
34
|
+
const url = validateUrl((await question('Video or playlist URL: ')).trim());
|
|
35
|
+
args.push(url);
|
|
36
|
+
const type = await choose(`Download (video/audio) [${defaults.audio ? 'audio' : 'video'}]: `, ['video', 'audio'], defaults.audio ? 'audio' : 'video');
|
|
37
|
+
args.push(type === 'audio' ? '--audio' : '--no-audio');
|
|
38
|
+
output.write('Reading available media…\n');
|
|
39
|
+
// Inspect the collection first so ordinary video links do not need a playlist prompt.
|
|
40
|
+
const inspectionOptions = { ...defaults, url, playlist: true };
|
|
41
|
+
const metadata = inspect ? await inspect(inspectionOptions) : await fetchMetadata(inspectionOptions, {
|
|
42
|
+
signal, backend: await prepareBackend(inspectionOptions, { signal }), runner: runBackend,
|
|
43
|
+
});
|
|
44
|
+
const isCollection = metadata._type === 'playlist' || Boolean(metadata.entries);
|
|
45
|
+
const collection = isCollection
|
|
46
|
+
? await choose(`Download the playlist? (y/n) [${defaults.playlist === false ? 'n' : 'y'}]: `,
|
|
47
|
+
['y', 'n'], defaults.playlist === false ? 'n' : 'y')
|
|
48
|
+
: 'n';
|
|
49
|
+
args.push(collection === 'y' ? '--playlist' : '--no-playlist');
|
|
50
|
+
output.write(`${cleanText(metadata.title || metadata.id || 'Media')}\n`);
|
|
51
|
+
if (collection === 'y' && metadata.entries) {
|
|
52
|
+
for (const { entry, index } of selectedEntries(metadata)) output.write(`${index}. ${cleanText(entry?.title || 'Unavailable entry')}\n`);
|
|
53
|
+
let selection;
|
|
54
|
+
while (true) {
|
|
55
|
+
const preferred = defaults.playlistItems || '';
|
|
56
|
+
selection = (await question(`Entries (e.g. 1,3-5; Enter = ${preferred || 'all'}): `)).trim() || preferred;
|
|
57
|
+
try { if (selection) validateItems(selection); selectedEntries(metadata, selection); break; }
|
|
58
|
+
catch (error) { output.write(`${error.message}\n`); }
|
|
59
|
+
}
|
|
60
|
+
if (selection) args.push('--playlist-items', selection);
|
|
61
|
+
output.write(`${describeEstimate(sizeEstimate(selectedEntries(metadata, selection)))}\n`);
|
|
62
|
+
}
|
|
63
|
+
if (type === 'video') {
|
|
64
|
+
const heights = availableHeights(metadata.formats).sort((a, b) => b - a).map(height => `${height}p`);
|
|
65
|
+
const choices = ['best', ...heights];
|
|
66
|
+
if (!heights.length) output.write('Resolution metadata unavailable; using best. Set a quality cap with -q in command mode.\n');
|
|
67
|
+
const fallback = choices.includes(defaults.quality) ? defaults.quality : 'best';
|
|
68
|
+
args.push('-q', await choose(`Quality (${choices.join(', ')}) [${fallback}]: `, choices, fallback));
|
|
69
|
+
}
|
|
70
|
+
// Switching the media type must not leave an incompatible format from a profile.
|
|
71
|
+
if (type === 'video' && ['mp3', 'm4a', 'aac', 'opus', 'flac', 'wav'].includes(defaults.format)) args.push('--format', 'mp4');
|
|
72
|
+
if (type === 'audio' && ['mp4', 'mkv', 'webm', 'mov'].includes(defaults.format)) args.push('--format', 'mp3');
|
|
73
|
+
const directory = (await question(`Output directory [${defaults.output || process.cwd()}]: `)).trim() || defaults.output || process.cwd();
|
|
74
|
+
args.push('-o', directory, '--resume');
|
|
75
|
+
const skipDefault = defaults.skipExisting === false ? 'n' : 'y';
|
|
76
|
+
const skip = await choose(`Skip previously downloaded videos? (y/n) [${skipDefault}]: `, ['y', 'n'], skipDefault);
|
|
77
|
+
args.push(skip === 'y' ? '--skip-existing' : '--no-skip-existing');
|
|
78
|
+
output.write(`Ready: ${type}, ${directory}\n`);
|
|
79
|
+
if (await choose('Start download? (y/n) [y]: ', ['y', 'n'], 'y') === 'n') return null;
|
|
80
|
+
return args;
|
|
81
|
+
} finally { rl?.close(); }
|
|
82
|
+
}
|