@stephane-klein/youtube-to-markdown 0.1.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/README.md +324 -0
- package/package.json +34 -0
- package/src/cli.js +155 -0
- package/src/config.js +45 -0
- package/src/download-vtt.js +59 -0
- package/src/extract-video-metadata.js +176 -0
- package/src/generate-markdown.js +475 -0
- package/src/markdown-stats.js +200 -0
- package/src/organize-files.js +137 -0
- package/src/vtt.js +137 -0
- package/src/ytdlp.js +25 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { DEFAULT_DIR } from "./vtt.js";
|
|
5
|
+
|
|
6
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
7
|
+
const LANGUAGE = /\.([^./]+)\.md$/;
|
|
8
|
+
|
|
9
|
+
async function markdownFiles(dir, base = dir) {
|
|
10
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
11
|
+
const files = [];
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const path = join(dir, entry.name);
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
files.push(...(await markdownFiles(path, base)));
|
|
16
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
17
|
+
files.push(relative(base, path));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return files;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const numberOrNull = (value) =>
|
|
24
|
+
typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
25
|
+
|
|
26
|
+
function safeParse(text) {
|
|
27
|
+
try {
|
|
28
|
+
return parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bump(map, key, { input, cached, output, cost }) {
|
|
35
|
+
const entry = map.get(key) ??
|
|
36
|
+
{ files: 0, input: 0, cached: 0, output: 0, cost: 0, unknownCost: 0 };
|
|
37
|
+
entry.files++;
|
|
38
|
+
entry.input += input ?? 0;
|
|
39
|
+
entry.cached += cached ?? 0;
|
|
40
|
+
entry.output += output ?? 0;
|
|
41
|
+
if (cost === null) entry.unknownCost++;
|
|
42
|
+
else entry.cost += cost;
|
|
43
|
+
map.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const int = new Intl.NumberFormat("en-US");
|
|
47
|
+
const seconds = (value) => `${value.toFixed(1)}s`;
|
|
48
|
+
const usd = (value) => `$${value.toFixed(6)}`;
|
|
49
|
+
const row = (label, value) => ` ${label.padEnd(20)} ${value}`;
|
|
50
|
+
|
|
51
|
+
export async function runMarkdownStats({ dir = DEFAULT_DIR } = {}) {
|
|
52
|
+
const stats = {
|
|
53
|
+
files: 0,
|
|
54
|
+
withFrontmatter: 0,
|
|
55
|
+
withoutFrontmatter: 0,
|
|
56
|
+
inputTokens: 0,
|
|
57
|
+
cachedInputTokens: 0,
|
|
58
|
+
outputTokens: 0,
|
|
59
|
+
cost: 0,
|
|
60
|
+
unknownCost: 0,
|
|
61
|
+
durationSum: 0,
|
|
62
|
+
durationCount: 0,
|
|
63
|
+
durationMin: null,
|
|
64
|
+
durationMax: null,
|
|
65
|
+
firstGeneratedAt: null,
|
|
66
|
+
lastGeneratedAt: null,
|
|
67
|
+
models: new Map(),
|
|
68
|
+
languages: new Map(),
|
|
69
|
+
folders: new Map(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const names = (await markdownFiles(dir)).sort();
|
|
73
|
+
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
stats.files++;
|
|
76
|
+
|
|
77
|
+
const match = FRONTMATTER.exec(await readFile(join(dir, name), "utf8"));
|
|
78
|
+
const parsed = match ? safeParse(match[1]) : null;
|
|
79
|
+
const data =
|
|
80
|
+
parsed && typeof parsed === "object" && "generated_at" in parsed
|
|
81
|
+
? parsed
|
|
82
|
+
: null;
|
|
83
|
+
|
|
84
|
+
if (!data) {
|
|
85
|
+
stats.withoutFrontmatter++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
stats.withFrontmatter++;
|
|
89
|
+
|
|
90
|
+
const llm = data.llm ?? {};
|
|
91
|
+
const input = numberOrNull(llm.input_tokens);
|
|
92
|
+
const cached = numberOrNull(llm.cached_input_tokens);
|
|
93
|
+
const output = numberOrNull(llm.output_tokens);
|
|
94
|
+
const cost = numberOrNull(llm.estimated_cost_usd);
|
|
95
|
+
const duration = numberOrNull(llm.duration_seconds);
|
|
96
|
+
|
|
97
|
+
stats.inputTokens += input ?? 0;
|
|
98
|
+
stats.cachedInputTokens += cached ?? 0;
|
|
99
|
+
stats.outputTokens += output ?? 0;
|
|
100
|
+
if (cost === null) stats.unknownCost++;
|
|
101
|
+
else stats.cost += cost;
|
|
102
|
+
|
|
103
|
+
if (duration !== null) {
|
|
104
|
+
stats.durationSum += duration;
|
|
105
|
+
stats.durationCount++;
|
|
106
|
+
stats.durationMin = stats.durationMin === null
|
|
107
|
+
? duration
|
|
108
|
+
: Math.min(stats.durationMin, duration);
|
|
109
|
+
stats.durationMax = stats.durationMax === null
|
|
110
|
+
? duration
|
|
111
|
+
: Math.max(stats.durationMax, duration);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof data.generated_at === "string") {
|
|
115
|
+
const at = data.generated_at;
|
|
116
|
+
if (!stats.firstGeneratedAt || at < stats.firstGeneratedAt) {
|
|
117
|
+
stats.firstGeneratedAt = at;
|
|
118
|
+
}
|
|
119
|
+
if (!stats.lastGeneratedAt || at > stats.lastGeneratedAt) {
|
|
120
|
+
stats.lastGeneratedAt = at;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
bump(stats.models, llm.model ?? "unknown", { input, cached, output, cost });
|
|
125
|
+
bump(stats.languages, LANGUAGE.exec(name)?.[1] ?? "unknown", {
|
|
126
|
+
input,
|
|
127
|
+
cached,
|
|
128
|
+
output,
|
|
129
|
+
cost,
|
|
130
|
+
});
|
|
131
|
+
const segments = name.split(/[\\/]/);
|
|
132
|
+
bump(stats.folders, segments.length > 1 ? segments[0] : "(root)", {
|
|
133
|
+
input,
|
|
134
|
+
cached,
|
|
135
|
+
output,
|
|
136
|
+
cost,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const lines = [
|
|
141
|
+
`${dir}/ — ${int.format(stats.files)} file(s)`,
|
|
142
|
+
row("with frontmatter", int.format(stats.withFrontmatter)),
|
|
143
|
+
row("without frontmatter", int.format(stats.withoutFrontmatter)),
|
|
144
|
+
"",
|
|
145
|
+
"tokens",
|
|
146
|
+
row("input", int.format(stats.inputTokens)),
|
|
147
|
+
row("cached input", int.format(stats.cachedInputTokens)),
|
|
148
|
+
row("output", int.format(stats.outputTokens)),
|
|
149
|
+
row("total", int.format(stats.inputTokens + stats.outputTokens)),
|
|
150
|
+
"",
|
|
151
|
+
"cost",
|
|
152
|
+
row("estimated", usd(stats.cost)),
|
|
153
|
+
row("unknown prices", `${int.format(stats.unknownCost)} file(s)`),
|
|
154
|
+
"",
|
|
155
|
+
"processing time",
|
|
156
|
+
row("total", seconds(stats.durationSum)),
|
|
157
|
+
row(
|
|
158
|
+
"mean",
|
|
159
|
+
seconds(
|
|
160
|
+
stats.durationCount ? stats.durationSum / stats.durationCount : 0,
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
row(
|
|
164
|
+
"min / max",
|
|
165
|
+
stats.durationCount
|
|
166
|
+
? `${seconds(stats.durationMin)} / ${seconds(stats.durationMax)}`
|
|
167
|
+
: "—",
|
|
168
|
+
),
|
|
169
|
+
"",
|
|
170
|
+
"generated_at",
|
|
171
|
+
row("first", stats.firstGeneratedAt ?? "—"),
|
|
172
|
+
row("last", stats.lastGeneratedAt ?? "—"),
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const breakdown = (title, map) => {
|
|
176
|
+
lines.push("", title);
|
|
177
|
+
if (map.size === 0) {
|
|
178
|
+
lines.push(" (none)");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const key of [...map.keys()].sort()) {
|
|
182
|
+
const entry = map.get(key);
|
|
183
|
+
const unknown = entry.unknownCost > 0
|
|
184
|
+
? ` (+${int.format(entry.unknownCost)} unknown)`
|
|
185
|
+
: "";
|
|
186
|
+
lines.push(
|
|
187
|
+
` ${key.padEnd(16)} ${int.format(entry.files).padStart(4)} file(s) ` +
|
|
188
|
+
`${int.format(entry.input).padStart(9)} in / ` +
|
|
189
|
+
`${int.format(entry.output).padStart(9)} out ` +
|
|
190
|
+
`${usd(entry.cost)}${unknown}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
breakdown("models", stats.models);
|
|
196
|
+
breakdown("languages", stats.languages);
|
|
197
|
+
breakdown("folders", stats.folders);
|
|
198
|
+
|
|
199
|
+
console.log(lines.join("\n"));
|
|
200
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { mkdir, readdir, rename } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_DIR,
|
|
5
|
+
ORPHANS_DIR,
|
|
6
|
+
assertFolderSlug,
|
|
7
|
+
exists,
|
|
8
|
+
primaryTitle,
|
|
9
|
+
slugify,
|
|
10
|
+
} from "./vtt.js";
|
|
11
|
+
|
|
12
|
+
const MARKER = /^(.*)\.([^.]+)\.vtt\.missing$/;
|
|
13
|
+
const VTT = /^(.*)\.([^.]+)\.vtt$/;
|
|
14
|
+
const MD = /^(.*)\.([^.]+)\.md$/;
|
|
15
|
+
|
|
16
|
+
function prefixOf(name) {
|
|
17
|
+
for (const pattern of [MARKER, VTT, MD]) {
|
|
18
|
+
const match = pattern.exec(name);
|
|
19
|
+
if (match) return match[1];
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function walk(dir) {
|
|
25
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const path = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (entry.name === ORPHANS_DIR) continue;
|
|
31
|
+
files.push(...(await walk(path)));
|
|
32
|
+
} else if (entry.isFile()) {
|
|
33
|
+
files.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function foldersByPrefix(feed) {
|
|
40
|
+
const targets = new Map();
|
|
41
|
+
for (const channel of feed) {
|
|
42
|
+
const folder = assertFolderSlug(channel.folder_slug) ?? "";
|
|
43
|
+
for (const video of channel.videos ?? []) {
|
|
44
|
+
const slug = slugify(primaryTitle(video));
|
|
45
|
+
if (!video.date || !slug) continue;
|
|
46
|
+
const prefix = `${video.date}_${slug}`;
|
|
47
|
+
const folders = targets.get(prefix) ?? new Set();
|
|
48
|
+
folders.add(folder);
|
|
49
|
+
targets.set(prefix, folders);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return targets;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runReorganize({
|
|
56
|
+
feed = [],
|
|
57
|
+
dir = DEFAULT_DIR,
|
|
58
|
+
dryRun = false,
|
|
59
|
+
} = {}) {
|
|
60
|
+
const targets = foldersByPrefix(feed);
|
|
61
|
+
const files = (await exists(dir)) ? (await walk(dir)).sort() : [];
|
|
62
|
+
const claimed = new Set();
|
|
63
|
+
|
|
64
|
+
const moves = [];
|
|
65
|
+
const conflicts = [];
|
|
66
|
+
let inPlace = 0;
|
|
67
|
+
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const name = basename(file);
|
|
70
|
+
const folders = targets.get(prefixOf(name));
|
|
71
|
+
|
|
72
|
+
let target;
|
|
73
|
+
let orphan = false;
|
|
74
|
+
if (!folders || folders.size === 0) {
|
|
75
|
+
target = join(dir, ORPHANS_DIR, relative(dir, file));
|
|
76
|
+
orphan = true;
|
|
77
|
+
} else if (folders.size > 1) {
|
|
78
|
+
conflicts.push({
|
|
79
|
+
file,
|
|
80
|
+
reason: `ambiguous folder_slug (${[...folders].sort().join(", ")})`,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
} else {
|
|
84
|
+
const folder = [...folders][0];
|
|
85
|
+
target = folder ? join(dir, folder, name) : join(dir, name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (target === file) {
|
|
89
|
+
inPlace++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (claimed.has(target) || (await exists(target))) {
|
|
93
|
+
conflicts.push({
|
|
94
|
+
file,
|
|
95
|
+
reason: `target already exists: ${relative(dir, target)}`,
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
claimed.add(target);
|
|
101
|
+
moves.push({ from: file, to: target, orphan });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const width = String(moves.length).length;
|
|
105
|
+
let moved = 0;
|
|
106
|
+
let orphans = 0;
|
|
107
|
+
for (const [index, move] of moves.entries()) {
|
|
108
|
+
const prefix = `[${String(index + 1).padStart(width)}/${moves.length}]`;
|
|
109
|
+
const status = move.orphan ? "orphan" : "moved";
|
|
110
|
+
if (move.orphan) orphans++;
|
|
111
|
+
else moved++;
|
|
112
|
+
if (dryRun) {
|
|
113
|
+
console.error(
|
|
114
|
+
`${prefix} ${status.padEnd(9)} ${relative(dir, move.to)} (dry run)`,
|
|
115
|
+
);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
await mkdir(dirname(move.to), { recursive: true });
|
|
119
|
+
await rename(move.from, move.to);
|
|
120
|
+
console.error(`${prefix} ${status.padEnd(9)} ${relative(dir, move.to)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const { file, reason } of conflicts) {
|
|
124
|
+
console.error(`conflict ${relative(dir, file)} (${reason})`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (dryRun) {
|
|
128
|
+
console.error(`dry run: no file was moved`);
|
|
129
|
+
console.error(
|
|
130
|
+
`summary: ${moved} to move, ${orphans} to orphan, ${inPlace} already in place, ${conflicts.length} conflict`,
|
|
131
|
+
);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
console.error(
|
|
135
|
+
`summary: ${moved} moved, ${orphans} orphan, ${inPlace} already in place, ${conflicts.length} conflict`,
|
|
136
|
+
);
|
|
137
|
+
}
|
package/src/vtt.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFile,
|
|
3
|
+
mkdtemp,
|
|
4
|
+
readdir,
|
|
5
|
+
rm,
|
|
6
|
+
stat,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
import { runYtDlp } from "./ytdlp.js";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_DIR = resolve("contents");
|
|
14
|
+
export const ORPHANS_DIR = "_orphans";
|
|
15
|
+
|
|
16
|
+
export function assertFolderSlug(folderSlug) {
|
|
17
|
+
if (folderSlug === undefined || folderSlug === null || folderSlug === "") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (typeof folderSlug !== "string") {
|
|
21
|
+
throw new Error("folder_slug must be a string");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const segments = folderSlug.split(/[\\/]+/).filter(Boolean);
|
|
25
|
+
if (isAbsolute(folderSlug) || segments.includes("..")) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`folder_slug must be a relative path inside contents_path: ${folderSlug}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (segments.length === 0) {
|
|
31
|
+
throw new Error("folder_slug must not be empty");
|
|
32
|
+
}
|
|
33
|
+
if (segments[0] === ORPHANS_DIR) {
|
|
34
|
+
throw new Error(`folder_slug must not be "${ORPHANS_DIR}" (reserved)`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return segments.join("/");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const slugify = (text) =>
|
|
41
|
+
text
|
|
42
|
+
.normalize("NFD")
|
|
43
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
46
|
+
.replace(/^-+|-+$/g, "");
|
|
47
|
+
|
|
48
|
+
export const primaryTitle = (video) => {
|
|
49
|
+
const title = video.title;
|
|
50
|
+
if (typeof title === "string") return title;
|
|
51
|
+
return title?.fr ?? title?.en ?? "";
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const titleForLang = (title, lang) => {
|
|
55
|
+
if (typeof title === "string") return title;
|
|
56
|
+
return title?.[lang] ?? title?.fr ?? title?.en ?? "";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export async function exists(path) {
|
|
60
|
+
try {
|
|
61
|
+
await stat(path);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function findVtt(dir) {
|
|
69
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
70
|
+
const entry = entries.find(
|
|
71
|
+
(item) => item.isFile && item.name.endsWith(".vtt"),
|
|
72
|
+
);
|
|
73
|
+
return entry ? join(dir, entry.name) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function writeMarker(path, url) {
|
|
77
|
+
await writeFile(path, `${url}\n${new Date().toISOString()}\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function downloadVtt(url, lang, dest) {
|
|
81
|
+
const tmp = await mkdtemp(join(tmpdir(), "yt-to-md-"));
|
|
82
|
+
let failed = false;
|
|
83
|
+
try {
|
|
84
|
+
for (const mode of ["--write-subs", "--write-auto-subs"]) {
|
|
85
|
+
const { success } = await runYtDlp(
|
|
86
|
+
[
|
|
87
|
+
"--skip-download",
|
|
88
|
+
"--sleep-requests",
|
|
89
|
+
"2",
|
|
90
|
+
mode,
|
|
91
|
+
"--sub-langs",
|
|
92
|
+
`^${lang}$`,
|
|
93
|
+
"--sub-format",
|
|
94
|
+
"vtt",
|
|
95
|
+
"-o",
|
|
96
|
+
join(tmp, "sub.%(ext)s"),
|
|
97
|
+
url,
|
|
98
|
+
],
|
|
99
|
+
{ allowFailure: true },
|
|
100
|
+
);
|
|
101
|
+
if (!success) failed = true;
|
|
102
|
+
const vtt = await findVtt(tmp);
|
|
103
|
+
if (vtt) {
|
|
104
|
+
await copyFile(vtt, dest);
|
|
105
|
+
return "downloaded";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return failed ? "error" : "missing";
|
|
109
|
+
} finally {
|
|
110
|
+
await rm(tmp, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function videoJobs(video, dir = DEFAULT_DIR, folderSlug = null) {
|
|
115
|
+
const folder = assertFolderSlug(folderSlug);
|
|
116
|
+
const langs = Object.entries(video.download_vtt ?? {})
|
|
117
|
+
.filter(([, on]) => on)
|
|
118
|
+
.map(([lang]) => lang);
|
|
119
|
+
if (langs.length === 0 || !video.url) return [];
|
|
120
|
+
|
|
121
|
+
const slug = slugify(primaryTitle(video));
|
|
122
|
+
if (!slug) return [];
|
|
123
|
+
|
|
124
|
+
return langs.map((lang) => {
|
|
125
|
+
const name = `${video.date}_${slug}.${lang}`;
|
|
126
|
+
const base = folder ? join(dir, folder, name) : join(dir, name);
|
|
127
|
+
return {
|
|
128
|
+
url: video.url,
|
|
129
|
+
lang,
|
|
130
|
+
title: primaryTitle(video),
|
|
131
|
+
titles: video.title,
|
|
132
|
+
base,
|
|
133
|
+
vtt: `${base}.vtt`,
|
|
134
|
+
marker: `${base}.vtt.missing`,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}
|
package/src/ytdlp.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const JS_RUNTIME = ["--js-runtimes", "node"];
|
|
4
|
+
|
|
5
|
+
export function runYtDlp(args, { stderr = "ignore", allowFailure = false } = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(
|
|
8
|
+
"yt-dlp",
|
|
9
|
+
["--no-warnings", ...JS_RUNTIME, ...args],
|
|
10
|
+
{ stdio: ["ignore", "pipe", stderr] },
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const chunks = [];
|
|
14
|
+
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
15
|
+
child.on("error", reject);
|
|
16
|
+
child.on("close", (code) => {
|
|
17
|
+
const stdout = Buffer.concat(chunks).toString("utf8");
|
|
18
|
+
if (code === 0 || allowFailure) {
|
|
19
|
+
resolve({ success: code === 0, code, stdout });
|
|
20
|
+
} else {
|
|
21
|
+
reject(new Error(`yt-dlp a échoué (code ${code}) : ${args.join(" ")}`));
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|