@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 ADDED
@@ -0,0 +1,324 @@
1
+ # youtube-feed-to-markdown
2
+
3
+ I collect the videos of a few YouTube channels into `youtube_to_markdown.yaml`,
4
+ using [yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch the metadata. It is
5
+ published on npmjs as `@stephane-klein/youtube-to-markdown`.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 22 or later;
10
+ - [yt-dlp](https://github.com/yt-dlp/yt-dlp) on your `PATH`.
11
+
12
+ The commands call yt-dlp with `--js-runtimes node`: solving YouTube's JavaScript
13
+ challenges requires an external JavaScript runtime, and Node.js — already
14
+ required here — does the job.
15
+
16
+ ## Install
17
+
18
+ The package is published on npmjs as `@stephane-klein/youtube-to-markdown`.
19
+ Install it globally to get the `youtube-to-markdown` command on your `PATH`:
20
+
21
+ ```sh
22
+ $ npm install -g @stephane-klein/youtube-to-markdown
23
+ ```
24
+
25
+ Or run it without installing it, with `npx`:
26
+
27
+ ```sh
28
+ $ npx @stephane-klein/youtube-to-markdown extract-video-metadata
29
+ ```
30
+
31
+ Either way, `yt-dlp` must be on your `PATH` (see Requirements). The commands
32
+ read `youtube_to_markdown.yaml` from the current directory unless you pass
33
+ `--config`.
34
+
35
+ ## Run
36
+
37
+ ```sh
38
+ $ youtube-to-markdown extract-video-metadata
39
+ → https://www.youtube.com/@le_science4all
40
+ → https://www.youtube.com/@MonsieurPhi
41
+ ```
42
+
43
+ `youtube-to-markdown` exposes five subcommands: `extract-video-metadata`,
44
+ `download-vtt`, `generate-markdown`, `markdown-stats` and `reorganize`. Run
45
+ `youtube-to-markdown <command> --help` for the available options.
46
+
47
+ The configuration file is `youtube_to_markdown.yaml` (override it with
48
+ `--config` or `YT_TO_MD_CONFIG`). Its settings resolve in this order: command-line
49
+ flags, then `YT_TO_MD_*` environment variables, then the settings written in the
50
+ YAML file, then the built-in defaults. The API key stays a secret: `--api-key` or
51
+ `OPENAI_API_KEY`.
52
+
53
+ The `extract-video-metadata` command reads the channel URLs from the
54
+ configuration file, asks yt-dlp for each channel's videos, and writes the result
55
+ back into the file. I run it from time to time: it is idempotent, so a new run
56
+ only adds the videos that are missing.
57
+
58
+ ## Configuration
59
+
60
+ `youtube_to_markdown.yaml` holds the settings and the channels. Settings are
61
+ `x_opencode_session`, `model_id`, `openaiapi_endpoint`, `concurrency` and
62
+ `ytdlp_concurrency` (plus the optional `api_key`, `retry_delays` and
63
+ `max_output_tokens`). I only set the channel URLs, and the
64
+ `extract-video-metadata` command fills the rest:
65
+
66
+ ```yaml
67
+ x_opencode_session: "youtube-to-markdown/0.1"
68
+ model_id: "mimo-v2.5"
69
+ openaiapi_endpoint: "https://opencode.ai/zen/go/v1/chat/completions"
70
+ concurrency: "6"
71
+ ytdlp_concurrency: "2"
72
+ feed:
73
+ - title: Science4All
74
+ folder_slug: science-4-all
75
+ url: https://www.youtube.com/@le_science4all
76
+ videos:
77
+ - title:
78
+ fr: Pourquoi π est-il si fou ? Relativité 1
79
+ en: Why is π so crazy? Relativity 1
80
+ date: 2016-02-12
81
+ url: https://www.youtube.com/watch?v=PxRPpdzmbUQ
82
+ - title: MrPhi
83
+ url: https://www.youtube.com/@MonsieurPhi
84
+ videos: []
85
+ ```
86
+
87
+ `contents_path` (optional) chooses the directory that holds the transcripts and
88
+ the generated Markdown. It accepts an absolute path or a path relative to the
89
+ directory of `youtube_to_markdown.yaml`; it defaults to `contents`. Unlike the
90
+ other settings, it can only be set in the YAML file — there is no flag or
91
+ environment variable for it. `.gitignore` only ignores the default `contents/`,
92
+ so add your own path there if you move it.
93
+
94
+ `folder_slug` (optional, on a channel) stores that channel's transcripts and
95
+ Markdown in a subdirectory of `contents_path`, for example `science-4-all`.
96
+ It is a relative path, so it stays inside `contents_path`; a channel without
97
+ `folder_slug` keeps its files at the root of `contents_path`. The reserved name
98
+ `_orphans` is rejected. Change it whenever you like, then run
99
+ `youtube-to-markdown reorganize` to move the existing files.
100
+
101
+ ## Transcripts
102
+
103
+ I mark the videos I want as transcripts with a `download_vtt` field:
104
+
105
+ ```yaml
106
+ - title:
107
+ fr: Pourquoi π est-il si fou ? Relativité 1
108
+ en: Why is π so crazy? Relativity 1
109
+ date: 2016-02-12
110
+ url: https://www.youtube.com/watch?v=PxRPpdzmbUQ
111
+ download_vtt:
112
+ fr: true
113
+ ```
114
+
115
+ Then:
116
+
117
+ ```sh
118
+ $ youtube-to-markdown download-vtt
119
+ 21 transcript(s) to check
120
+ [ 1/21] downloaded 2016-07-14_le-sol-accelere-t-il-vraiment-vers-le-haut-debattonsmieux.fr.vtt
121
+ [ 2/21] already downloaded 2016-08-25_les-synonymes-a-connotations-opposees-debattonsmieux.fr.vtt
122
+ [ 3/21] missing 2017-06-26_favoriser-l-honnetete-democratie-18.fr.vtt
123
+ …
124
+ summary: 1 downloaded, 1 already downloaded, 1 missing, 0 error
125
+ ```
126
+
127
+ For each marked video, the command writes the subtitle to
128
+ `contents/<date>_<slug>.<lang>.vtt`, for example
129
+ `contents/2016-02-12_pourquoi-est-il-si-fou-relativite-1.fr.vtt`. It prefers
130
+ manual subtitles and falls back to the auto-generated ones. A file that already
131
+ exists is left untouched, so running the command again only fetches new
132
+ transcripts. The `extract-video-metadata` command keeps the `download_vtt`
133
+ field.
134
+
135
+ When a video has no subtitle at all, the command writes a `<…>.vtt.missing`
136
+ marker and stops trying. I delete that marker to force a new attempt.
137
+
138
+ ## Markdown
139
+
140
+ I mark the videos I want as Markdown with `generate_markdown`. The transcript is
141
+ downloaded first if it is not there yet:
142
+
143
+ ```yaml
144
+ download_vtt:
145
+ fr: true
146
+ generate_markdown:
147
+ fr: true
148
+ ```
149
+
150
+ Before the first run, provide your API key through the `OPENAI_API_KEY`
151
+ environment variable, or pass it with `--api-key`.
152
+
153
+ Then:
154
+
155
+ ```sh
156
+ $ youtube-to-markdown generate-markdown
157
+ 74 markdown(s) to generate with mimo-v2.5
158
+ ✔ Download transcripts (73/74)
159
+ › downloaded 2017-08-04_nietzsche-la-morale-des-winners-genealogie-de-la-morale-1-2.fr.md
160
+ › skipped (no transcript) 2017-06-26_favoriser-l-honnetete-democratie-18.fr.md
161
+ ✔ Generate markdown (73/74, 37 already)
162
+ › generated 2017-10-13_le-scepticisme-le-trilemme-d-agrippa-grain-de-philo-14-ep-2.fr.md mimo-v2.5 llm 87.1s 4428 in / 3262 out ~$0.000927
163
+ …
164
+ summary: 36 generated, 37 already generated, 1 skipped, 0 error
165
+ total: 3135.6s, 159408 in / 117432 out, ~$0.033372
166
+ ```
167
+
168
+ The command sends each transcript to an LLM through ai-sdk, configured with the
169
+ API key (`OPENAI_API_KEY` or `--api-key`), the model (`YT_TO_MD_MODEL_ID` or
170
+ `--model`) and the endpoint (`YT_TO_MD_OPENAIAPI_ENDPOINT` or `--endpoint`), and
171
+ gets back Markdown prose with section headings when the talk needs them.
172
+ Paragraphs are hard-wrapped at 80 columns. The result goes to
173
+ `contents/<date>_<slug>.<lang>.md`. A second run reports `already generated` and
174
+ writes nothing.
175
+
176
+ The command runs in two sequential phases. `Download transcripts` reports how many
177
+ videos have a transcript (`available/total`) and fetches the missing ones, at
178
+ most `--ytdlp-concurrency` at a time (default 2); only downloads, skips and errors
179
+ are listed. The `Generate markdown` phase then converts the transcripts, at most
180
+ `--concurrency` at a time (default 6), reporting the number of up-to-date
181
+ Markdown files over the whole feed (`ready/total, N already`). Each phase keeps
182
+ only the last 20 events on screen, so it stays bounded even with a large feed.
183
+
184
+ Retryable API errors (HTTP 408, 409, 429 or >= 500) are retried with growing
185
+ delays — 10 s, 30 s, 60 s by default, configurable with `--retry-delays`
186
+ (comma-separated seconds, or `YT_TO_MD_RETRY_DELAYS`). A `Retry-After` header
187
+ from the server is honored when longer than the schedule. Each retry is shown in
188
+ the phase output; after the last attempt the file is reported as an error and is
189
+ retried on the next run.
190
+
191
+ ### Frontmatter
192
+
193
+ Each generated file starts with a YAML frontmatter describing the run:
194
+
195
+ ```yaml
196
+ ---
197
+ source_url: https://www.youtube.com/watch?v=PxRPpdzmbUQ
198
+ video_title: Pourquoi π est-il si fou ? Relativité 1
199
+ generated_at: 2026-09-19T10:12:33.456Z
200
+ llm:
201
+ model: mimo-v2.5
202
+ duration_seconds: 87.1
203
+ input_tokens: 4428
204
+ cached_input_tokens: 0
205
+ output_tokens: 3262
206
+ estimated_cost_usd: 0.000927
207
+ finish_reason: stop
208
+ ---
209
+ ```
210
+
211
+ `video_title` is the original video title in the file's language and
212
+ `generated_at` is the UTC time of the generation. `estimated_cost_usd` is `null`
213
+ when the model is not in the price table. Existing files are left untouched: run
214
+ `youtube-to-markdown generate-markdown --force` to regenerate every file, which
215
+ is also the way to add the frontmatter to files generated before it existed.
216
+
217
+ ## Folders
218
+
219
+ A channel may set `folder_slug` to keep its files in their own subdirectory of
220
+ `contents_path`. When you add, change or remove that setting, `reorganize` moves
221
+ the already downloaded transcripts and generated Markdown to match. It runs for
222
+ real by default; add `--dry-run` to preview:
223
+
224
+ ```sh
225
+ $ youtube-to-markdown reorganize --dry-run
226
+ [ 1/38] moved science-4-all/2016-07-14_le-sol-accelere….fr.vtt (dry run)
227
+ …
228
+ dry run: no file was moved
229
+ summary: 38 to move, 0 to orphan, 112 already in place, 0 conflict
230
+ ```
231
+
232
+ A file that no longer matches any video of the feed is an orphan: the command
233
+ moves it under `contents_path/_orphans/`, keeping its relative path. A file whose
234
+ target already exists, or a filename shared by two channels with different
235
+ `folder_slug`, is reported as a conflict and left untouched.
236
+
237
+ ## Stats
238
+
239
+ `markdown-stats` reads the frontmatter of every `*.md` under `contents_path`
240
+ (recursively) and prints global totals — tokens, estimated cost, processing
241
+ time — with a breakdown per model, per language and per folder (`folder_slug`,
242
+ or `(root)` when the file sits at the root of `contents_path`):
243
+
244
+ ```sh
245
+ $ youtube-to-markdown markdown-stats
246
+ contents/ — 43 file(s)
247
+ with frontmatter 3
248
+ without frontmatter 40
249
+
250
+ tokens
251
+ input 16,134
252
+ cached input 16,000
253
+ output 14,486
254
+ total 30,620
255
+
256
+ cost
257
+ estimated $0.004120
258
+ unknown prices 0 file(s)
259
+
260
+ processing time
261
+ total 395.6s
262
+ mean 131.9s
263
+ min / max 111.8s / 165.3s
264
+
265
+ generated_at
266
+ first 2026-09-19T08:56:54.497Z
267
+ last 2026-09-19T08:57:47.976Z
268
+
269
+ models
270
+ mimo-v2.5 3 file(s) 16,134 in / 14,486 out $0.004120
271
+
272
+ languages
273
+ fr 3 file(s) 16,134 in / 14,486 out $0.004120
274
+
275
+ folders
276
+ science-4-all 2 file(s) 10,134 in / 9,486 out $0.002120
277
+ (root) 1 file(s) 6,000 in / 5,000 out $0.002000
278
+ ```
279
+
280
+ Files without frontmatter (generated before the frontmatter existed) are counted
281
+ but left out of the totals.
282
+
283
+ ## Development
284
+
285
+ To work on this project, [mise](https://mise.jdx.dev/) installs both yt-dlp and
286
+ Node.js and defines the `youtube-to-markdown` shell alias; `npm install` fetches
287
+ the JavaScript dependencies:
288
+
289
+ ```sh
290
+ $ mise install
291
+ $ npm install
292
+ ```
293
+
294
+ The shell alias maps `youtube-to-markdown` to `node src/cli.js`. It is set when
295
+ you enter the project from an interactive bash, zsh or fish shell, so it is only
296
+ available in interactive shells; in scripts and CI, call
297
+ `node src/cli.js <command>` instead.
298
+
299
+ To generate Markdown without exporting `OPENAI_API_KEY` in your shell, copy
300
+ `.secret.sh.example` to `.secret.sh` and put your API key in it; mise sources
301
+ that file automatically:
302
+
303
+ ```sh
304
+ $ cp .secret.sh.example .secret.sh
305
+ $ $EDITOR .secret.sh
306
+ ```
307
+
308
+ ## Publish
309
+
310
+ I publish the package to npmjs from this repository with mise:
311
+
312
+ ```sh
313
+ $ npm login
314
+ $ npm version patch
315
+ $ mise run publish-to-npmjs
316
+ ```
317
+
318
+ `npm login` authenticates against npmjs, once per machine. `npm version patch`
319
+ bumps the version and creates the matching git tag (use `minor` or `major` for
320
+ larger changes). `mise run publish-to-npmjs` first checks the authentication with
321
+ `npm whoami`, then runs `npm publish`; it publishes the version written in
322
+ `package.json`, so bump it first. The package is scoped, so `publishConfig` in
323
+ `package.json` forces public access.
324
+
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@stephane-klein/youtube-to-markdown",
3
+ "version": "0.1.0",
4
+ "description": "Collect YouTube channel videos into a YAML config with yt-dlp, download transcripts and turn them into Markdown prose with an LLM.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Stéphane Klein <contact@stephane-klein.info>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/stephane-klein/youtube-feed-to-markdown.git"
11
+ },
12
+ "engines": {
13
+ "node": ">=22"
14
+ },
15
+ "bin": {
16
+ "youtube-to-markdown": "src/cli.js"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "youtube-to-markdown": "node src/cli.js"
26
+ },
27
+ "dependencies": {
28
+ "@ai-sdk/openai-compatible": "~3.0.51",
29
+ "ai": "~7.0.105",
30
+ "listr2": "~11.1.0",
31
+ "yaml": "~2.9.1",
32
+ "yargs": "~18.1.0"
33
+ }
34
+ }
package/src/cli.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from "node:module";
4
+ import { dirname, resolve } from "node:path";
5
+ import yargs from "yargs";
6
+ import { hideBin } from "yargs/helpers";
7
+ import { loadConfig, loadEnv, settingsFromConfig } from "./config.js";
8
+ import { runExtract } from "./extract-video-metadata.js";
9
+ import { runDownloadVtt } from "./download-vtt.js";
10
+ import { runGenerateMarkdown } from "./generate-markdown.js";
11
+ import { runMarkdownStats } from "./markdown-stats.js";
12
+ import { runReorganize } from "./organize-files.js";
13
+
14
+ const { version } = createRequire(import.meta.url)("../package.json");
15
+
16
+ const SETTING_KEYS = [
17
+ "model",
18
+ "endpoint",
19
+ "apiKey",
20
+ "session",
21
+ "concurrency",
22
+ "ytdlpConcurrency",
23
+ "retryDelays",
24
+ "maxOutputTokens",
25
+ ];
26
+
27
+ const handle = (run) => async (argv) => {
28
+ try {
29
+ await run(argv);
30
+ } catch (error) {
31
+ console.error(`error: ${error.message}`);
32
+ process.exitCode = 1;
33
+ }
34
+ };
35
+
36
+ const config = {
37
+ describe: "Path to the YAML configuration file",
38
+ type: "string",
39
+ default: "youtube_to_markdown.yaml",
40
+ };
41
+
42
+ const project = (argv) => {
43
+ const yaml = loadConfig(argv.config);
44
+ const settings = { ...settingsFromConfig(yaml) };
45
+ for (const key of SETTING_KEYS) {
46
+ if (argv[key] !== undefined) settings[key] = argv[key];
47
+ }
48
+ const dir = resolve(dirname(argv.config), yaml.contents_path ?? "contents");
49
+ return { path: argv.config, config: yaml, feed: yaml.feed ?? [], settings, dir };
50
+ };
51
+
52
+ yargs(hideBin(process.argv))
53
+ .scriptName("youtube-to-markdown")
54
+ .config(loadEnv())
55
+ .option("config", config)
56
+ .command(
57
+ "extract-video-metadata",
58
+ "Fetch the channel videos from YouTube into the configuration file",
59
+ (yargs) =>
60
+ yargs
61
+ .option("force-dates", {
62
+ describe: "Fetch every upload date again, ignoring the known ones",
63
+ type: "boolean",
64
+ default: false,
65
+ }),
66
+ handle(async (argv) => {
67
+ const { path, config: yaml } = project(argv);
68
+ await runExtract({ path, config: yaml, forceDates: argv.forceDates });
69
+ }),
70
+ )
71
+ .command(
72
+ "download-vtt",
73
+ "Download the VTT transcripts of the marked videos",
74
+ () => {},
75
+ handle(async (argv) => {
76
+ const { feed, dir } = project(argv);
77
+ await runDownloadVtt({ feed, dir });
78
+ }),
79
+ )
80
+ .command(
81
+ "generate-markdown",
82
+ "Turn the VTT transcripts into Markdown prose with an LLM",
83
+ (yargs) =>
84
+ yargs
85
+ .option("model", {
86
+ describe: "LLM model id (default: model_id in the configuration)",
87
+ type: "string",
88
+ })
89
+ .option("endpoint", {
90
+ describe: "OpenAI-compatible chat completions endpoint",
91
+ type: "string",
92
+ })
93
+ .option("api-key", {
94
+ describe: "API key (defaults to OPENAI_API_KEY)",
95
+ type: "string",
96
+ })
97
+ .option("session", {
98
+ describe: "Value of the X-OpenCode-Session header",
99
+ type: "string",
100
+ })
101
+ .option("concurrency", {
102
+ describe: "Maximum number of concurrent LLM calls (default: 6)",
103
+ type: "number",
104
+ })
105
+ .option("ytdlp-concurrency", {
106
+ describe: "Maximum number of concurrent transcript downloads (default: 2)",
107
+ type: "number",
108
+ })
109
+ .option("retry-delays", {
110
+ describe: "Comma-separated retry delays in seconds (default: 10,30,60)",
111
+ type: "string",
112
+ })
113
+ .option("max-output-tokens", {
114
+ describe: "Override the computed output token budget",
115
+ type: "number",
116
+ })
117
+ .option("force", {
118
+ describe: "Regenerate every Markdown file",
119
+ type: "boolean",
120
+ default: false,
121
+ }),
122
+ handle(async (argv) => {
123
+ const { feed, settings, dir } = project(argv);
124
+ await runGenerateMarkdown({ feed, ...settings, dir, force: argv.force });
125
+ }),
126
+ )
127
+ .command(
128
+ "markdown-stats",
129
+ "Report global tokens, cost and time from the Markdown frontmatter",
130
+ () => {},
131
+ handle(async (argv) => {
132
+ const { dir } = project(argv);
133
+ await runMarkdownStats({ dir });
134
+ }),
135
+ )
136
+ .command(
137
+ "reorganize",
138
+ "Move the transcripts and Markdown into their folder_slug folders",
139
+ (yargs) =>
140
+ yargs.option("dry-run", {
141
+ describe: "Show the moves without doing them",
142
+ type: "boolean",
143
+ default: false,
144
+ }),
145
+ handle(async (argv) => {
146
+ const { feed, dir } = project(argv);
147
+ await runReorganize({ feed, dir, dryRun: argv.dryRun });
148
+ }),
149
+ )
150
+ .demandCommand(1, "Use one of the available commands")
151
+ .strictCommands()
152
+ .recommendCommands()
153
+ .version(version)
154
+ .help()
155
+ .parse();
package/src/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parse } from "yaml";
3
+
4
+ export function loadConfig(path) {
5
+ return parse(readFileSync(path, "utf8")) ?? {};
6
+ }
7
+
8
+ const YAML_KEYS = {
9
+ model: "model_id",
10
+ endpoint: "openaiapi_endpoint",
11
+ apiKey: "api_key",
12
+ session: "x_opencode_session",
13
+ concurrency: "concurrency",
14
+ ytdlpConcurrency: "ytdlp_concurrency",
15
+ retryDelays: "retry_delays",
16
+ maxOutputTokens: "max_output_tokens",
17
+ };
18
+
19
+ export function settingsFromConfig(config) {
20
+ return Object.fromEntries(
21
+ Object.entries(YAML_KEYS)
22
+ .map(([key, yamlKey]) => [key, config[yamlKey]])
23
+ .filter(([, value]) => value !== undefined),
24
+ );
25
+ }
26
+
27
+ const ENV = {
28
+ config: "YT_TO_MD_CONFIG",
29
+ model: "YT_TO_MD_MODEL_ID",
30
+ endpoint: "YT_TO_MD_OPENAIAPI_ENDPOINT",
31
+ apiKey: "YT_TO_MD_API_KEY",
32
+ session: "YT_TO_MD_X_OPENCODE_SESSION",
33
+ concurrency: "YT_TO_MD_CONCURRENCY",
34
+ ytdlpConcurrency: "YT_TO_MD_YTDLP_CONCURRENCY",
35
+ retryDelays: "YT_TO_MD_RETRY_DELAYS",
36
+ maxOutputTokens: "YT_TO_MD_MAX_OUTPUT_TOKENS",
37
+ };
38
+
39
+ export function loadEnv(env = process.env) {
40
+ return Object.fromEntries(
41
+ Object.entries(ENV)
42
+ .map(([key, name]) => [key, env[name]])
43
+ .filter(([, value]) => value !== undefined),
44
+ );
45
+ }
@@ -0,0 +1,59 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, relative } from "node:path";
3
+ import { DEFAULT_DIR, downloadVtt, exists, videoJobs, writeMarker } from "./vtt.js";
4
+
5
+ export async function runDownloadVtt({ feed = [], dir = DEFAULT_DIR } = {}) {
6
+ await mkdir(dir, { recursive: true });
7
+
8
+ const jobs = feed.flatMap((channel) =>
9
+ (channel.videos ?? []).flatMap((video) =>
10
+ videoJobs(video, dir, channel.folder_slug),
11
+ ),
12
+ );
13
+
14
+ console.error(`${jobs.length} transcript(s) to check`);
15
+
16
+ const width = String(jobs.length).length;
17
+ let downloaded = 0;
18
+ let present = 0;
19
+ let missing = 0;
20
+ let errors = 0;
21
+
22
+ for (const [index, job] of jobs.entries()) {
23
+ const prefix = `[${String(index + 1).padStart(width)}/${jobs.length}]`;
24
+ const name = relative(dir, job.vtt);
25
+
26
+ if (await exists(job.vtt)) {
27
+ present++;
28
+ console.error(`${prefix} ${"already downloaded".padEnd(18)} ${name}`);
29
+ continue;
30
+ }
31
+
32
+ if (await exists(job.marker)) {
33
+ missing++;
34
+ console.error(`${prefix} ${"missing".padEnd(18)} ${name}`);
35
+ continue;
36
+ }
37
+
38
+ await mkdir(dirname(job.vtt), { recursive: true });
39
+
40
+ const status = await downloadVtt(job.url, job.lang, job.vtt);
41
+ if (status === "downloaded") {
42
+ downloaded++;
43
+ console.error(`${prefix} ${"downloaded".padEnd(18)} ${name}`);
44
+ } else if (status === "missing") {
45
+ await writeMarker(job.marker, job.url);
46
+ missing++;
47
+ console.error(`${prefix} ${"missing".padEnd(18)} ${name}`);
48
+ } else {
49
+ errors++;
50
+ console.error(
51
+ `${prefix} ${"error".padEnd(18)} ${name} (will retry on next run)`,
52
+ );
53
+ }
54
+ }
55
+
56
+ console.error(
57
+ `summary: ${downloaded} downloaded, ${present} already downloaded, ${missing} missing, ${errors} error`,
58
+ );
59
+ }