@stephane-klein/youtube-to-markdown 0.1.0 → 0.3.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 CHANGED
@@ -1,8 +1,17 @@
1
1
  # youtube-feed-to-markdown
2
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`.
3
+ The `youtube-to-markdown` CLI generates Markdown files from YouTube video
4
+ transcripts.
5
+
6
+ I use it to search through the content of popular-science videos and shows of
7
+ all kinds. I also use it to feed that corpus into a RAG.
8
+
9
+ Technically, it is fairly simple: the project relies on
10
+ [yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch the metadata of a YouTube
11
+ channel or playlist and then to download its transcripts.
12
+
13
+ In the final step, I use an LLM through a standard OpenAI-compatible API to
14
+ reconstruct a complete text from the video transcripts.
6
15
 
7
16
  ## Requirements
8
17
 
@@ -28,11 +37,94 @@ Or run it without installing it, with `npx`:
28
37
  $ npx @stephane-klein/youtube-to-markdown extract-video-metadata
29
38
  ```
30
39
 
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`.
40
+ Either way, `yt-dlp` must be on your `PATH` (see Requirements).
41
+
42
+ Run `youtube-to-markdown --help` to list the available commands:
43
+
44
+ ```sh
45
+ $ youtube-to-markdown --help
46
+ youtube-to-markdown <command>
47
+
48
+ Commands:
49
+ extract-video-metadata Fetch the videos of each feed source (channel or
50
+ playlist) from YouTube into the configuration file
51
+ download-vtt Download the VTT transcripts of the marked videos
52
+ generate-markdown Turn the VTT transcripts into Markdown prose with an
53
+ LLM
54
+ markdown-stats Report global tokens, cost and time from the Markdown
55
+ frontmatter
56
+ reorganize Move the transcripts and Markdown into their
57
+ folder_slug folders
58
+ doctor Check the runtime dependencies and the LLM access
59
+ completion Generate a shell completion script for bash or zsh
60
+
61
+ Options:
62
+ --config Path to the YAML configuration file
63
+ [string] [default: "youtube_to_markdown.yaml"]
64
+ --version Show version number [boolean]
65
+ --help Show help [boolean]
66
+ ```
67
+
68
+ Install the shell completion for bash or zsh (`$SHELL` selects the script):
69
+
70
+ ```sh
71
+ $ youtube-to-markdown completion >> ~/.zshrc
72
+ ```
73
+
74
+ Use `~/.bashrc` on bash.
75
+
76
+ ## Getting started
77
+
78
+ **1. Create `youtube_to_markdown.yaml`** with the LLM settings and your sources.
79
+ The example below uses [OpenCode Go](https://opencode.ai/en/go), an OpenAI-compatible API:
80
+
81
+ ```yaml
82
+ x_opencode_session: "youtube-to-markdown/0.1"
83
+ model_id: "mimo-v2.5"
84
+ openaiapi_endpoint: "https://opencode.ai/zen/go/v1/chat/completions"
85
+ feed:
86
+ - title: Science4All
87
+ url: https://www.youtube.com/@le_science4all
88
+ videos: []
89
+ ```
90
+
91
+ Only the `url` is required: `extract-video-metadata` fills the titles, the dates
92
+ and the video lists. `model_id` and `openaiapi_endpoint` are not specific to
93
+ OpenCode Go — point them at any OpenAI-compatible provider. The other settings
94
+ are documented below.
95
+
96
+ **2. Store your API key** in `.secret.sh` and source it. Write it with your editor
97
+ so the key never lands in your shell history:
98
+
99
+ ```sh
100
+ $ $EDITOR .secret.sh
101
+ $ cat .secret.sh
102
+ export OPENAI_API_KEY="sk-…"
103
+ $ source .secret.sh
104
+ ```
105
+
106
+ **3. Check the setup** with `doctor` and fix every `error` (a `warn` is fine):
107
+
108
+ ```sh
109
+ $ youtube-to-markdown doctor
110
+ node v24.21.0 ok
111
+ yt-dlp 2026.08.19 ok
112
+ config youtube_to_markdown.yaml ok (2 channel(s), 636 video(s))
113
+ contents contents ok
114
+ model mimo-v2.5 ok
115
+ endpoint https://opencode.ai/zen/go/v1/chat/completions ok
116
+ api key OPENAI_API_KEY ok
117
+ session set info
118
+ pricing models.dev ok ($0.14 in / $0.28 out / $0.0028 cached per 1M)
119
+ llm 1.0s ok (248 in / 8 out)
120
+
121
+ summary: 9 ok, 0 warning, 0 error
122
+ ```
123
+
124
+ `doctor` exits with a non-zero status as soon as one line is an `error`, so it
125
+ can gate a CI job.
34
126
 
35
- ## Run
127
+ **4. Fetch the source videos** into the file:
36
128
 
37
129
  ```sh
38
130
  $ youtube-to-markdown extract-video-metadata
@@ -40,29 +132,29 @@ $ youtube-to-markdown extract-video-metadata
40
132
  → https://www.youtube.com/@MonsieurPhi
41
133
  ```
42
134
 
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.
135
+ The command is idempotent: a new run only adds the missing videos.
46
136
 
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`.
137
+ **5. Generate the Markdown** with `download-vtt`, then `generate-markdown`. The
138
+ other commands are `markdown-stats`, `reorganize` and `doctor`; run
139
+ `youtube-to-markdown <command> --help` for their options.
52
140
 
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.
141
+ ## Configure the settings and the sources
57
142
 
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
143
+ The commands read `youtube_to_markdown.yaml` from the current directory; override
144
+ it with `--config` or `YT_TO_MD_CONFIG`. It holds the settings and the sources.
145
+ Settings resolve in this order: command-line flags, then `YT_TO_MD_*` environment
146
+ variables, then the settings written in the YAML file, then the built-in
147
+ defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
148
+ settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
149
+ `concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
150
+ `retry_delays` and `max_output_tokens`). I only set the source URLs, and the
64
151
  `extract-video-metadata` command fills the rest:
65
152
 
153
+ `x_opencode_session` is the base of the `X-OpenCode-Session` header; it is
154
+ optional and specific to OpenCode. A stable per-video suffix is appended to it,
155
+ so each video is routed as its own conversation. `model_id` and
156
+ `openaiapi_endpoint` are not: point them at any OpenAI-compatible provider.
157
+
66
158
  ```yaml
67
159
  x_opencode_session: "youtube-to-markdown/0.1"
68
160
  model_id: "mimo-v2.5"
@@ -91,14 +183,36 @@ other settings, it can only be set in the YAML file — there is no flag or
91
183
  environment variable for it. `.gitignore` only ignores the default `contents/`,
92
184
  so add your own path there if you move it.
93
185
 
94
- `folder_slug` (optional, on a channel) stores that channel's transcripts and
186
+ `folder_slug` (optional, on a feed entry) stores that source's transcripts and
95
187
  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
188
+ It is a relative path, so it stays inside `contents_path`; a source without
97
189
  `folder_slug` keeps its files at the root of `contents_path`. The reserved name
98
190
  `_orphans` is rejected. Change it whenever you like, then run
99
191
  `youtube-to-markdown reorganize` to move the existing files.
100
192
 
101
- ## Transcripts
193
+ ## Playlists
194
+
195
+ A feed entry can point to a YouTube playlist instead of a channel:
196
+
197
+ ```yaml
198
+ feed:
199
+ - title: Contre-histoire de la philosophie, vol. 9
200
+ folder_slug: contre-histoire-philo-9
201
+ url: https://www.youtube.com/playlist?list=OLAK5uy_lEyjyS840R2OkHNVgOt1fGjPzeBTs1xKI
202
+ videos: []
203
+ ```
204
+
205
+ The commands detect a playlist from its URL (the `/playlist` path);
206
+ `extract-video-metadata` uses it as is instead of resolving the channel uploads
207
+ playlist. `title` is filled with the playlist title when it is empty, and the
208
+ videos keep the playlist order instead of being sorted by date. Each video gets
209
+ an `index` field (`1`, `2`, …) which becomes the filename prefix:
210
+ `contents/contre-histoire-philo-9/1-2019-01-04_quel-xixeme-siecle-i-un-temps-nouveau.fr.vtt`.
211
+ The `index` field is written by `extract-video-metadata`; do not edit it by
212
+ hand. A video removed from the playlist is kept as an extra, without `index`,
213
+ at the end of the list.
214
+
215
+ ## Download the transcripts of the videos
102
216
 
103
217
  I mark the videos I want as transcripts with a `download_vtt` field:
104
218
 
@@ -135,7 +249,7 @@ field.
135
249
  When a video has no subtitle at all, the command writes a `<…>.vtt.missing`
136
250
  marker and stops trying. I delete that marker to force a new attempt.
137
251
 
138
- ## Markdown
252
+ ## Generate Markdown text from the transcripts
139
253
 
140
254
  I mark the videos I want as Markdown with `generate_markdown`. The transcript is
141
255
  downloaded first if it is not there yet:
@@ -188,7 +302,7 @@ from the server is honored when longer than the schedule. Each retry is shown in
188
302
  the phase output; after the last attempt the file is reported as an error and is
189
303
  retried on the next run.
190
304
 
191
- ### Frontmatter
305
+ ### Generated frontmatter
192
306
 
193
307
  Each generated file starts with a YAML frontmatter describing the run:
194
308
 
@@ -205,16 +319,32 @@ llm:
205
319
  output_tokens: 3262
206
320
  estimated_cost_usd: 0.000927
207
321
  finish_reason: stop
322
+ pricing:
323
+ source: models.dev
324
+ peak: null
208
325
  ---
209
326
  ```
210
327
 
211
328
  `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
329
+ `generated_at` is the UTC time of the generation. `pricing.source` names the
330
+ catalog the rates came from and `pricing.peak` tells whether the peak rate
331
+ applied (`null` when the source has no peak/off-peak pricing). Existing files
332
+ are left untouched: run `youtube-to-markdown generate-markdown --force` to
333
+ regenerate every file, which is also the way to add the frontmatter to files
334
+ generated before it existed.
335
+
336
+ ### Pricing
337
+
338
+ The estimated cost uses rates fetched at run time from
339
+ [models.dev](https://models.dev) and
340
+ [LiteLLM](https://github.com/BerriAI/litellm). The provider is resolved from
341
+ `openaiapi_endpoint`; LiteLLM is preferred when it prices the model, because it
342
+ carries DeepSeek's peak/off-peak windows, otherwise models.dev is used. Both
343
+ catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
344
+ (`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
345
+ unavailable, `estimated_cost_usd` is `null` and the run continues.
346
+
347
+ ## Organize the files into folders
218
348
 
219
349
  A channel may set `folder_slug` to keep its files in their own subdirectory of
220
350
  `contents_path`. When you add, change or remove that setting, `reorganize` moves
@@ -234,7 +364,7 @@ moves it under `contents_path/_orphans/`, keeping its relative path. A file whos
234
364
  target already exists, or a filename shared by two channels with different
235
365
  `folder_slug`, is reported as a conflict and left untouched.
236
366
 
237
- ## Stats
367
+ ## Report the tokens, cost and time
238
368
 
239
369
  `markdown-stats` reads the frontmatter of every `*.md` under `contents_path`
240
370
  (recursively) and prints global totals — tokens, estimated cost, processing
@@ -280,21 +410,23 @@ folders
280
410
  Files without frontmatter (generated before the frontmatter existed) are counted
281
411
  but left out of the totals.
282
412
 
283
- ## Development
413
+ ## Set up the development environment
284
414
 
285
415
  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:
416
+ Node.js; `npm install` fetches the JavaScript dependencies:
288
417
 
289
418
  ```sh
290
419
  $ mise install
291
420
  $ npm install
292
421
  ```
293
422
 
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.
423
+ The `enter` hook defines a `youtube-to-markdown` shell function that calls
424
+ `node src/cli.js`, and loads the shell completion for bash and zsh; the `leave`
425
+ hook removes both. Everything is set when you enter the project from an
426
+ interactive bash or zsh shell, so it is only available in interactive shells; in
427
+ scripts and CI, call `node src/cli.js <command>` instead. A function, rather than
428
+ a shell alias, is what lets `youtube-to-markdown <TAB>` complete the subcommands:
429
+ zsh expands aliases before dispatching completion, which would bypass `compdef`.
298
430
 
299
431
  To generate Markdown without exporting `OPENAI_API_KEY` in your shell, copy
300
432
  `.secret.sh.example` to `.secret.sh` and put your API key in it; mise sources
@@ -305,7 +437,7 @@ $ cp .secret.sh.example .secret.sh
305
437
  $ $EDITOR .secret.sh
306
438
  ```
307
439
 
308
- ## Publish
440
+ ## Publish the package to npmjs
309
441
 
310
442
  I publish the package to npmjs from this repository with mise:
311
443
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stephane-klein/youtube-to-markdown",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { runDownloadVtt } from "./download-vtt.js";
10
10
  import { runGenerateMarkdown } from "./generate-markdown.js";
11
11
  import { runMarkdownStats } from "./markdown-stats.js";
12
12
  import { runReorganize } from "./organize-files.js";
13
+ import { runDoctor } from "./doctor.js";
13
14
 
14
15
  const { version } = createRequire(import.meta.url)("../package.json");
15
16
 
@@ -49,13 +50,14 @@ const project = (argv) => {
49
50
  return { path: argv.config, config: yaml, feed: yaml.feed ?? [], settings, dir };
50
51
  };
51
52
 
52
- yargs(hideBin(process.argv))
53
- .scriptName("youtube-to-markdown")
53
+ const cli = yargs(hideBin(process.argv))
54
+ .scriptName("")
55
+ .usage("youtube-to-markdown <command>")
54
56
  .config(loadEnv())
55
57
  .option("config", config)
56
58
  .command(
57
59
  "extract-video-metadata",
58
- "Fetch the channel videos from YouTube into the configuration file",
60
+ "Fetch the videos of each feed source (channel or playlist) from YouTube into the configuration file",
59
61
  (yargs) =>
60
62
  yargs
61
63
  .option("force-dates", {
@@ -147,9 +149,65 @@ yargs(hideBin(process.argv))
147
149
  await runReorganize({ feed, dir, dryRun: argv.dryRun });
148
150
  }),
149
151
  )
150
- .demandCommand(1, "Use one of the available commands")
152
+ .command(
153
+ "doctor",
154
+ "Check the runtime dependencies and the LLM access",
155
+ (yargs) =>
156
+ yargs
157
+ .option("model", {
158
+ describe: "LLM model id to test (default: model_id in the configuration)",
159
+ type: "string",
160
+ })
161
+ .option("endpoint", {
162
+ describe: "OpenAI-compatible chat completions endpoint to test",
163
+ type: "string",
164
+ })
165
+ .option("api-key", {
166
+ describe: "API key to test (defaults to OPENAI_API_KEY)",
167
+ type: "string",
168
+ })
169
+ .option("session", {
170
+ describe: "Value of the X-OpenCode-Session header",
171
+ type: "string",
172
+ })
173
+ .option("llm", {
174
+ describe: "Run a minimal LLM request to test the API access",
175
+ type: "boolean",
176
+ default: true,
177
+ })
178
+ .option("timeout", {
179
+ describe: "Timeout in seconds for the LLM request (default: 30)",
180
+ type: "number",
181
+ default: 30,
182
+ }),
183
+ handle(async (argv) => {
184
+ await runDoctor({
185
+ configPath: argv.config,
186
+ overrides: {
187
+ model: argv.model,
188
+ endpoint: argv.endpoint,
189
+ apiKey: argv.apiKey,
190
+ session: argv.session,
191
+ },
192
+ llm: argv.llm,
193
+ timeoutMs: argv.timeout * 1000,
194
+ });
195
+ }),
196
+ )
197
+ .command(
198
+ "completion",
199
+ "Generate a shell completion script for bash or zsh",
200
+ () => {},
201
+ () => cli.showCompletionScript("youtube-to-markdown", "completion"),
202
+ )
203
+ .demandCommand(
204
+ 1,
205
+ "Use one of the available commands, or run `youtube-to-markdown doctor` " +
206
+ "first.",
207
+ )
151
208
  .strictCommands()
152
209
  .recommendCommands()
153
210
  .version(version)
154
- .help()
155
- .parse();
211
+ .help();
212
+
213
+ cli.parse();
package/src/doctor.js ADDED
@@ -0,0 +1,238 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { generateText } from "ai";
4
+ import { loadConfig, settingsFromConfig } from "./config.js";
5
+ import { createModel, describeError } from "./generate-markdown.js";
6
+ import { loadPricing } from "./pricing.js";
7
+ import { exists } from "./vtt.js";
8
+ import { runYtDlp } from "./ytdlp.js";
9
+
10
+ const MIN_NODE_MAJOR = 22;
11
+
12
+ function resolveApiKey(overrides, config, env = process.env) {
13
+ if (overrides.apiKey !== undefined) {
14
+ const envKey = env.YT_TO_MD_API_KEY;
15
+ const source = envKey !== undefined && envKey === overrides.apiKey
16
+ ? "YT_TO_MD_API_KEY"
17
+ : "--api-key";
18
+ return { key: overrides.apiKey, source };
19
+ }
20
+ if (config?.api_key !== undefined) {
21
+ return { key: config.api_key, source: "api_key (config)" };
22
+ }
23
+ if (env.OPENAI_API_KEY) {
24
+ return { key: env.OPENAI_API_KEY, source: "OPENAI_API_KEY" };
25
+ }
26
+ return { key: undefined, source: null };
27
+ }
28
+
29
+ async function checkContents(dir) {
30
+ try {
31
+ await access(dir, constants.W_OK);
32
+ return { status: "ok", note: null };
33
+ } catch (error) {
34
+ if (error.code !== "ENOENT") {
35
+ return { status: "error", note: "not writable" };
36
+ }
37
+ }
38
+ let parent = dirname(dir);
39
+ while (true) {
40
+ try {
41
+ await access(parent, constants.W_OK);
42
+ return { status: "warn", note: "not created yet" };
43
+ } catch (error) {
44
+ if (error.code !== "ENOENT") {
45
+ return { status: "error", note: "not writable" };
46
+ }
47
+ const next = dirname(parent);
48
+ if (next === parent) return { status: "error", note: "not writable" };
49
+ parent = next;
50
+ }
51
+ }
52
+ }
53
+
54
+ function checkNode() {
55
+ const version = process.versions.node;
56
+ const major = Number(version.split(".")[0]);
57
+ return major >= MIN_NODE_MAJOR
58
+ ? { status: "ok", value: `v${version}`, note: null }
59
+ : { status: "error", value: `v${version}`, note: `requires >= ${MIN_NODE_MAJOR}` };
60
+ }
61
+
62
+ async function checkYtDlp() {
63
+ try {
64
+ const { stdout } = await runYtDlp(["--version"]);
65
+ return { status: "ok", value: stdout.trim() || "found", note: null };
66
+ } catch (error) {
67
+ return { status: "error", value: "-", note: error.message };
68
+ }
69
+ }
70
+
71
+ async function loadConfigSafe(configPath) {
72
+ if (!(await exists(configPath))) {
73
+ return {
74
+ config: null,
75
+ row: { status: "warn", value: configPath, note: "not found" },
76
+ };
77
+ }
78
+ try {
79
+ const config = loadConfig(configPath);
80
+ const channels = config.feed?.length ?? 0;
81
+ const videos = (config.feed ?? []).reduce(
82
+ (total, channel) => total + (channel.videos?.length ?? 0),
83
+ 0,
84
+ );
85
+ return {
86
+ config,
87
+ row: {
88
+ status: "ok",
89
+ value: configPath,
90
+ note: `${channels} source(s), ${videos} video(s)`,
91
+ },
92
+ };
93
+ } catch (error) {
94
+ return {
95
+ config: null,
96
+ row: { status: "error", value: configPath, note: error.message },
97
+ };
98
+ }
99
+ }
100
+
101
+ function checkEndpoint(endpoint) {
102
+ if (!endpoint) return { status: "error", value: "-", note: "not set" };
103
+ try {
104
+ new URL(endpoint);
105
+ return { status: "ok", value: endpoint, note: null };
106
+ } catch {
107
+ return { status: "error", value: endpoint, note: "not a valid URL" };
108
+ }
109
+ }
110
+
111
+ async function checkLlm({ llm, settings, key, timeoutMs }) {
112
+ if (!llm) {
113
+ return { status: "warn", value: "-", note: "skipped (--no-llm)" };
114
+ }
115
+ if (!settings.model) return { status: "error", value: "-", note: "model not set" };
116
+ if (!settings.endpoint) return { status: "error", value: "-", note: "endpoint not set" };
117
+ if (!key) return { status: "error", value: "-", note: "api key not set" };
118
+
119
+ try {
120
+ const model = createModel({
121
+ modelId: settings.model,
122
+ endpoint: settings.endpoint,
123
+ apiKey: key,
124
+ session: settings.session,
125
+ });
126
+ const started = performance.now();
127
+ const { usage } = await generateText({
128
+ model,
129
+ prompt: "ping",
130
+ maxOutputTokens: 8,
131
+ maxRetries: 0,
132
+ abortSignal: AbortSignal.timeout(timeoutMs),
133
+ });
134
+ const elapsed = (performance.now() - started) / 1000;
135
+ return {
136
+ status: "ok",
137
+ value: `${elapsed.toFixed(1)}s`,
138
+ note: `${usage?.inputTokens ?? 0} in / ${usage?.outputTokens ?? 0} out`,
139
+ };
140
+ } catch (error) {
141
+ return { status: "error", value: "-", note: describeError(error, 1) };
142
+ }
143
+ }
144
+
145
+ async function checkPricing({ settings }) {
146
+ if (!settings.endpoint) {
147
+ return { status: "warn", value: "-", note: "endpoint not set" };
148
+ }
149
+ if (!settings.model) {
150
+ return { status: "warn", value: "-", note: "model not set" };
151
+ }
152
+
153
+ const pricing = await loadPricing({ endpoint: settings.endpoint });
154
+ if (!pricing) return { status: "warn", value: "-", note: "no pricing source" };
155
+
156
+ const price = pricing.priceAt(settings.model);
157
+ if (!price) {
158
+ return { status: "warn", value: pricing.provider, note: "model not priced" };
159
+ }
160
+
161
+ const peak = price.peak === null ? "" : price.peak ? " (peak)" : " (off-peak)";
162
+ const usd = (value) => (typeof value === "number" ? `$${value}` : "-");
163
+ return {
164
+ status: "ok",
165
+ value: `${price.source}${peak}`,
166
+ note: `${usd(price.input)} in / ${usd(price.output)} out / ${usd(price.cachedInput)} cached per 1M`,
167
+ };
168
+ }
169
+
170
+ function render(rows) {
171
+ const labelWidth = Math.max(...rows.map((row) => row.label.length));
172
+ const valueWidth = Math.max(...rows.map((row) => row.value.length));
173
+ return rows.map((row) => {
174
+ const note = row.note ? ` (${row.note})` : "";
175
+ return `${row.label.padEnd(labelWidth)} ${row.value.padEnd(valueWidth)} ${row.status}${note}`;
176
+ });
177
+ }
178
+
179
+ export async function runDoctor({
180
+ configPath = "youtube_to_markdown.yaml",
181
+ overrides = {},
182
+ llm = true,
183
+ timeoutMs = 30000,
184
+ } = {}) {
185
+ const { config, row: configRow } = await loadConfigSafe(configPath);
186
+
187
+ const settings = { ...settingsFromConfig(config ?? {}) };
188
+ for (const [key, value] of Object.entries(overrides)) {
189
+ if (value !== undefined) settings[key] = value;
190
+ }
191
+
192
+ const { key, source } = resolveApiKey(overrides, config);
193
+ const dir = resolve(dirname(configPath), config?.contents_path ?? "contents");
194
+
195
+ const rows = [
196
+ { label: "node", ...checkNode() },
197
+ { label: "yt-dlp", ...(await checkYtDlp()) },
198
+ ];
199
+ rows.push({ label: "config", ...configRow });
200
+ rows.push({
201
+ label: "contents",
202
+ value: config?.contents_path ?? "contents",
203
+ ...(await checkContents(dir)),
204
+ });
205
+ rows.push({
206
+ label: "model",
207
+ value: settings.model ?? "-",
208
+ status: settings.model ? "ok" : "error",
209
+ note: settings.model ? null : "not set",
210
+ });
211
+ rows.push({ label: "endpoint", ...checkEndpoint(settings.endpoint) });
212
+ rows.push({
213
+ label: "api key",
214
+ value: source ?? "-",
215
+ status: key ? "ok" : "error",
216
+ note: key ? null : "not set",
217
+ });
218
+ if (settings.session) {
219
+ rows.push({ label: "session", value: "set", status: "info", note: null });
220
+ }
221
+ rows.push({ label: "pricing", ...(await checkPricing({ settings })) });
222
+ const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
223
+ rows.push({ label: "llm", ...llmRow });
224
+
225
+ const counts = { ok: 0, warn: 0, error: 0 };
226
+ for (const row of rows) {
227
+ if (row.status in counts) counts[row.status]++;
228
+ }
229
+
230
+ console.log(`youtube-to-markdown doctor`);
231
+ console.log(render(rows).join("\n"));
232
+ console.log();
233
+ console.log(
234
+ `summary: ${counts.ok} ok, ${counts.warn} warning, ${counts.error} error`,
235
+ );
236
+
237
+ if (counts.error > 0) process.exitCode = 1;
238
+ }
@@ -32,11 +32,19 @@ async function uploadsPlaylistUrl(channelUrl) {
32
32
  return `https://www.youtube.com/playlist?list=UU${channelId.slice(2)}`;
33
33
  }
34
34
 
35
+ const isPlaylistUrl = (url) => {
36
+ try {
37
+ return new URL(url).pathname === "/playlist";
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+
35
43
  async function listEntries(playlistUrl, lang) {
36
44
  const args = [
37
45
  "--flat-playlist",
38
46
  "-O",
39
- '{"titre": %(title)j, "url": %(webpage_url)j}',
47
+ '{"titre": %(title)j, "url": %(webpage_url)j, "playlist": %(playlist_title)j, "index": %(playlist_index)j}',
40
48
  "--extractor-args",
41
49
  `youtube:lang=${lang}`,
42
50
  ];
@@ -81,8 +89,9 @@ async function fetchExactDates(urls) {
81
89
  return dates;
82
90
  }
83
91
 
84
- async function fetchVideos(channelUrl, knownDates) {
85
- const playlistUrl = await uploadsPlaylistUrl(channelUrl);
92
+ async function fetchVideos(sourceUrl, knownDates) {
93
+ const playlist = isPlaylistUrl(sourceUrl);
94
+ const playlistUrl = playlist ? sourceUrl : await uploadsPlaylistUrl(sourceUrl);
86
95
  const en = await listEntries(playlistUrl, "en");
87
96
  const fr = await listEntries(playlistUrl, "fr");
88
97
  const frTitles = new Map(fr.map((entry) => [entry.url, entry.titre]));
@@ -92,11 +101,15 @@ async function fetchVideos(channelUrl, knownDates) {
92
101
  .map((entry) => {
93
102
  const enTitle = entry.titre.trim();
94
103
  const frTitle = (frTitles.get(entry.url) ?? enTitle).trim();
95
- const title =
96
- frTitle && enTitle && frTitle !== enTitle
97
- ? { fr: frTitle, en: enTitle }
98
- : { fr: frTitle || enTitle };
99
- return { title, url: entry.url };
104
+ const video = {
105
+ title:
106
+ frTitle && enTitle && frTitle !== enTitle
107
+ ? { fr: frTitle, en: enTitle }
108
+ : { fr: frTitle || enTitle },
109
+ url: entry.url,
110
+ };
111
+ if (playlist && Number.isInteger(entry.index)) video.index = entry.index;
112
+ return video;
100
113
  });
101
114
 
102
115
  const missing = videos
@@ -109,9 +122,20 @@ async function fetchVideos(channelUrl, knownDates) {
109
122
  video.date = knownDates.get(id) ?? fetched.get(id) ?? "";
110
123
  }
111
124
 
112
- return videos
113
- .map((video) => ({ title: video.title, date: video.date, url: video.url }))
114
- .reverse();
125
+ const ordered = playlist
126
+ ? [...videos].sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
127
+ : [...videos].reverse();
128
+
129
+ return {
130
+ title: playlist ? (en[0]?.playlist ?? "") : "",
131
+ playlist,
132
+ videos: ordered.map((video) => {
133
+ const fields = { title: video.title, date: video.date, url: video.url };
134
+ return video.index !== undefined
135
+ ? { index: video.index, ...fields }
136
+ : fields;
137
+ }),
138
+ };
115
139
  }
116
140
 
117
141
  function normalizeEntry(entry) {
@@ -122,7 +146,7 @@ function normalizeEntry(entry) {
122
146
  return entry;
123
147
  }
124
148
 
125
- const MANAGED_KEYS = new Set(["title", "date", "url", "titre"]);
149
+ const MANAGED_KEYS = new Set(["title", "date", "url", "titre", "index"]);
126
150
 
127
151
  function userFields(entry) {
128
152
  return Object.fromEntries(
@@ -130,7 +154,7 @@ function userFields(entry) {
130
154
  );
131
155
  }
132
156
 
133
- function mergeVideos(existing, fetched) {
157
+ function mergeVideos(existing, fetched, { sort = true } = {}) {
134
158
  const existingByUrl = new Map(
135
159
  existing.filter((v) => v.url).map((v) => [v.url, v]),
136
160
  );
@@ -143,10 +167,12 @@ function mergeVideos(existing, fetched) {
143
167
  const fetchedUrls = new Set(fetched.map((v) => v.url));
144
168
  const extras = existing
145
169
  .filter((v) => v.url && !fetchedUrls.has(v.url))
146
- .map(normalizeEntry);
147
- return [...merged, ...extras].sort((a, b) =>
148
- (a.date ?? "").localeCompare(b.date ?? ""),
149
- );
170
+ .map(normalizeEntry)
171
+ .map(({ index, ...video }) => video);
172
+ const all = [...merged, ...extras];
173
+ return sort
174
+ ? all.sort((a, b) => (a.date ?? "").localeCompare(b.date ?? ""))
175
+ : all;
150
176
  }
151
177
 
152
178
  export async function runExtract({
@@ -166,10 +192,11 @@ export async function runExtract({
166
192
  .map((video) => [videoId(video.url), video.date]),
167
193
  );
168
194
  console.error(`→ ${channel.url}`);
169
- channel.videos = mergeVideos(
170
- videos,
171
- await fetchVideos(channel.url, knownDates),
172
- );
195
+ const fetched = await fetchVideos(channel.url, knownDates);
196
+ if (fetched.title && !channel.title) channel.title = fetched.title;
197
+ channel.videos = mergeVideos(videos, fetched.videos, {
198
+ sort: !fetched.playlist,
199
+ });
173
200
  }
174
201
 
175
202
  await writeFile(path, stringify(config, { lineWidth: 0 }));
@@ -1,9 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { dirname, relative } from "node:path";
3
4
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
5
  import { APICallError, generateText } from "ai";
5
6
  import { Listr, ListrLogger, ProcessOutput } from "listr2";
6
7
  import { stringify } from "yaml";
8
+ import { loadPricing } from "./pricing.js";
7
9
  import {
8
10
  DEFAULT_DIR,
9
11
  downloadVtt,
@@ -26,17 +28,12 @@ const SYSTEM = [
26
28
 
27
29
  const WIDTH = 80;
28
30
 
29
- const PRICES = {
30
- "mimo-v2.5": { input: 0.14, output: 0.28, cachedInput: 0.0028 }
31
- };
32
-
33
31
  function cachedInputTokens(usage) {
34
32
  const details = usage?.inputTokenDetails ?? {};
35
33
  return details.cacheReadTokens ?? usage?.cachedInputTokens ?? 0;
36
34
  }
37
35
 
38
- function estimateCost(modelId, usage) {
39
- const price = PRICES[modelId];
36
+ function estimateCost(price, usage) {
40
37
  if (!price || !usage) return null;
41
38
 
42
39
  const details = usage.inputTokenDetails ?? {};
@@ -80,7 +77,7 @@ function metricsLine(modelId, usage, elapsed, cost) {
80
77
  .join(" ");
81
78
  }
82
79
 
83
- function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
80
+ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price }) {
84
81
  const data = {
85
82
  source_url: job.url,
86
83
  video_title: titleForLang(job.titles, job.lang),
@@ -93,6 +90,7 @@ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
93
90
  output_tokens: usage?.outputTokens ?? null,
94
91
  estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
95
92
  finish_reason: finishReason,
93
+ pricing: price ? { source: price.source, peak: price.peak } : null,
96
94
  },
97
95
  };
98
96
 
@@ -183,7 +181,7 @@ function retryDelayMs(error, fallbackMs) {
183
181
  return Math.max(headerMs, fallbackMs);
184
182
  }
185
183
 
186
- function describeError(error, attempts) {
184
+ export function describeError(error, attempts) {
187
185
  const suffix = ` after ${attempts} attempt(s)`;
188
186
  if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
189
187
 
@@ -201,8 +199,51 @@ function parseRetryDelays(value) {
201
199
  .filter((item) => Number.isFinite(item) && item >= 0);
202
200
  }
203
201
 
202
+ function convertUsage(usage) {
203
+ const prompt = usage?.prompt_tokens ?? 0;
204
+ const completion = usage?.completion_tokens ?? 0;
205
+ const cacheRead =
206
+ usage?.prompt_cache_hit_tokens ??
207
+ usage?.prompt_tokens_details?.cached_tokens ??
208
+ 0;
209
+ const reasoning = usage?.completion_tokens_details?.reasoning_tokens ?? 0;
210
+ return {
211
+ inputTokens: {
212
+ total: prompt,
213
+ noCache: Math.max(prompt - cacheRead, 0),
214
+ cacheRead,
215
+ cacheWrite: undefined,
216
+ },
217
+ outputTokens: {
218
+ total: completion,
219
+ text: Math.max(completion - reasoning, 0),
220
+ reasoning,
221
+ },
222
+ raw: usage,
223
+ };
224
+ }
225
+
226
+ export function createModel({ modelId, endpoint, apiKey, session }) {
227
+ const provider = createOpenAICompatible({
228
+ name: "opencode",
229
+ apiKey,
230
+ baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
231
+ headers: session ? { "X-OpenCode-Session": session } : {},
232
+ convertUsage,
233
+ });
234
+ return provider(modelId);
235
+ }
236
+
237
+ function videoSession(base, job) {
238
+ const id = createHash("sha256")
239
+ .update(`${job.url}#${job.lang}`)
240
+ .digest("hex")
241
+ .slice(0, 16);
242
+ return `${base || "youtube-to-markdown"}-${id}`;
243
+ }
244
+
204
245
  async function toMarkdown(
205
- { model, retryDelays, maxOutputTokens },
246
+ { model, retryDelays, maxOutputTokens, session },
206
247
  title,
207
248
  transcript,
208
249
  onRetry,
@@ -217,8 +258,10 @@ async function toMarkdown(
217
258
  system: SYSTEM,
218
259
  prompt: `Title: ${title}\n\nTranscript:\n${transcript}`,
219
260
  temperature: 0.2,
261
+ reasoning: "low",
220
262
  maxOutputTokens: outputBudget(transcript, maxOutputTokens),
221
263
  maxRetries: 0,
264
+ headers: { "X-OpenCode-Session": session },
222
265
  });
223
266
  const elapsed = (performance.now() - started) / 1000;
224
267
 
@@ -257,7 +300,7 @@ export async function runGenerateMarkdown({
257
300
  model: modelId,
258
301
  endpoint,
259
302
  apiKey,
260
- session,
303
+ sessionBase,
261
304
  concurrency = 6,
262
305
  ytdlpConcurrency = 2,
263
306
  retryDelays = "10,30,60",
@@ -274,14 +317,7 @@ export async function runGenerateMarkdown({
274
317
  const llmConcurrency = Number(concurrency);
275
318
  const vttConcurrency = Number(ytdlpConcurrency);
276
319
 
277
- const provider = createOpenAICompatible({
278
- name: "opencode",
279
- apiKey: key,
280
- baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
281
- headers: session ? { "X-OpenCode-Session": session } : {},
282
- });
283
-
284
- const model = provider(modelId);
320
+ const model = createModel({ modelId, endpoint, apiKey: key, session: sessionBase });
285
321
 
286
322
  await mkdir(dir, { recursive: true });
287
323
 
@@ -332,6 +368,8 @@ export async function runGenerateMarkdown({
332
368
  else pending.push(job);
333
369
  }
334
370
 
371
+ const prices = pending.length > 0 ? await loadPricing({ endpoint }) : null;
372
+
335
373
  const downloadTask = {
336
374
  title: "Download transcripts",
337
375
  rendererOptions: phaseOptions,
@@ -407,8 +445,14 @@ export async function runGenerateMarkdown({
407
445
  const name = relative(dir, md);
408
446
  try {
409
447
  const transcript = vttToText(await readFile(job.vtt, "utf8"));
448
+ const price = prices ? prices.priceAt(modelId) : null;
410
449
  const { body, usage, finishReason, elapsed } = await toMarkdown(
411
- { model, retryDelays: RETRY_DELAYS, maxOutputTokens },
450
+ {
451
+ model,
452
+ retryDelays: RETRY_DELAYS,
453
+ maxOutputTokens,
454
+ session: videoSession(sessionBase, job),
455
+ },
412
456
  job.title,
413
457
  transcript,
414
458
  (nextAttempt, totalAttempts, waitMs) => {
@@ -420,7 +464,7 @@ export async function runGenerateMarkdown({
420
464
  stats.totalSeconds += elapsed;
421
465
  stats.totalInput += usage?.inputTokens ?? 0;
422
466
  stats.totalOutput += usage?.outputTokens ?? 0;
423
- const cost = estimateCost(modelId, usage);
467
+ const cost = estimateCost(price, usage);
424
468
  if (cost === null) stats.costKnown = false;
425
469
  else stats.totalCost += cost;
426
470
  const metrics = metricsLine(modelId, usage, elapsed, cost);
@@ -437,6 +481,7 @@ export async function runGenerateMarkdown({
437
481
  elapsed,
438
482
  cost,
439
483
  finishReason,
484
+ price,
440
485
  });
441
486
  await mkdir(dirname(md), { recursive: true });
442
487
  await writeFile(
@@ -43,7 +43,9 @@ function foldersByPrefix(feed) {
43
43
  for (const video of channel.videos ?? []) {
44
44
  const slug = slugify(primaryTitle(video));
45
45
  if (!video.date || !slug) continue;
46
- const prefix = `${video.date}_${slug}`;
46
+ const prefix = Number.isInteger(video.index)
47
+ ? `${video.index}-${video.date}_${slug}`
48
+ : `${video.date}_${slug}`;
47
49
  const folders = targets.get(prefix) ?? new Set();
48
50
  folders.add(folder);
49
51
  targets.set(prefix, folders);
package/src/pricing.js ADDED
@@ -0,0 +1,177 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const MODELS_DEV_URL = "https://models.dev/api.json";
6
+ const LITELLM_URL =
7
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
8
+
9
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
10
+ const FETCH_TIMEOUT_MS = 8000;
11
+
12
+ const CACHE_DIR = join(
13
+ process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"),
14
+ "youtube-to-markdown",
15
+ );
16
+
17
+ const normalizeBase = (url) => url.replace(/\/+$/, "");
18
+
19
+ async function fetchText(url) {
20
+ const response = await fetch(url, {
21
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
22
+ });
23
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${url}`);
24
+ return response.text();
25
+ }
26
+
27
+ async function loadCachedText(url, name) {
28
+ const file = join(CACHE_DIR, name);
29
+ try {
30
+ const info = await stat(file);
31
+ if (Date.now() - info.mtimeMs < CACHE_TTL_MS) {
32
+ return await readFile(file, "utf8");
33
+ }
34
+ } catch {
35
+ // no fresh cache
36
+ }
37
+
38
+ try {
39
+ const text = await fetchText(url);
40
+ await mkdir(dirname(file), { recursive: true });
41
+ await writeFile(file, text);
42
+ return text;
43
+ } catch (error) {
44
+ return await readFile(file, "utf8").catch(() => {
45
+ throw error;
46
+ });
47
+ }
48
+ }
49
+
50
+ async function loadJson(url, name) {
51
+ return JSON.parse(await loadCachedText(url, name));
52
+ }
53
+
54
+ function findProvider(catalog, base) {
55
+ const candidates = base.endsWith("/v1")
56
+ ? [base, base.slice(0, -"/v1".length)]
57
+ : [base];
58
+ for (const candidate of candidates) {
59
+ for (const provider of Object.values(catalog)) {
60
+ if (provider?.api && normalizeBase(provider.api) === candidate) {
61
+ return provider;
62
+ }
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function indexLitellm(catalog) {
69
+ const providers = new Map();
70
+ for (const [key, entry] of Object.entries(catalog)) {
71
+ const provider = entry?.litellm_provider;
72
+ if (!provider) continue;
73
+ let models = providers.get(provider);
74
+ if (!models) {
75
+ models = new Map();
76
+ providers.set(provider, models);
77
+ }
78
+ const name = key.includes("/") ? key.slice(key.lastIndexOf("/") + 1) : key;
79
+ if (!models.has(name)) models.set(name, entry);
80
+ if (!models.has(key)) models.set(key, entry);
81
+ }
82
+ return providers;
83
+ }
84
+
85
+ const toMinutes = (value) => {
86
+ const [hours, minutes] = value.split(":").map(Number);
87
+ return hours * 60 + minutes;
88
+ };
89
+
90
+ const isWithinRange = (minutes, start, end) =>
91
+ start === end
92
+ ? true
93
+ : start < end
94
+ ? minutes >= start && minutes < end
95
+ : minutes >= start || minutes < end;
96
+
97
+ function isOffPeak(windows, at) {
98
+ const day = at.getUTCDay();
99
+ const weekday = day === 0 ? 7 : day;
100
+ const minutes = at.getUTCHours() * 60 + at.getUTCMinutes();
101
+ for (const window of windows ?? []) {
102
+ if (!window?.weekdays?.includes(weekday)) continue;
103
+ const ranges = Array.isArray(window.hours_utc)
104
+ ? window.hours_utc
105
+ : [window.hours_utc];
106
+ for (const range of ranges) {
107
+ if (typeof range !== "string" || !range.includes("-")) continue;
108
+ const [start, end] = range.split("-").map(toMinutes);
109
+ if (isWithinRange(minutes, start, end)) return true;
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ const perMillion = (perToken) =>
116
+ typeof perToken === "number" ? perToken * 1e6 : undefined;
117
+
118
+ function litellmPrice(entry, at) {
119
+ const offPeak =
120
+ entry.off_peak_pricing && isOffPeak(entry.off_peak_pricing.windows, at);
121
+ const rates = offPeak ? entry.off_peak_pricing : entry;
122
+ return {
123
+ input: perMillion(rates.input_cost_per_token),
124
+ output: perMillion(rates.output_cost_per_token),
125
+ cachedInput: perMillion(
126
+ rates.cache_read_input_token_cost ?? rates.input_cost_per_token_cache_hit,
127
+ ),
128
+ peak: entry.off_peak_pricing ? !offPeak : null,
129
+ source: "litellm",
130
+ };
131
+ }
132
+
133
+ function modelsDevPrice(model) {
134
+ const cost = model?.cost;
135
+ if (!cost) return null;
136
+ return {
137
+ input: cost.input,
138
+ output: cost.output,
139
+ cachedInput: cost.cache_read ?? 0,
140
+ peak: null,
141
+ source: "models.dev",
142
+ };
143
+ }
144
+
145
+ export async function loadPricing({ endpoint } = {}) {
146
+ if (!endpoint) return null;
147
+ const base = normalizeBase(endpoint.replace(/\/chat\/completions\/?$/, ""));
148
+
149
+ let catalog;
150
+ try {
151
+ catalog = await loadJson(MODELS_DEV_URL, "models-dev.json");
152
+ } catch (error) {
153
+ console.error(`warning: pricing catalog unavailable (${error.message})`);
154
+ return null;
155
+ }
156
+
157
+ const provider = findProvider(catalog, base);
158
+ if (!provider) return null;
159
+
160
+ let litellm = null;
161
+ try {
162
+ litellm = indexLitellm(await loadJson(LITELLM_URL, "litellm.json")).get(
163
+ provider.id,
164
+ ) ?? null;
165
+ } catch {
166
+ litellm = null;
167
+ }
168
+
169
+ return {
170
+ provider: provider.id,
171
+ priceAt(modelId, at = new Date()) {
172
+ const entry = litellm?.get(modelId);
173
+ if (entry) return litellmPrice(entry, at);
174
+ return modelsDevPrice(provider.models?.[modelId]);
175
+ },
176
+ };
177
+ }
package/src/vtt.js CHANGED
@@ -121,8 +121,10 @@ export function videoJobs(video, dir = DEFAULT_DIR, folderSlug = null) {
121
121
  const slug = slugify(primaryTitle(video));
122
122
  if (!slug) return [];
123
123
 
124
+ const prefix = Number.isInteger(video.index) ? `${video.index}-` : "";
125
+
124
126
  return langs.map((lang) => {
125
- const name = `${video.date}_${slug}.${lang}`;
127
+ const name = `${prefix}${video.date}_${slug}.${lang}`;
126
128
  const base = folder ? join(dir, folder, name) : join(dir, name);
127
129
  return {
128
130
  url: video.url,