@stephane-klein/youtube-to-markdown 0.4.0 → 0.5.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
@@ -13,6 +13,37 @@ channel or playlist and then to download its transcripts.
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.
15
15
 
16
+ ## AI usage in this project
17
+
18
+ This project was developed using:
19
+
20
+ - Harness: [OpenCode](https://opencode.ai)
21
+ - Models: [DeepSeek-V4.1-Flash](https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash) via [OpenCode Go](https://opencode.ai/go)
22
+ - I mostly vibe coded this project: I made the implementation, library and architecture choices, but I reviewed the code very little.
23
+
24
+ The figures below cover the period from the start of the project to version 0.4.0
25
+ ([de39801](https://github.com/stephane-klein/youtube-to-markdown/commit/de39801f123fa1cfaeaa3b9cfed5071968977474)).
26
+
27
+ Metrics:
28
+
29
+ - 16 OpenCode sessions, 1426 interactions;
30
+ - 11 h of human time on this project, against 4 h of AI generation time over the same period, so a human/AI time ratio of 2.75 (11 h / 4 h);
31
+ - Cost: $2.4958;
32
+ - Tokens:
33
+ - input: 7,047,916
34
+ - output: 421,770
35
+ - cache read: 228,841,600
36
+ - total: 236,311,286
37
+
38
+ Per agent:
39
+
40
+ | agent | interactions | share | output tokens | active hours | active share | generation hours | cost |
41
+ |:--------|-------------:|------:|--------------:|-------------:|-------------:|-----------------:|-------:|
42
+ | build | 962 | 68% | 259,706 | 3.71 | 38% | 1.93 | $1.385 |
43
+ | plan | 450 | 32% | 155,136 | 5.91 | 61% | 2.10 | $1.098 |
44
+ | explore | 14 | 1% | 6,928 | 0.02 | 0% | 0.02 | $0.013 |
45
+
46
+
16
47
  ## Requirements
17
48
 
18
49
  - Node.js 22 or later;
@@ -129,7 +160,13 @@ can gate a CI job.
129
160
  ```sh
130
161
  $ youtube-to-markdown extract-video-metadata
131
162
  → https://www.youtube.com/@le_science4all
163
+ exact dates 50/2217 2.4s elapsed ~1m 44s left
164
+ exact dates 100/2217 4.8s elapsed ~1m 42s left
165
+ exact dates 2217/2217 1m 46s elapsed
166
+ done in 1m 46s
132
167
  → https://www.youtube.com/@MonsieurPhi
168
+ done in 12.3s
169
+ summary: 2 sources, 2678 videos, 2m 05s elapsed
133
170
  ```
134
171
 
135
172
  The command is idempotent: a new run only adds the missing videos.
@@ -348,10 +385,10 @@ llm:
348
385
  `video_title` is the original video title in the file's language and
349
386
  `generated_at` is the UTC time of the generation. `pricing.source` names the
350
387
  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.
388
+ applied to the first call (`null` when the source has no peak/off-peak pricing).
389
+ Existing files are left untouched: run `youtube-to-markdown generate-markdown
390
+ --force` to regenerate every file, which is also the way to add the frontmatter
391
+ to files generated before it existed.
355
392
 
356
393
  ### Pricing
357
394
 
@@ -364,6 +401,10 @@ catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
364
401
  (`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
365
402
  unavailable, `estimated_cost_usd` is `null` and the run continues.
366
403
 
404
+ The rate is sampled at the start of every LLM call, so a run that straddles a
405
+ peak/off-peak boundary is priced with the right rate on each side. The
406
+ `pricing` block in the frontmatter describes the first call of the file.
407
+
367
408
  ## Organize the files into folders
368
409
 
369
410
  A channel may set `folder_slug` to keep its files in their own subdirectory of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stephane-klein/youtube-to-markdown",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -1,9 +1,12 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import { stringify } from "yaml";
3
+ import { formatDuration, progressLine } from "./progress.js";
3
4
  import { runYtDlp } from "./ytdlp.js";
4
5
 
5
6
  const DATE_CHUNK = 50;
6
7
 
8
+ const count = (value, word) => `${value} ${word}${value === 1 ? "" : "s"}`;
9
+
7
10
  const toIso = (raw) =>
8
11
  /^\d{8}$/.test(raw)
9
12
  ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
@@ -61,12 +64,10 @@ const videoId = (url) => /[?&]v=([\w-]+)/.exec(url)?.[1] ?? url;
61
64
 
62
65
  async function fetchExactDates(urls) {
63
66
  const dates = new Map();
67
+ const startedAt = performance.now();
64
68
 
65
69
  for (let i = 0; i < urls.length; i += DATE_CHUNK) {
66
70
  const chunk = urls.slice(i, i + DATE_CHUNK);
67
- console.error(
68
- ` exact dates ${Math.min(i + DATE_CHUNK, urls.length)}/${urls.length}`,
69
- );
70
71
  const out = await ytDlp(
71
72
  [
72
73
  "--skip-download",
@@ -84,6 +85,15 @@ async function fetchExactDates(urls) {
84
85
  const iso = toIso(raw ?? "");
85
86
  if (id && iso) dates.set(id, iso);
86
87
  }
88
+
89
+ console.error(
90
+ progressLine({
91
+ label: "exact dates",
92
+ done: Math.min(i + DATE_CHUNK, urls.length),
93
+ total: urls.length,
94
+ startedAt,
95
+ }),
96
+ );
87
97
  }
88
98
 
89
99
  return dates;
@@ -180,9 +190,14 @@ export async function runExtract({
180
190
  config = {},
181
191
  forceDates = false,
182
192
  } = {}) {
193
+ const startedAt = performance.now();
194
+ let sourceCount = 0;
195
+ let videoCount = 0;
196
+
183
197
  for (const channel of config.feed ?? []) {
184
198
  if (typeof channel.url !== "string" || !channel.url) continue;
185
199
 
200
+ const channelStartedAt = performance.now();
186
201
  const videos = channel.videos ?? [];
187
202
  const knownDates = forceDates
188
203
  ? new Map()
@@ -197,7 +212,17 @@ export async function runExtract({
197
212
  channel.videos = mergeVideos(videos, fetched.videos, {
198
213
  sort: !fetched.playlist,
199
214
  });
215
+
216
+ sourceCount += 1;
217
+ videoCount += channel.videos.length;
218
+ console.error(
219
+ ` done in ${formatDuration(performance.now() - channelStartedAt)}`,
220
+ );
200
221
  }
201
222
 
202
223
  await writeFile(path, stringify(config, { lineWidth: 0 }));
224
+
225
+ console.error(
226
+ `summary: ${count(sourceCount, "source")}, ${count(videoCount, "video")}, ${formatDuration(performance.now() - startedAt)} elapsed`,
227
+ );
203
228
  }
@@ -275,7 +275,7 @@ function cleanBody(text) {
275
275
  }
276
276
 
277
277
  async function generateOnce(
278
- { model, retryDelays, session, limiter },
278
+ { model, retryDelays, session, limiter, priceAt },
279
279
  { system, prompt, maxOutputTokens, onRetry },
280
280
  ) {
281
281
  const totalAttempts = retryDelays.length + 1;
@@ -283,8 +283,10 @@ async function generateOnce(
283
283
  for (let attempt = 1;; attempt++) {
284
284
  const started = performance.now();
285
285
  try {
286
- const call = () =>
287
- generateText({
286
+ let at;
287
+ const call = () => {
288
+ at = new Date();
289
+ return generateText({
288
290
  model,
289
291
  system,
290
292
  prompt,
@@ -294,12 +296,15 @@ async function generateOnce(
294
296
  maxRetries: 0,
295
297
  headers: { "X-OpenCode-Session": session },
296
298
  });
299
+ };
297
300
  const { text, usage, finishReason } = await (
298
301
  limiter ? limiter(call) : call()
299
302
  );
300
303
  const elapsed = (performance.now() - started) / 1000;
304
+ const price = priceAt ? priceAt(at) : null;
305
+ const cost = estimateCost(price, usage);
301
306
 
302
- return { text, usage, finishReason, elapsed };
307
+ return { text, usage, finishReason, elapsed, price, cost };
303
308
  } catch (error) {
304
309
  if (!isRetryable(error) || attempt > retryDelays.length) {
305
310
  throw new Error(describeError(error, attempt), { cause: error });
@@ -400,6 +405,7 @@ async function toMarkdown(
400
405
  chunkTargetTokens,
401
406
  session,
402
407
  limiter,
408
+ priceAt,
403
409
  },
404
410
  title,
405
411
  transcript,
@@ -423,12 +429,15 @@ async function toMarkdown(
423
429
  const usages = [];
424
430
  let elapsed = 0;
425
431
  let finishReason = "stop";
432
+ let cost = 0;
433
+ let costKnown = true;
434
+ let price = null;
426
435
 
427
436
  await Promise.all(
428
437
  parts.map(async (part, index) => {
429
438
  onProgress?.(index + 1, total);
430
439
  const result = await generateOnce(
431
- { model, retryDelays, session, limiter },
440
+ { model, retryDelays, session, limiter, priceAt },
432
441
  {
433
442
  system: SYSTEM,
434
443
  prompt: chunkPrompt(title, part, index, total),
@@ -439,6 +448,9 @@ async function toMarkdown(
439
448
  bodies[index] = cleanBody(result.text);
440
449
  usages.push(result.usage);
441
450
  elapsed += result.elapsed;
451
+ if (result.cost === null) costKnown = false;
452
+ else cost += result.cost;
453
+ if (price === null && result.price) price = result.price;
442
454
  if (result.finishReason !== "stop" && finishReason === "stop") {
443
455
  finishReason = result.finishReason;
444
456
  }
@@ -452,7 +464,7 @@ async function toMarkdown(
452
464
  if (!previousTail || !nextHead) continue;
453
465
 
454
466
  const smoothing = await generateOnce(
455
- { model, retryDelays, session, limiter },
467
+ { model, retryDelays, session, limiter, priceAt },
456
468
  {
457
469
  system: SMOOTH_SYSTEM,
458
470
  prompt: smoothPrompt(previousTail, nextHead),
@@ -462,6 +474,9 @@ async function toMarkdown(
462
474
  );
463
475
  usages.push(smoothing.usage);
464
476
  elapsed += smoothing.elapsed;
477
+ if (smoothing.cost === null) costKnown = false;
478
+ else cost += smoothing.cost;
479
+ if (price === null && smoothing.price) price = smoothing.price;
465
480
  if (smoothing.finishReason === "stop") {
466
481
  const rewritten = cleanBody(smoothing.text);
467
482
  if (rewritten) {
@@ -484,6 +499,8 @@ async function toMarkdown(
484
499
  finishReason,
485
500
  elapsed,
486
501
  chunks: total,
502
+ cost: costKnown ? cost : null,
503
+ price,
487
504
  };
488
505
  }
489
506
 
@@ -581,6 +598,7 @@ export async function runGenerateMarkdown({
581
598
  : isSet(modelOutputLimit)
582
599
  ? Number(modelOutputLimit)
583
600
  : DEFAULT_CEILING;
601
+ const priceAt = prices ? (at) => prices.priceAt(modelId, at) : null;
584
602
  console.error(
585
603
  `output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
586
604
  );
@@ -660,34 +678,34 @@ export async function runGenerateMarkdown({
660
678
  const name = relative(dir, md);
661
679
  try {
662
680
  const transcript = vttToText(await readFile(job.vtt, "utf8"));
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
- },
674
- job.title,
675
- transcript,
676
- (nextAttempt, totalAttempts, waitMs) => {
677
- task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
678
- `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
679
- },
680
- (part, totalParts) => {
681
- if (totalParts > 1) {
682
- task.output = `generating part ${part}/${totalParts} ${name}`;
683
- }
684
- },
685
- );
681
+ const { body, usage, finishReason, elapsed, chunks, cost, price } =
682
+ await toMarkdown(
683
+ {
684
+ model,
685
+ retryDelays: RETRY_DELAYS,
686
+ maxOutputTokens,
687
+ ceiling,
688
+ chunkTargetTokens,
689
+ session: videoSession(sessionBase, job),
690
+ limiter,
691
+ priceAt,
692
+ },
693
+ job.title,
694
+ transcript,
695
+ (nextAttempt, totalAttempts, waitMs) => {
696
+ task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
697
+ `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
698
+ },
699
+ (part, totalParts) => {
700
+ if (totalParts > 1) {
701
+ task.output = `generating part ${part}/${totalParts} ${name}`;
702
+ }
703
+ },
704
+ );
686
705
 
687
706
  stats.totalSeconds += elapsed;
688
707
  stats.totalInput += usage?.inputTokens ?? 0;
689
708
  stats.totalOutput += usage?.outputTokens ?? 0;
690
- const cost = estimateCost(price, usage);
691
709
  if (cost === null) stats.costKnown = false;
692
710
  else stats.totalCost += cost;
693
711
  const metrics = metricsLine(modelId, usage, elapsed, cost);
@@ -748,6 +766,7 @@ export {
748
766
  chunkPrompt,
749
767
  cleanBody,
750
768
  createLimiter,
769
+ estimateCost,
751
770
  plannedBudget,
752
771
  smoothPrompt,
753
772
  sumUsage,
@@ -0,0 +1,32 @@
1
+ export function formatDuration(ms) {
2
+ const value = Number.isFinite(ms) && ms > 0 ? ms : 0;
3
+ if (value < 1000) return `${Math.round(value)}ms`;
4
+
5
+ const seconds = value / 1000;
6
+ if (seconds < 59.95) return `${seconds.toFixed(1)}s`;
7
+
8
+ const rounded = Math.round(seconds);
9
+ const minutes = Math.floor(rounded / 60);
10
+ if (minutes < 60) return `${minutes}m ${String(rounded % 60).padStart(2, "0")}s`;
11
+
12
+ const hours = Math.floor(minutes / 60);
13
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
14
+ }
15
+
16
+ export function progressStats({ done, total, startedAt, now = performance.now() }) {
17
+ const elapsed = Math.max(0, now - startedAt);
18
+ const completed = Math.min(Math.max(done, 0), Math.max(total, 0));
19
+
20
+ if (completed === 0 || total <= 0) return { elapsed, eta: null };
21
+
22
+ return { elapsed, eta: (elapsed * (total - completed)) / completed };
23
+ }
24
+
25
+ export function progressLine({ label, done, total, startedAt, now }) {
26
+ const { elapsed, eta } = progressStats({ done, total, startedAt, now });
27
+ const parts = [` ${label} ${done}/${total}`, `${formatDuration(elapsed)} elapsed`];
28
+
29
+ if (eta !== null && eta > 0) parts.push(`~${formatDuration(eta)} left`);
30
+
31
+ return parts.join(" ");
32
+ }