@stephane-klein/youtube-to-markdown 0.2.0 → 0.4.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
@@ -7,8 +7,8 @@ I use it to search through the content of popular-science videos and shows of
7
7
  all kinds. I also use it to feed that corpus into a RAG.
8
8
 
9
9
  Technically, it is fairly simple: the project relies on
10
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch a YouTube channel's metadata
11
- and then to download its transcripts.
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
12
 
13
13
  In the final step, I use an LLM through a standard OpenAI-compatible API to
14
14
  reconstruct a complete text from the video transcripts.
@@ -46,8 +46,8 @@ $ youtube-to-markdown --help
46
46
  youtube-to-markdown <command>
47
47
 
48
48
  Commands:
49
- extract-video-metadata Fetch the channel videos from YouTube into the
50
- configuration file
49
+ extract-video-metadata Fetch the videos of each feed source (channel or
50
+ playlist) from YouTube into the configuration file
51
51
  download-vtt Download the VTT transcripts of the marked videos
52
52
  generate-markdown Turn the VTT transcripts into Markdown prose with an
53
53
  LLM
@@ -75,7 +75,7 @@ Use `~/.bashrc` on bash.
75
75
 
76
76
  ## Getting started
77
77
 
78
- **1. Create `youtube_to_markdown.yaml`** with the LLM settings and your channels.
78
+ **1. Create `youtube_to_markdown.yaml`** with the LLM settings and your sources.
79
79
  The example below uses [OpenCode Go](https://opencode.ai/en/go), an OpenAI-compatible API:
80
80
 
81
81
  ```yaml
@@ -115,15 +115,16 @@ model mimo-v2.5 ok
115
115
  endpoint https://opencode.ai/zen/go/v1/chat/completions ok
116
116
  api key OPENAI_API_KEY ok
117
117
  session set info
118
+ pricing models.dev ok ($0.14 in / $0.28 out / $0.0028 cached per 1M)
118
119
  llm 1.0s ok (248 in / 8 out)
119
120
 
120
- summary: 8 ok, 0 warning, 0 error
121
+ summary: 9 ok, 0 warning, 0 error
121
122
  ```
122
123
 
123
124
  `doctor` exits with a non-zero status as soon as one line is an `error`, so it
124
125
  can gate a CI job.
125
126
 
126
- **4. Fetch the channel videos** into the file:
127
+ **4. Fetch the source videos** into the file:
127
128
 
128
129
  ```sh
129
130
  $ youtube-to-markdown extract-video-metadata
@@ -137,21 +138,22 @@ The command is idempotent: a new run only adds the missing videos.
137
138
  other commands are `markdown-stats`, `reorganize` and `doctor`; run
138
139
  `youtube-to-markdown <command> --help` for their options.
139
140
 
140
- ## Configure the settings and the channels
141
+ ## Configure the settings and the sources
141
142
 
142
143
  The commands read `youtube_to_markdown.yaml` from the current directory; override
143
- it with `--config` or `YT_TO_MD_CONFIG`. It holds the settings and the channels.
144
+ it with `--config` or `YT_TO_MD_CONFIG`. It holds the settings and the sources.
144
145
  Settings resolve in this order: command-line flags, then `YT_TO_MD_*` environment
145
146
  variables, then the settings written in the YAML file, then the built-in
146
147
  defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
147
148
  settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
148
149
  `concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
149
- `retry_delays` and `max_output_tokens`). I only set the channel URLs, and the
150
- `extract-video-metadata` command fills the rest:
150
+ `retry_delays`, `max_output_tokens` and `chunk_target_tokens`). I only set the
151
+ source URLs, and the `extract-video-metadata` command fills the rest:
151
152
 
152
- `x_opencode_session` is sent as the `X-OpenCode-Session` header; it is optional
153
- and specific to OpenCode. `model_id` and `openaiapi_endpoint` are not: point them
154
- at any OpenAI-compatible provider.
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.
155
157
 
156
158
  ```yaml
157
159
  x_opencode_session: "youtube-to-markdown/0.1"
@@ -181,13 +183,35 @@ other settings, it can only be set in the YAML file — there is no flag or
181
183
  environment variable for it. `.gitignore` only ignores the default `contents/`,
182
184
  so add your own path there if you move it.
183
185
 
184
- `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
185
187
  Markdown in a subdirectory of `contents_path`, for example `science-4-all`.
186
- 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
187
189
  `folder_slug` keeps its files at the root of `contents_path`. The reserved name
188
190
  `_orphans` is rejected. Change it whenever you like, then run
189
191
  `youtube-to-markdown reorganize` to move the existing files.
190
192
 
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
+
191
215
  ## Download the transcripts of the videos
192
216
 
193
217
  I mark the videos I want as transcripts with a `download_vtt` field:
@@ -278,6 +302,25 @@ from the server is honored when longer than the schedule. Each retry is shown in
278
302
  the phase output; after the last attempt the file is reported as an error and is
279
303
  retried on the next run.
280
304
 
305
+ ### Output budget and long transcripts
306
+
307
+ The model must rewrite the whole transcript, so the command asks it for roughly
308
+ twice the input size (`maxOutputTokens`). That budget is capped per call: the
309
+ command reads the model's advertised output limit from the pricing catalogs and
310
+ uses it as the ceiling; if the catalog has no entry it falls back to 32768
311
+ tokens. `--max-output-tokens` (or `YT_TO_MD_MAX_OUTPUT_TOKENS`) overrides the
312
+ ceiling by hand.
313
+
314
+ When the projected output would exceed the ceiling — a long video on a model
315
+ with a small output limit — the transcript is split into chunks that each fit
316
+ the budget, generated independently, then stitched together: immediately
317
+ repeated headings are dropped and each junction is smoothed by a small extra
318
+ LLM call that rewrites the beginning of the next section so it flows from the
319
+ previous one. `--chunk-target-tokens` (or `YT_TO_MD_CHUNK_TARGET_TOKENS`,
320
+ default 8000) sets the target input size of each chunk. The generation of a
321
+ chunk is guaranteed to stop with `stop`; otherwise the file is reported as an
322
+ error and nothing is written. The frontmatter records how many chunks were used.
323
+
281
324
  ### Generated frontmatter
282
325
 
283
326
  Each generated file starts with a YAML frontmatter describing the run:
@@ -289,20 +332,37 @@ video_title: Pourquoi π est-il si fou ? Relativité 1
289
332
  generated_at: 2026-09-19T10:12:33.456Z
290
333
  llm:
291
334
  model: mimo-v2.5
335
+ chunks: 1
292
336
  duration_seconds: 87.1
293
337
  input_tokens: 4428
294
338
  cached_input_tokens: 0
295
339
  output_tokens: 3262
296
340
  estimated_cost_usd: 0.000927
297
341
  finish_reason: stop
342
+ pricing:
343
+ source: models.dev
344
+ peak: null
298
345
  ---
299
346
  ```
300
347
 
301
348
  `video_title` is the original video title in the file's language and
302
- `generated_at` is the UTC time of the generation. `estimated_cost_usd` is `null`
303
- when the model is not in the price table. Existing files are left untouched: run
304
- `youtube-to-markdown generate-markdown --force` to regenerate every file, which
305
- is also the way to add the frontmatter to files generated before it existed.
349
+ `generated_at` is the UTC time of the generation. `pricing.source` names the
350
+ catalog the rates came from and `pricing.peak` tells whether the peak rate
351
+ applied (`null` when the source has no peak/off-peak pricing). Existing files
352
+ are left untouched: run `youtube-to-markdown generate-markdown --force` to
353
+ regenerate every file, which is also the way to add the frontmatter to files
354
+ generated before it existed.
355
+
356
+ ### Pricing
357
+
358
+ The estimated cost uses rates fetched at run time from
359
+ [models.dev](https://models.dev) and
360
+ [LiteLLM](https://github.com/BerriAI/litellm). The provider is resolved from
361
+ `openaiapi_endpoint`; LiteLLM is preferred when it prices the model, because it
362
+ carries DeepSeek's peak/off-peak windows, otherwise models.dev is used. Both
363
+ catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
364
+ (`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
365
+ unavailable, `estimated_cost_usd` is `null` and the run continues.
306
366
 
307
367
  ## Organize the files into folders
308
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stephane-klein/youtube-to-markdown",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
@@ -22,7 +22,8 @@
22
22
  "access": "public"
23
23
  },
24
24
  "scripts": {
25
- "youtube-to-markdown": "node src/cli.js"
25
+ "youtube-to-markdown": "node src/cli.js",
26
+ "test": "node --test"
26
27
  },
27
28
  "dependencies": {
28
29
  "@ai-sdk/openai-compatible": "~3.0.51",
package/src/chunk.js ADDED
@@ -0,0 +1,68 @@
1
+ export const CHARS_PER_TOKEN = 3.5;
2
+
3
+ export function estimateTokens(text) {
4
+ return Math.ceil(`${text ?? ""}`.length / CHARS_PER_TOKEN);
5
+ }
6
+
7
+ export function chunkTranscript(transcript, { targetTokens }) {
8
+ const target = Math.max(1, Math.floor(Number(targetTokens) || 1));
9
+ const lines = `${transcript ?? ""}`.split("\n");
10
+ const chunks = [];
11
+ let current = [];
12
+ let currentTokens = 0;
13
+
14
+ for (const line of lines) {
15
+ const lineTokens = estimateTokens(line) + 1;
16
+ if (current.length > 0 && currentTokens + lineTokens > target) {
17
+ chunks.push(current.join("\n"));
18
+ current = [];
19
+ currentTokens = 0;
20
+ }
21
+ current.push(line);
22
+ currentTokens += lineTokens;
23
+ }
24
+
25
+ if (current.length > 0) chunks.push(current.join("\n"));
26
+ return chunks.length > 0 ? chunks : [""];
27
+ }
28
+
29
+ export function splitBlocks(markdown) {
30
+ return `${markdown ?? ""}`.split(/\n{2,}/);
31
+ }
32
+
33
+ export function firstBlock(markdown) {
34
+ return splitBlocks(markdown)[0]?.trim() ?? "";
35
+ }
36
+
37
+ export function lastBlock(markdown) {
38
+ const blocks = splitBlocks(markdown).filter((block) => block.trim());
39
+ return blocks[blocks.length - 1]?.trim() ?? "";
40
+ }
41
+
42
+ export function replaceFirstBlock(markdown, replacement) {
43
+ const blocks = splitBlocks(markdown);
44
+ if (blocks.length === 0) return replacement;
45
+ blocks[0] = replacement;
46
+ return blocks.join("\n\n");
47
+ }
48
+
49
+ export function dedupeAdjacentHeadings(markdown) {
50
+ const lines = `${markdown ?? ""}`.split("\n");
51
+ const out = [];
52
+ let lastHeading = null;
53
+
54
+ for (const line of lines) {
55
+ const heading = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
56
+ if (heading) {
57
+ const key = heading[1].trim().toLowerCase();
58
+ if (key === lastHeading) continue;
59
+ lastHeading = key;
60
+ out.push(line);
61
+ continue;
62
+ }
63
+ if (line.trim() !== "") lastHeading = null;
64
+ out.push(line);
65
+ }
66
+
67
+ return out.join("\n").replace(/\n{3,}/g, "\n\n");
68
+ }
package/src/cli.js CHANGED
@@ -23,6 +23,7 @@ const SETTING_KEYS = [
23
23
  "ytdlpConcurrency",
24
24
  "retryDelays",
25
25
  "maxOutputTokens",
26
+ "chunkTargetTokens",
26
27
  ];
27
28
 
28
29
  const handle = (run) => async (argv) => {
@@ -57,7 +58,7 @@ const cli = yargs(hideBin(process.argv))
57
58
  .option("config", config)
58
59
  .command(
59
60
  "extract-video-metadata",
60
- "Fetch the channel videos from YouTube into the configuration file",
61
+ "Fetch the videos of each feed source (channel or playlist) from YouTube into the configuration file",
61
62
  (yargs) =>
62
63
  yargs
63
64
  .option("force-dates", {
@@ -116,6 +117,10 @@ const cli = yargs(hideBin(process.argv))
116
117
  describe: "Override the computed output token budget",
117
118
  type: "number",
118
119
  })
120
+ .option("chunk-target-tokens", {
121
+ describe: "Target input tokens per chunk when a transcript is split (default: 8000)",
122
+ type: "number",
123
+ })
119
124
  .option("force", {
120
125
  describe: "Regenerate every Markdown file",
121
126
  type: "boolean",
package/src/config.js CHANGED
@@ -14,6 +14,7 @@ const YAML_KEYS = {
14
14
  ytdlpConcurrency: "ytdlp_concurrency",
15
15
  retryDelays: "retry_delays",
16
16
  maxOutputTokens: "max_output_tokens",
17
+ chunkTargetTokens: "chunk_target_tokens",
17
18
  };
18
19
 
19
20
  export function settingsFromConfig(config) {
@@ -34,6 +35,7 @@ const ENV = {
34
35
  ytdlpConcurrency: "YT_TO_MD_YTDLP_CONCURRENCY",
35
36
  retryDelays: "YT_TO_MD_RETRY_DELAYS",
36
37
  maxOutputTokens: "YT_TO_MD_MAX_OUTPUT_TOKENS",
38
+ chunkTargetTokens: "YT_TO_MD_CHUNK_TARGET_TOKENS",
37
39
  };
38
40
 
39
41
  export function loadEnv(env = process.env) {
package/src/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
3
3
  import { generateText } from "ai";
4
4
  import { loadConfig, settingsFromConfig } from "./config.js";
5
5
  import { createModel, describeError } from "./generate-markdown.js";
6
+ import { loadPricing } from "./pricing.js";
6
7
  import { exists } from "./vtt.js";
7
8
  import { runYtDlp } from "./ytdlp.js";
8
9
 
@@ -86,7 +87,7 @@ async function loadConfigSafe(configPath) {
86
87
  row: {
87
88
  status: "ok",
88
89
  value: configPath,
89
- note: `${channels} channel(s), ${videos} video(s)`,
90
+ note: `${channels} source(s), ${videos} video(s)`,
90
91
  },
91
92
  };
92
93
  } catch (error) {
@@ -141,6 +142,31 @@ async function checkLlm({ llm, settings, key, timeoutMs }) {
141
142
  }
142
143
  }
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
+
144
170
  function render(rows) {
145
171
  const labelWidth = Math.max(...rows.map((row) => row.label.length));
146
172
  const valueWidth = Math.max(...rows.map((row) => row.value.length));
@@ -192,6 +218,7 @@ export async function runDoctor({
192
218
  if (settings.session) {
193
219
  rows.push({ label: "session", value: "set", status: "info", note: null });
194
220
  }
221
+ rows.push({ label: "pricing", ...(await checkPricing({ settings })) });
195
222
  const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
196
223
  rows.push({ label: "llm", ...llmRow });
197
224
 
@@ -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,19 @@
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 {
9
+ chunkTranscript,
10
+ dedupeAdjacentHeadings,
11
+ estimateTokens,
12
+ firstBlock,
13
+ lastBlock,
14
+ replaceFirstBlock,
15
+ } from "./chunk.js";
16
+ import { loadPricing } from "./pricing.js";
7
17
  import {
8
18
  DEFAULT_DIR,
9
19
  downloadVtt,
@@ -24,19 +34,23 @@ const SYSTEM = [
24
34
  "- Output only the Markdown body.",
25
35
  ].join("\n");
26
36
 
27
- const WIDTH = 80;
37
+ const SMOOTH_SYSTEM = [
38
+ "You improve the transition between two consecutive Markdown sections that were written independently.",
39
+ "- You are given the end of the previous section and the beginning of the next one.",
40
+ "- Rewrite ONLY the beginning of the next section so it flows naturally from the previous one.",
41
+ "- Keep the same language, the same facts and roughly the same length.",
42
+ "- Keep the Markdown formatting of the beginning.",
43
+ "- Output only the rewritten beginning, without any preamble or code fences.",
44
+ ].join("\n");
28
45
 
29
- const PRICES = {
30
- "mimo-v2.5": { input: 0.14, output: 0.28, cachedInput: 0.0028 }
31
- };
46
+ const WIDTH = 80;
32
47
 
33
48
  function cachedInputTokens(usage) {
34
49
  const details = usage?.inputTokenDetails ?? {};
35
50
  return details.cacheReadTokens ?? usage?.cachedInputTokens ?? 0;
36
51
  }
37
52
 
38
- function estimateCost(modelId, usage) {
39
- const price = PRICES[modelId];
53
+ function estimateCost(price, usage) {
40
54
  if (!price || !usage) return null;
41
55
 
42
56
  const details = usage.inputTokenDetails ?? {};
@@ -53,20 +67,26 @@ function estimateCost(modelId, usage) {
53
67
 
54
68
  const OUTPUT_FACTOR = 2;
55
69
  const OUTPUT_FLOOR = 2048;
56
- const OUTPUT_CEILING = 32768;
70
+ const DEFAULT_CEILING = 32768;
71
+ const DEFAULT_CHUNK_TARGET_TOKENS = 8000;
57
72
 
58
- function outputBudget(transcript, override) {
59
- if (override !== undefined && override !== null && override !== "") {
60
- return Number(override);
61
- }
73
+ function isSet(value) {
74
+ return value !== undefined && value !== null && value !== "";
75
+ }
62
76
 
63
- const inputTokens = Math.ceil(transcript.length / 3.5);
77
+ function plannedBudget(transcript, ceiling) {
78
+ const inputTokens = estimateTokens(transcript);
64
79
  return Math.min(
65
- OUTPUT_CEILING,
80
+ ceiling,
66
81
  Math.max(OUTPUT_FLOOR, Math.ceil(inputTokens * OUTPUT_FACTOR)),
67
82
  );
68
83
  }
69
84
 
85
+ function budgetForPart(text, { maxOutputTokens, ceiling }) {
86
+ if (isSet(maxOutputTokens)) return Number(maxOutputTokens);
87
+ return plannedBudget(text, ceiling);
88
+ }
89
+
70
90
  function metricsLine(modelId, usage, elapsed, cost) {
71
91
  return [
72
92
  modelId,
@@ -80,19 +100,21 @@ function metricsLine(modelId, usage, elapsed, cost) {
80
100
  .join(" ");
81
101
  }
82
102
 
83
- function frontmatter(job, { modelId, usage, elapsed, cost, finishReason }) {
103
+ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price, chunks }) {
84
104
  const data = {
85
105
  source_url: job.url,
86
106
  video_title: titleForLang(job.titles, job.lang),
87
107
  generated_at: new Date().toISOString(),
88
108
  llm: {
89
109
  model: modelId,
110
+ chunks: chunks ?? 1,
90
111
  duration_seconds: Number(elapsed.toFixed(1)),
91
112
  input_tokens: usage?.inputTokens ?? null,
92
113
  cached_input_tokens: usage ? cachedInputTokens(usage) : null,
93
114
  output_tokens: usage?.outputTokens ?? null,
94
115
  estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
95
116
  finish_reason: finishReason,
117
+ pricing: price ? { source: price.source, peak: price.peak } : null,
96
118
  },
97
119
  };
98
120
 
@@ -201,44 +223,83 @@ function parseRetryDelays(value) {
201
223
  .filter((item) => Number.isFinite(item) && item >= 0);
202
224
  }
203
225
 
226
+ function convertUsage(usage) {
227
+ const prompt = usage?.prompt_tokens ?? 0;
228
+ const completion = usage?.completion_tokens ?? 0;
229
+ const cacheRead =
230
+ usage?.prompt_cache_hit_tokens ??
231
+ usage?.prompt_tokens_details?.cached_tokens ??
232
+ 0;
233
+ const reasoning = usage?.completion_tokens_details?.reasoning_tokens ?? 0;
234
+ return {
235
+ inputTokens: {
236
+ total: prompt,
237
+ noCache: Math.max(prompt - cacheRead, 0),
238
+ cacheRead,
239
+ cacheWrite: undefined,
240
+ },
241
+ outputTokens: {
242
+ total: completion,
243
+ text: Math.max(completion - reasoning, 0),
244
+ reasoning,
245
+ },
246
+ raw: usage,
247
+ };
248
+ }
249
+
204
250
  export function createModel({ modelId, endpoint, apiKey, session }) {
205
251
  const provider = createOpenAICompatible({
206
252
  name: "opencode",
207
253
  apiKey,
208
254
  baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
209
255
  headers: session ? { "X-OpenCode-Session": session } : {},
256
+ convertUsage,
210
257
  });
211
258
  return provider(modelId);
212
259
  }
213
260
 
214
- async function toMarkdown(
215
- { model, retryDelays, maxOutputTokens },
216
- title,
217
- transcript,
218
- onRetry,
261
+ function videoSession(base, job) {
262
+ const id = createHash("sha256")
263
+ .update(`${job.url}#${job.lang}`)
264
+ .digest("hex")
265
+ .slice(0, 16);
266
+ return `${base || "youtube-to-markdown"}-${id}`;
267
+ }
268
+
269
+ function cleanBody(text) {
270
+ return text
271
+ .trim()
272
+ .replace(/^```[a-z]*\n?/, "")
273
+ .replace(/\n?```$/, "")
274
+ .trim();
275
+ }
276
+
277
+ async function generateOnce(
278
+ { model, retryDelays, session, limiter },
279
+ { system, prompt, maxOutputTokens, onRetry },
219
280
  ) {
220
281
  const totalAttempts = retryDelays.length + 1;
221
282
 
222
283
  for (let attempt = 1;; attempt++) {
223
284
  const started = performance.now();
224
285
  try {
225
- const { text, usage, finishReason } = await generateText({
226
- model,
227
- system: SYSTEM,
228
- prompt: `Title: ${title}\n\nTranscript:\n${transcript}`,
229
- temperature: 0.2,
230
- maxOutputTokens: outputBudget(transcript, maxOutputTokens),
231
- maxRetries: 0,
232
- });
286
+ const call = () =>
287
+ generateText({
288
+ model,
289
+ system,
290
+ prompt,
291
+ temperature: 0.2,
292
+ reasoning: "low",
293
+ maxOutputTokens,
294
+ maxRetries: 0,
295
+ headers: { "X-OpenCode-Session": session },
296
+ });
297
+ const { text, usage, finishReason } = await (
298
+ limiter ? limiter(call) : call()
299
+ );
233
300
  const elapsed = (performance.now() - started) / 1000;
234
301
 
235
- const body = text
236
- .trim()
237
- .replace(/^```[a-z]*\n?/, "")
238
- .replace(/\n?```$/, "")
239
- .trim();
240
-
241
- return { body, usage, finishReason, elapsed };
302
+ return { text, usage, finishReason, elapsed };
242
303
  } catch (error) {
243
304
  if (!isRetryable(error) || attempt > retryDelays.length) {
244
305
  throw new Error(describeError(error, attempt), { cause: error });
@@ -250,6 +311,182 @@ async function toMarkdown(
250
311
  }
251
312
  }
252
313
 
314
+ function chunkPrompt(title, transcript, index, total) {
315
+ if (total <= 1) return `Title: ${title}\n\nTranscript:\n${transcript}`;
316
+ return [
317
+ `Title: ${title}`,
318
+ "",
319
+ `This is part ${index + 1} of ${total} of the transcript.`,
320
+ "Output only the Markdown body for this part, without the main title.",
321
+ "",
322
+ `Transcript (part ${index + 1}/${total}):`,
323
+ transcript,
324
+ ].join("\n");
325
+ }
326
+
327
+ function smoothPrompt(previousTail, nextHead) {
328
+ return [
329
+ "End of the previous section:",
330
+ "",
331
+ previousTail,
332
+ "",
333
+ "Beginning of the next section:",
334
+ "",
335
+ nextHead,
336
+ ].join("\n");
337
+ }
338
+
339
+ function sumUsage(usages) {
340
+ const present = usages.filter(Boolean);
341
+ if (present.length === 0) return null;
342
+
343
+ const total = {
344
+ inputTokens: 0,
345
+ outputTokens: 0,
346
+ totalTokens: 0,
347
+ inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0 },
348
+ outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
349
+ };
350
+
351
+ for (const usage of present) {
352
+ total.inputTokens += usage.inputTokens ?? 0;
353
+ total.outputTokens += usage.outputTokens ?? 0;
354
+ total.totalTokens += usage.totalTokens ?? 0;
355
+ total.inputTokenDetails.noCacheTokens +=
356
+ usage.inputTokenDetails?.noCacheTokens ?? 0;
357
+ total.inputTokenDetails.cacheReadTokens +=
358
+ usage.inputTokenDetails?.cacheReadTokens ?? 0;
359
+ total.outputTokenDetails.textTokens +=
360
+ usage.outputTokenDetails?.textTokens ?? 0;
361
+ total.outputTokenDetails.reasoningTokens +=
362
+ usage.outputTokenDetails?.reasoningTokens ?? 0;
363
+ }
364
+
365
+ return total;
366
+ }
367
+
368
+ function createLimiter(limit) {
369
+ const max = Math.max(1, Number(limit) || 1);
370
+ let active = 0;
371
+ const queue = [];
372
+
373
+ const pump = () => {
374
+ while (active < max && queue.length > 0) {
375
+ active++;
376
+ const { task, resolve, reject } = queue.shift();
377
+ Promise.resolve()
378
+ .then(task)
379
+ .then(resolve, reject)
380
+ .finally(() => {
381
+ active--;
382
+ pump();
383
+ });
384
+ }
385
+ };
386
+
387
+ return (task) =>
388
+ new Promise((resolve, reject) => {
389
+ queue.push({ task, resolve, reject });
390
+ pump();
391
+ });
392
+ }
393
+
394
+ async function toMarkdown(
395
+ {
396
+ model,
397
+ retryDelays,
398
+ maxOutputTokens,
399
+ ceiling,
400
+ chunkTargetTokens,
401
+ session,
402
+ limiter,
403
+ },
404
+ title,
405
+ transcript,
406
+ onRetry,
407
+ onProgress,
408
+ ) {
409
+ const budgetCeiling = isSet(maxOutputTokens)
410
+ ? Number(maxOutputTokens)
411
+ : ceiling;
412
+ const desired = Math.max(
413
+ OUTPUT_FLOOR,
414
+ Math.ceil(estimateTokens(transcript) * OUTPUT_FACTOR),
415
+ );
416
+ const parts =
417
+ desired > budgetCeiling
418
+ ? chunkTranscript(transcript, { targetTokens: chunkTargetTokens })
419
+ : [transcript];
420
+
421
+ const total = parts.length;
422
+ const bodies = new Array(total);
423
+ const usages = [];
424
+ let elapsed = 0;
425
+ let finishReason = "stop";
426
+
427
+ await Promise.all(
428
+ parts.map(async (part, index) => {
429
+ onProgress?.(index + 1, total);
430
+ const result = await generateOnce(
431
+ { model, retryDelays, session, limiter },
432
+ {
433
+ system: SYSTEM,
434
+ prompt: chunkPrompt(title, part, index, total),
435
+ maxOutputTokens: budgetForPart(part, { maxOutputTokens, ceiling }),
436
+ onRetry,
437
+ },
438
+ );
439
+ bodies[index] = cleanBody(result.text);
440
+ usages.push(result.usage);
441
+ elapsed += result.elapsed;
442
+ if (result.finishReason !== "stop" && finishReason === "stop") {
443
+ finishReason = result.finishReason;
444
+ }
445
+ }),
446
+ );
447
+
448
+ if (finishReason === "stop" && total > 1) {
449
+ for (let index = 0; index + 1 < total; index++) {
450
+ const previousTail = lastBlock(bodies[index]);
451
+ const nextHead = firstBlock(bodies[index + 1]);
452
+ if (!previousTail || !nextHead) continue;
453
+
454
+ const smoothing = await generateOnce(
455
+ { model, retryDelays, session, limiter },
456
+ {
457
+ system: SMOOTH_SYSTEM,
458
+ prompt: smoothPrompt(previousTail, nextHead),
459
+ maxOutputTokens: plannedBudget(nextHead, budgetCeiling),
460
+ onRetry,
461
+ },
462
+ );
463
+ usages.push(smoothing.usage);
464
+ elapsed += smoothing.elapsed;
465
+ if (smoothing.finishReason === "stop") {
466
+ const rewritten = cleanBody(smoothing.text);
467
+ if (rewritten) {
468
+ bodies[index + 1] = replaceFirstBlock(bodies[index + 1], rewritten);
469
+ }
470
+ }
471
+ }
472
+ }
473
+
474
+ const body = dedupeAdjacentHeadings(
475
+ bodies
476
+ .join("\n\n")
477
+ .replace(/\n{3,}/g, "\n\n")
478
+ .trim(),
479
+ );
480
+
481
+ return {
482
+ body,
483
+ usage: sumUsage(usages),
484
+ finishReason,
485
+ elapsed,
486
+ chunks: total,
487
+ };
488
+ }
489
+
253
490
  async function forEachConcurrent(items, limit, worker) {
254
491
  let next = 0;
255
492
  await Promise.all(
@@ -267,11 +504,12 @@ export async function runGenerateMarkdown({
267
504
  model: modelId,
268
505
  endpoint,
269
506
  apiKey,
270
- session,
507
+ sessionBase,
271
508
  concurrency = 6,
272
509
  ytdlpConcurrency = 2,
273
510
  retryDelays = "10,30,60",
274
511
  maxOutputTokens,
512
+ chunkTargetTokens = DEFAULT_CHUNK_TARGET_TOKENS,
275
513
  force = false,
276
514
  dir = DEFAULT_DIR,
277
515
  } = {}) {
@@ -283,8 +521,9 @@ export async function runGenerateMarkdown({
283
521
  const RETRY_DELAYS = parseRetryDelays(retryDelays);
284
522
  const llmConcurrency = Number(concurrency);
285
523
  const vttConcurrency = Number(ytdlpConcurrency);
524
+ const limiter = createLimiter(llmConcurrency);
286
525
 
287
- const model = createModel({ modelId, endpoint, apiKey: key, session });
526
+ const model = createModel({ modelId, endpoint, apiKey: key, session: sessionBase });
288
527
 
289
528
  await mkdir(dir, { recursive: true });
290
529
 
@@ -335,6 +574,17 @@ export async function runGenerateMarkdown({
335
574
  else pending.push(job);
336
575
  }
337
576
 
577
+ const prices = pending.length > 0 ? await loadPricing({ endpoint }) : null;
578
+ const modelOutputLimit = prices?.outputLimitAt(modelId) ?? null;
579
+ const ceiling = isSet(maxOutputTokens)
580
+ ? Number(maxOutputTokens)
581
+ : isSet(modelOutputLimit)
582
+ ? Number(modelOutputLimit)
583
+ : DEFAULT_CEILING;
584
+ console.error(
585
+ `output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
586
+ );
587
+
338
588
  const downloadTask = {
339
589
  title: "Download transcripts",
340
590
  rendererOptions: phaseOptions,
@@ -410,20 +660,34 @@ export async function runGenerateMarkdown({
410
660
  const name = relative(dir, md);
411
661
  try {
412
662
  const transcript = vttToText(await readFile(job.vtt, "utf8"));
413
- const { body, usage, finishReason, elapsed } = await toMarkdown(
414
- { model, retryDelays: RETRY_DELAYS, maxOutputTokens },
663
+ const price = prices ? prices.priceAt(modelId) : null;
664
+ const { body, usage, finishReason, elapsed, chunks } = await toMarkdown(
665
+ {
666
+ model,
667
+ retryDelays: RETRY_DELAYS,
668
+ maxOutputTokens,
669
+ ceiling,
670
+ chunkTargetTokens,
671
+ session: videoSession(sessionBase, job),
672
+ limiter,
673
+ },
415
674
  job.title,
416
675
  transcript,
417
676
  (nextAttempt, totalAttempts, waitMs) => {
418
677
  task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
419
678
  `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
420
679
  },
680
+ (part, totalParts) => {
681
+ if (totalParts > 1) {
682
+ task.output = `generating part ${part}/${totalParts} ${name}`;
683
+ }
684
+ },
421
685
  );
422
686
 
423
687
  stats.totalSeconds += elapsed;
424
688
  stats.totalInput += usage?.inputTokens ?? 0;
425
689
  stats.totalOutput += usage?.outputTokens ?? 0;
426
- const cost = estimateCost(modelId, usage);
690
+ const cost = estimateCost(price, usage);
427
691
  if (cost === null) stats.costKnown = false;
428
692
  else stats.totalCost += cost;
429
693
  const metrics = metricsLine(modelId, usage, elapsed, cost);
@@ -440,6 +704,8 @@ export async function runGenerateMarkdown({
440
704
  elapsed,
441
705
  cost,
442
706
  finishReason,
707
+ price,
708
+ chunks,
443
709
  });
444
710
  await mkdir(dirname(md), { recursive: true });
445
711
  await writeFile(
@@ -476,3 +742,13 @@ export async function runGenerateMarkdown({
476
742
  }`,
477
743
  );
478
744
  }
745
+
746
+ export {
747
+ budgetForPart,
748
+ chunkPrompt,
749
+ cleanBody,
750
+ createLimiter,
751
+ plannedBudget,
752
+ smoothPrompt,
753
+ sumUsage,
754
+ };
@@ -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,185 @@
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
+ outputLimitAt(modelId) {
177
+ const entry = litellm?.get(modelId);
178
+ const limit =
179
+ entry?.max_output_tokens ??
180
+ provider.models?.[modelId]?.limit?.output ??
181
+ entry?.max_tokens;
182
+ return typeof limit === "number" && limit > 0 ? limit : null;
183
+ },
184
+ };
185
+ }
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,