@stephane-klein/youtube-to-markdown 0.2.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
@@ -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
+ `retry_delays` and `max_output_tokens`). I only set the source URLs, and the
150
151
  `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:
@@ -295,14 +319,30 @@ llm:
295
319
  output_tokens: 3262
296
320
  estimated_cost_usd: 0.000927
297
321
  finish_reason: stop
322
+ pricing:
323
+ source: models.dev
324
+ peak: null
298
325
  ---
299
326
  ```
300
327
 
301
328
  `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.
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.
306
346
 
307
347
  ## Organize the files into folders
308
348
 
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.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
@@ -57,7 +57,7 @@ const cli = yargs(hideBin(process.argv))
57
57
  .option("config", config)
58
58
  .command(
59
59
  "extract-video-metadata",
60
- "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",
61
61
  (yargs) =>
62
62
  yargs
63
63
  .option("force-dates", {
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,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
 
@@ -201,18 +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
+
204
226
  export function createModel({ modelId, endpoint, apiKey, session }) {
205
227
  const provider = createOpenAICompatible({
206
228
  name: "opencode",
207
229
  apiKey,
208
230
  baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
209
231
  headers: session ? { "X-OpenCode-Session": session } : {},
232
+ convertUsage,
210
233
  });
211
234
  return provider(modelId);
212
235
  }
213
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
+
214
245
  async function toMarkdown(
215
- { model, retryDelays, maxOutputTokens },
246
+ { model, retryDelays, maxOutputTokens, session },
216
247
  title,
217
248
  transcript,
218
249
  onRetry,
@@ -227,8 +258,10 @@ async function toMarkdown(
227
258
  system: SYSTEM,
228
259
  prompt: `Title: ${title}\n\nTranscript:\n${transcript}`,
229
260
  temperature: 0.2,
261
+ reasoning: "low",
230
262
  maxOutputTokens: outputBudget(transcript, maxOutputTokens),
231
263
  maxRetries: 0,
264
+ headers: { "X-OpenCode-Session": session },
232
265
  });
233
266
  const elapsed = (performance.now() - started) / 1000;
234
267
 
@@ -267,7 +300,7 @@ export async function runGenerateMarkdown({
267
300
  model: modelId,
268
301
  endpoint,
269
302
  apiKey,
270
- session,
303
+ sessionBase,
271
304
  concurrency = 6,
272
305
  ytdlpConcurrency = 2,
273
306
  retryDelays = "10,30,60",
@@ -284,7 +317,7 @@ export async function runGenerateMarkdown({
284
317
  const llmConcurrency = Number(concurrency);
285
318
  const vttConcurrency = Number(ytdlpConcurrency);
286
319
 
287
- const model = createModel({ modelId, endpoint, apiKey: key, session });
320
+ const model = createModel({ modelId, endpoint, apiKey: key, session: sessionBase });
288
321
 
289
322
  await mkdir(dir, { recursive: true });
290
323
 
@@ -335,6 +368,8 @@ export async function runGenerateMarkdown({
335
368
  else pending.push(job);
336
369
  }
337
370
 
371
+ const prices = pending.length > 0 ? await loadPricing({ endpoint }) : null;
372
+
338
373
  const downloadTask = {
339
374
  title: "Download transcripts",
340
375
  rendererOptions: phaseOptions,
@@ -410,8 +445,14 @@ export async function runGenerateMarkdown({
410
445
  const name = relative(dir, md);
411
446
  try {
412
447
  const transcript = vttToText(await readFile(job.vtt, "utf8"));
448
+ const price = prices ? prices.priceAt(modelId) : null;
413
449
  const { body, usage, finishReason, elapsed } = await toMarkdown(
414
- { model, retryDelays: RETRY_DELAYS, maxOutputTokens },
450
+ {
451
+ model,
452
+ retryDelays: RETRY_DELAYS,
453
+ maxOutputTokens,
454
+ session: videoSession(sessionBase, job),
455
+ },
415
456
  job.title,
416
457
  transcript,
417
458
  (nextAttempt, totalAttempts, waitMs) => {
@@ -423,7 +464,7 @@ export async function runGenerateMarkdown({
423
464
  stats.totalSeconds += elapsed;
424
465
  stats.totalInput += usage?.inputTokens ?? 0;
425
466
  stats.totalOutput += usage?.outputTokens ?? 0;
426
- const cost = estimateCost(modelId, usage);
467
+ const cost = estimateCost(price, usage);
427
468
  if (cost === null) stats.costKnown = false;
428
469
  else stats.totalCost += cost;
429
470
  const metrics = metricsLine(modelId, usage, elapsed, cost);
@@ -440,6 +481,7 @@ export async function runGenerateMarkdown({
440
481
  elapsed,
441
482
  cost,
442
483
  finishReason,
484
+ price,
443
485
  });
444
486
  await mkdir(dirname(md), { recursive: true });
445
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,