videobook 0.1.0 → 0.2.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 +36 -0
- package/README.md +80 -18
- package/dist/cli/args.js +53 -3
- package/dist/cli/config-command.js +103 -0
- package/dist/cli/doctor-command.js +51 -17
- package/dist/cli/key-report.js +16 -0
- package/dist/cli/main.js +14 -1
- package/dist/cli/mcp-command.js +6 -0
- package/dist/cli/run-command.js +60 -16
- package/dist/cli/serve-command.js +5 -0
- package/dist/cli/setup-command.js +60 -0
- package/dist/dancing-workflow.js +2 -1
- package/dist/env-file.js +91 -0
- package/dist/env-secrets.js +38 -74
- package/dist/ffprobe-binary.js +29 -7
- package/dist/hydration.js +56 -0
- package/dist/jobs/archive-clip.js +2 -1
- package/dist/jobs/download-video.js +2 -1
- package/dist/load-env.js +5 -2
- package/dist/one-password.js +200 -0
- package/dist/queue.js +4 -0
- package/dist/serve.js +5 -30
- package/dist/tools/videobook-domain.js +24 -9
- package/dist/ytdlp-binary.js +65 -0
- package/package.json +3 -11
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
export const OP_VAULT_SETTING = "VIDEOBOOK_OP_VAULT";
|
|
7
|
+
export const OP_TOKEN_SETTING = "VIDEOBOOK_OP_SERVICE_TOKEN";
|
|
8
|
+
const OP_TIMEOUT_MS = 10_000;
|
|
9
|
+
const TEMPLATE_UNSAFE = /[\r\n]|\{\{|\}\}/u;
|
|
10
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
11
|
+
export function isOpReference(value) {
|
|
12
|
+
return typeof value === "string" && value.trim().startsWith("op://");
|
|
13
|
+
}
|
|
14
|
+
export function vaultReference(vault, key) {
|
|
15
|
+
return `op://${vault}/${key}/password`;
|
|
16
|
+
}
|
|
17
|
+
export function resolveServiceToken(raw, env) {
|
|
18
|
+
const value = raw?.trim();
|
|
19
|
+
if (!value)
|
|
20
|
+
return { value: null, source: null };
|
|
21
|
+
const name = value.startsWith("env:") ? value.slice(4) : value.startsWith("$") ? value.slice(1) : value;
|
|
22
|
+
if (value !== name || /^[A-Z_][A-Z0-9_]*$/u.test(value)) {
|
|
23
|
+
if (!ENV_NAME.test(name))
|
|
24
|
+
return { value: null, source: `env:${name}` };
|
|
25
|
+
return { value: env[name]?.trim() || null, source: `env:${name}` };
|
|
26
|
+
}
|
|
27
|
+
return { value, source: "literal" };
|
|
28
|
+
}
|
|
29
|
+
export function readOnePasswordSettings(env) {
|
|
30
|
+
return {
|
|
31
|
+
vault: env[OP_VAULT_SETTING]?.trim() || null,
|
|
32
|
+
token: resolveServiceToken(env[OP_TOKEN_SETTING], env),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function redactToken(text, token) {
|
|
36
|
+
return token ? text.split(token).join("***") : text;
|
|
37
|
+
}
|
|
38
|
+
export function planReferences(env, managedKeys, vault, vaultTitles) {
|
|
39
|
+
const explicit = Object.entries(env)
|
|
40
|
+
.filter((entry) => isOpReference(entry[1]))
|
|
41
|
+
.map(([key, reference]) => [key, reference.trim()]);
|
|
42
|
+
const covered = new Set(explicit.map(([key]) => key));
|
|
43
|
+
const convention = vault
|
|
44
|
+
? managedKeys
|
|
45
|
+
.filter((key) => !covered.has(key) && !env[key]?.trim() && vaultTitles.has(key))
|
|
46
|
+
.map((key) => [key, vaultReference(vault, key)])
|
|
47
|
+
: [];
|
|
48
|
+
return { explicit, convention };
|
|
49
|
+
}
|
|
50
|
+
function startMarker(nonce, key) {
|
|
51
|
+
return `<<<${nonce}:${key}>>>`;
|
|
52
|
+
}
|
|
53
|
+
function endMarker(nonce, key) {
|
|
54
|
+
return `<<<${nonce}/${key}>>>`;
|
|
55
|
+
}
|
|
56
|
+
export function injectionTemplate(entries, nonce) {
|
|
57
|
+
return entries.map(([key, reference]) => `${startMarker(nonce, key)}{{ ${reference} }}${endMarker(nonce, key)}`).join("\n");
|
|
58
|
+
}
|
|
59
|
+
export function parseInjection(output, keys, nonce) {
|
|
60
|
+
const values = new Map();
|
|
61
|
+
for (const key of keys) {
|
|
62
|
+
const start = output.indexOf(startMarker(nonce, key));
|
|
63
|
+
if (start < 0)
|
|
64
|
+
continue;
|
|
65
|
+
const from = start + startMarker(nonce, key).length;
|
|
66
|
+
const end = output.indexOf(endMarker(nonce, key), from);
|
|
67
|
+
if (end < 0)
|
|
68
|
+
continue;
|
|
69
|
+
const value = output.slice(from, end);
|
|
70
|
+
if (value !== "")
|
|
71
|
+
values.set(key, value);
|
|
72
|
+
}
|
|
73
|
+
return values;
|
|
74
|
+
}
|
|
75
|
+
function opEnvironment(env, token) {
|
|
76
|
+
return token ? { ...env, OP_SERVICE_ACCOUNT_TOKEN: token } : env;
|
|
77
|
+
}
|
|
78
|
+
export function opErrorMessage(error, token) {
|
|
79
|
+
const failure = error;
|
|
80
|
+
if (failure.code === "ENOENT")
|
|
81
|
+
return "the 1Password CLI (op) is not on PATH";
|
|
82
|
+
if (failure.code === "ETIMEDOUT" || failure.signal === "SIGTERM")
|
|
83
|
+
return "op timed out";
|
|
84
|
+
const line = String(failure.stderr ?? "").trim().split("\n")[0]?.trim().replace(/^\[ERROR\]\s+\S+\s+\S+\s+/u, "");
|
|
85
|
+
return redactToken(line || failure.message || "op failed", token);
|
|
86
|
+
}
|
|
87
|
+
function runOp(args, env, token) {
|
|
88
|
+
return execFileSync("op", args, {
|
|
89
|
+
env: opEnvironment(env, token),
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
timeout: OP_TIMEOUT_MS,
|
|
92
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export function listVaultItemTitles(vault, env, token) {
|
|
97
|
+
const parsed = JSON.parse(runOp(["item", "list", "--vault", vault, "--format", "json"], env, token));
|
|
98
|
+
if (!Array.isArray(parsed))
|
|
99
|
+
throw new Error("op item list did not return a list");
|
|
100
|
+
return new Set(parsed.flatMap((item) => {
|
|
101
|
+
const title = item && typeof item === "object" ? item.title : undefined;
|
|
102
|
+
return typeof title === "string" ? [title] : [];
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
function injectBatch(entries, env, token) {
|
|
106
|
+
const nonce = randomUUID().replaceAll("-", "");
|
|
107
|
+
const directory = mkdtempSync(join(tmpdir(), "videobook-op-"));
|
|
108
|
+
try {
|
|
109
|
+
const template = join(directory, "template");
|
|
110
|
+
writeFileSync(template, injectionTemplate(entries, nonce), { mode: 0o600 });
|
|
111
|
+
const output = runOp(["inject", "--in-file", template], env, token);
|
|
112
|
+
return parseInjection(output, entries.map(([key]) => key), nonce);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
rmSync(directory, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function readOne(key, reference, env, token) {
|
|
119
|
+
try {
|
|
120
|
+
const value = runOp(["read", "--no-newline", reference], env, token);
|
|
121
|
+
return value === ""
|
|
122
|
+
? { values: new Map(), failures: [{ key, reference, reason: "the item has no value" }] }
|
|
123
|
+
: { values: new Map([[key, value]]), failures: [] };
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
return { values: new Map(), failures: [{ key, reference, reason: opErrorMessage(error, token) }] };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function mergeResults(results) {
|
|
130
|
+
return {
|
|
131
|
+
values: new Map(results.flatMap((result) => [...result.values])),
|
|
132
|
+
failures: results.flatMap((result) => result.failures),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function resolveEntries(entries, env, token) {
|
|
136
|
+
const unsafe = entries.filter(([, reference]) => TEMPLATE_UNSAFE.test(reference));
|
|
137
|
+
const safe = entries.filter(([, reference]) => !TEMPLATE_UNSAFE.test(reference));
|
|
138
|
+
const refused = unsafe.map(([key, reference]) => ({ key, reference, reason: "malformed op:// reference" }));
|
|
139
|
+
if (safe.length === 0)
|
|
140
|
+
return { values: new Map(), failures: refused };
|
|
141
|
+
try {
|
|
142
|
+
const values = injectBatch(safe, env, token);
|
|
143
|
+
const missing = safe.filter(([key]) => !values.has(key)).map(([key, reference]) => ({ key, reference, reason: "op inject returned no value" }));
|
|
144
|
+
return { values, failures: [...refused, ...missing] };
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
const individual = mergeResults(safe.map(([key, reference]) => readOne(key, reference, env, token)));
|
|
148
|
+
return { values: individual.values, failures: [...refused, ...individual.failures] };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function vaultTitles(settings, env) {
|
|
152
|
+
if (!settings.vault)
|
|
153
|
+
return { titles: new Set(), error: null };
|
|
154
|
+
try {
|
|
155
|
+
return { titles: listVaultItemTitles(settings.vault, env, settings.token.value), error: null };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return { titles: new Set(), error: `could not list vault ${settings.vault}: ${opErrorMessage(error, settings.token.value)}` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function emptyResolution(settings) {
|
|
162
|
+
return {
|
|
163
|
+
attempted: false,
|
|
164
|
+
vault: settings.vault,
|
|
165
|
+
tokenSource: settings.token.source,
|
|
166
|
+
fromReferences: [],
|
|
167
|
+
fromVault: [],
|
|
168
|
+
unresolved: [],
|
|
169
|
+
error: null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function resolveOnePasswordEnv(env, managedKeys) {
|
|
173
|
+
const settings = readOnePasswordSettings(env);
|
|
174
|
+
const hasReferences = Object.values(env).some((value) => isOpReference(value));
|
|
175
|
+
if (!hasReferences && !settings.vault)
|
|
176
|
+
return emptyResolution(settings);
|
|
177
|
+
const listing = vaultTitles(settings, env);
|
|
178
|
+
const plan = planReferences(env, managedKeys, settings.vault, listing.titles);
|
|
179
|
+
const result = resolveEntries([...plan.explicit, ...plan.convention], env, settings.token.value);
|
|
180
|
+
for (const [key] of plan.explicit) {
|
|
181
|
+
const value = result.values.get(key);
|
|
182
|
+
if (value === undefined)
|
|
183
|
+
delete env[key];
|
|
184
|
+
else
|
|
185
|
+
env[key] = value;
|
|
186
|
+
}
|
|
187
|
+
for (const [key] of plan.convention) {
|
|
188
|
+
const value = result.values.get(key);
|
|
189
|
+
if (value !== undefined)
|
|
190
|
+
env[key] = value;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
...emptyResolution(settings),
|
|
194
|
+
attempted: true,
|
|
195
|
+
fromReferences: plan.explicit.map(([key]) => key).filter((key) => result.values.has(key)),
|
|
196
|
+
fromVault: plan.convention.map(([key]) => key).filter((key) => result.values.has(key)),
|
|
197
|
+
unresolved: result.failures,
|
|
198
|
+
error: listing.error,
|
|
199
|
+
};
|
|
200
|
+
}
|
package/dist/queue.js
CHANGED
|
@@ -517,6 +517,10 @@ export function enqueueOptions(jobId, payload) {
|
|
|
517
517
|
artifactWorkOrientation: classified?.orientation ?? null,
|
|
518
518
|
};
|
|
519
519
|
}
|
|
520
|
+
export function jobIdForKey(jobKey, bookId) {
|
|
521
|
+
const entry = jobIdIndex.get(jobKey);
|
|
522
|
+
return entry && entry.bookId === bookId ? entry.internalId : null;
|
|
523
|
+
}
|
|
520
524
|
function rememberJob(jobId, entry) {
|
|
521
525
|
jobIdIndex.set(jobId, entry);
|
|
522
526
|
while (jobIdIndex.size > JOB_INDEX_MAX) {
|
package/dist/serve.js
CHANGED
|
@@ -3,45 +3,19 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
3
3
|
import { stopAceServer } from "./acestep/process.js";
|
|
4
4
|
import { createApp } from "./app.js";
|
|
5
5
|
import { bookManager, scopedEngine } from "./engine.js";
|
|
6
|
+
import { getDefaultSecretStore } from "./env-secrets.js";
|
|
7
|
+
import { hydrateLibrary } from "./hydration.js";
|
|
6
8
|
import { registerAllJobHandlers } from "./jobs/index.js";
|
|
7
9
|
import { logger } from "./logger.js";
|
|
8
|
-
import {
|
|
10
|
+
import { stopPostMetricsScheduler } from "./posts/post-metrics.js";
|
|
9
11
|
import { queue } from "./queue.js";
|
|
10
|
-
import {
|
|
12
|
+
import { stopScheduler } from "./scheduled-posts.js";
|
|
11
13
|
import { SERVER_VERSION } from "./server.js";
|
|
12
14
|
import { ServerAlreadyRunningError, acquireServerLock, inspectServerOwner, } from "./server-lock.js";
|
|
13
15
|
import { attachVoiceAudio } from "./voice-audio.js";
|
|
14
16
|
const LOOPBACK_HOST = "127.0.0.1";
|
|
15
17
|
const SHUTDOWN_WATCHDOG_MS = 20_000;
|
|
16
18
|
const HTTP_CLOSE_GRACE_MS = 2_000;
|
|
17
|
-
function openBooks(books) {
|
|
18
|
-
return books.list().flatMap((book) => {
|
|
19
|
-
const selected = books.get(book.bookId);
|
|
20
|
-
return selected.ok ? [selected.value] : [];
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
async function hydrateLibrary(books, options) {
|
|
24
|
-
await books.ready();
|
|
25
|
-
if (options.devFixtures) {
|
|
26
|
-
const { ensureDancingWorkflow } = await import("./dancing-workflow.js");
|
|
27
|
-
await ensureDancingWorkflow(books);
|
|
28
|
-
}
|
|
29
|
-
const imageSubjects = await import("./jobs/image-subjects.js");
|
|
30
|
-
const runNotebook = await import("./jobs/run-notebook.js");
|
|
31
|
-
for (const book of openBooks(books)) {
|
|
32
|
-
await book.engine.jobs.queue.reap();
|
|
33
|
-
await imageSubjects.reconcileImageSubjectJobFailures(book.engine, book.bookId);
|
|
34
|
-
await runNotebook.abortLeasedNotebookRunsOnStartup(book.engine);
|
|
35
|
-
await runNotebook.reconcileGenerationMediaOutputs(book.engine);
|
|
36
|
-
await runNotebook.reconcileQueuedNotebookCells(book.engine);
|
|
37
|
-
}
|
|
38
|
-
await queue.start();
|
|
39
|
-
for (const book of openBooks(books)) {
|
|
40
|
-
await runNotebook.reconcileAwaitingNotebookOutputs(book.engine, book.bookId);
|
|
41
|
-
}
|
|
42
|
-
await loadAndArmAll();
|
|
43
|
-
startPostMetricsScheduler();
|
|
44
|
-
}
|
|
45
19
|
async function shutdownServices(services) {
|
|
46
20
|
stopScheduler();
|
|
47
21
|
stopPostMetricsScheduler();
|
|
@@ -110,6 +84,7 @@ async function claimLibrary(server, libraryDir, port, url) {
|
|
|
110
84
|
export async function startServer(options) {
|
|
111
85
|
const libraryDir = bookManager.rootDir;
|
|
112
86
|
await refuseRunningOwner(libraryDir);
|
|
87
|
+
getDefaultSecretStore().resolveOnePassword();
|
|
113
88
|
registerAllJobHandlers(scopedEngine);
|
|
114
89
|
const services = createApp({ ui: options.ui, uiDir: options.uiDir });
|
|
115
90
|
const server = services.app.listen(options.port, LOOPBACK_HOST);
|
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { v7 as uuidv7 } from "uuid";
|
|
7
7
|
import { ensureGenerateAudioOutput, normalizeLegacyExtractCells, } from "../notebook-cell-outputs.js";
|
|
8
8
|
import { mutateNotebook, withNotebookMutation } from "../notebook-mutations.js";
|
|
9
|
-
import { queue, stopBookQueue } from "../queue.js";
|
|
9
|
+
import { jobIdForKey, queue, stopBookQueue } from "../queue.js";
|
|
10
10
|
import { claimNotebookGenerationPlan, failQueuedNotebookCells, generationPlanClaimOutputCellId, notebookCellDefinitionFingerprint, notebookCellIsPaid, notebookCellIsRunnable, notebookCellMediaType, markNotebookCellQueued, prepareNotebookGeneration, } from "../jobs/run-notebook.js";
|
|
11
11
|
import { buildGenerationInsertPlan, continueQueuedInsert, } from "./command-plan.js";
|
|
12
12
|
import { assessToolCallEstimate, logUnavailableCostEstimate, } from "../jobs/cost-estimate-assessment.js";
|
|
@@ -271,6 +271,24 @@ function pinnedResultFromInput(input) {
|
|
|
271
271
|
createdAt: input.created_at,
|
|
272
272
|
};
|
|
273
273
|
}
|
|
274
|
+
function jobsByIdOrKey(engine, bookId, ids, keys) {
|
|
275
|
+
const byId = ids.map((id) => ({ id, jobKey: null }));
|
|
276
|
+
const byKey = keys.flatMap((jobKey) => {
|
|
277
|
+
const id = jobIdForKey(jobKey, bookId);
|
|
278
|
+
return id === null ? [] : [{ id, jobKey }];
|
|
279
|
+
});
|
|
280
|
+
return [...byId, ...byKey].flatMap(({ id, jobKey }) => {
|
|
281
|
+
const job = engine.jobs.queue.get(id);
|
|
282
|
+
return job ? [{ job, jobKey }] : [];
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function selectJobs(engine, args) {
|
|
286
|
+
const ids = Array.isArray(args.job_ids) ? args.job_ids.map(Number) : null;
|
|
287
|
+
const keys = Array.isArray(args.job_keys) ? args.job_keys.map(String) : null;
|
|
288
|
+
if (!ids && !keys)
|
|
289
|
+
return engine.jobs.queue.list({ limit: Number(args.limit) || 200 }).map((job) => ({ job, jobKey: null }));
|
|
290
|
+
return jobsByIdOrKey(engine, String(args.book_id), ids ?? [], keys ?? []);
|
|
291
|
+
}
|
|
274
292
|
function json(value) {
|
|
275
293
|
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
276
294
|
}
|
|
@@ -1898,21 +1916,18 @@ export function videobookDomainDefs(engine, books) {
|
|
|
1898
1916
|
},
|
|
1899
1917
|
{
|
|
1900
1918
|
name: "list_jobs",
|
|
1901
|
-
description: "List persistent jobs for a book",
|
|
1919
|
+
description: "List persistent jobs for a book. Filter by numeric job_ids, or by job_keys, the job_id strings other tools return while the server that queued them is running.",
|
|
1902
1920
|
scope: "book",
|
|
1903
1921
|
schema: {
|
|
1904
1922
|
...bookSchema,
|
|
1905
1923
|
limit: z.number().int().positive().max(500).optional(),
|
|
1906
1924
|
job_ids: z.array(z.number().int().positive()).min(1).max(100).optional(),
|
|
1925
|
+
job_keys: z.array(z.string().min(1).max(500)).min(1).max(100).optional(),
|
|
1907
1926
|
},
|
|
1908
1927
|
handler: async (args) => {
|
|
1909
|
-
const jobs =
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
})
|
|
1913
|
-
: engine.jobs.queue.list({ limit: Number(args.limit) || 200 });
|
|
1914
|
-
return json(await Promise.all(jobs.map(async (job) => ({
|
|
1915
|
-
...job, providerTask: await jobProviderTaskStatus(engine, job),
|
|
1928
|
+
const jobs = selectJobs(engine, args);
|
|
1929
|
+
return json(await Promise.all(jobs.map(async ({ job, jobKey }) => ({
|
|
1930
|
+
...job, ...(jobKey ? { jobKey } : {}), providerTask: await jobProviderTaskStatus(engine, job),
|
|
1916
1931
|
}))));
|
|
1917
1932
|
},
|
|
1918
1933
|
},
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
4
|
+
import { FFMPEG } from "./ffmpeg-binary.js";
|
|
5
|
+
const YTDLP_OVERRIDE = "VIDEOBOOK_YTDLP_PATH";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
export const YTDLP_MISSING_MESSAGE = "yt-dlp is not installed. Put yt-dlp on PATH (for example brew install yt-dlp), set VIDEOBOOK_YTDLP_PATH, "
|
|
8
|
+
+ "or reinstall with npm install -g videobook --allow-scripts=ffmpeg-static,ytdlp-nodejs,@dolthub/doltlite,usearch,onnxruntime-node,esbuild";
|
|
9
|
+
function isExecutableFile(path) {
|
|
10
|
+
try {
|
|
11
|
+
if (!statSync(path).isFile())
|
|
12
|
+
return false;
|
|
13
|
+
accessSync(path, constants.X_OK);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function executableNames(name, env) {
|
|
21
|
+
if (process.platform !== "win32")
|
|
22
|
+
return [name];
|
|
23
|
+
const extensions = (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
|
24
|
+
return [name, ...extensions.map((extension) => `${name}${extension.toLowerCase()}`)];
|
|
25
|
+
}
|
|
26
|
+
export function findOnPath(name, env = process.env) {
|
|
27
|
+
const directories = (env.PATH ?? "").split(delimiter).filter((directory) => isAbsolute(directory));
|
|
28
|
+
for (const directory of directories) {
|
|
29
|
+
const match = executableNames(name, env).map((candidate) => join(directory, candidate)).find(isExecutableFile);
|
|
30
|
+
if (match)
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export function bundledYtdlp() {
|
|
36
|
+
try {
|
|
37
|
+
const { helpers } = require("ytdlp-nodejs");
|
|
38
|
+
const path = helpers.findYtdlpBinary();
|
|
39
|
+
return path && isExecutableFile(path) ? path : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function resolveYtdlp(env = process.env, bundled = bundledYtdlp()) {
|
|
46
|
+
const override = env[YTDLP_OVERRIDE]?.trim();
|
|
47
|
+
if (override)
|
|
48
|
+
return { path: override, source: "VIDEOBOOK_YTDLP_PATH" };
|
|
49
|
+
if (bundled)
|
|
50
|
+
return { path: bundled, source: "bundled" };
|
|
51
|
+
const onPath = findOnPath("yt-dlp", env);
|
|
52
|
+
return onPath ? { path: onPath, source: "PATH" } : null;
|
|
53
|
+
}
|
|
54
|
+
function ffmpegForYtdlp(env) {
|
|
55
|
+
if (isAbsolute(FFMPEG))
|
|
56
|
+
return isExecutableFile(FFMPEG) ? FFMPEG : null;
|
|
57
|
+
return findOnPath(FFMPEG, env);
|
|
58
|
+
}
|
|
59
|
+
export function ytdlpRunOptions(env = process.env, bundled = bundledYtdlp()) {
|
|
60
|
+
const binary = resolveYtdlp(env, bundled);
|
|
61
|
+
if (!binary)
|
|
62
|
+
throw new Error(YTDLP_MISSING_MESSAGE);
|
|
63
|
+
const ffmpeg = ffmpegForYtdlp(env);
|
|
64
|
+
return { binaryPath: binary.path, ...(ffmpeg ? { ffmpegPath: ffmpeg } : {}) };
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "videobook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Local-first MCP server and web UI for AI video creation: notebooks, generation, editing, rendering and publishing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -13,15 +13,7 @@
|
|
|
13
13
|
"cli"
|
|
14
14
|
],
|
|
15
15
|
"license": "MIT",
|
|
16
|
-
"homepage": "https://
|
|
17
|
-
"repository": {
|
|
18
|
-
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/justintanner/videobook.git",
|
|
20
|
-
"directory": "server"
|
|
21
|
-
},
|
|
22
|
-
"bugs": {
|
|
23
|
-
"url": "https://github.com/justintanner/videobook/issues"
|
|
24
|
-
},
|
|
16
|
+
"homepage": "https://www.npmjs.com/package/videobook",
|
|
25
17
|
"type": "module",
|
|
26
18
|
"bin": {
|
|
27
19
|
"videobook": "bin/videobook.js"
|
|
@@ -57,6 +49,7 @@
|
|
|
57
49
|
"@apicity/x": "^0.16.1",
|
|
58
50
|
"@apicity/xai": "^0.16.1",
|
|
59
51
|
"@devmehq/open-graph-extractor": "^1.1.1",
|
|
52
|
+
"@ffprobe-installer/ffprobe": "2.1.2",
|
|
60
53
|
"@modelcontextprotocol/client": "^2.2.0",
|
|
61
54
|
"@modelcontextprotocol/node": "2.0.0",
|
|
62
55
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
@@ -68,7 +61,6 @@
|
|
|
68
61
|
"express": "^5.2.1",
|
|
69
62
|
"ffmpeg-static": "^5.3.0",
|
|
70
63
|
"ffmpeggy": "^3.1.3",
|
|
71
|
-
"ffprobe-static": "^3.1.0",
|
|
72
64
|
"heic-decode": "^2.1.0",
|
|
73
65
|
"hono": "^4.12.31",
|
|
74
66
|
"qrcode": "^1.5.4",
|