@tacone/prosey 0.5.0 → 0.7.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/bin/prosey CHANGED
@@ -101600,46 +101600,6 @@ var lt = (r) => {
101600
101600
  };
101601
101601
 
101602
101602
  // src/config.ts
101603
- var FALLBACK_CONFIG_TOML = `# Default prosey configuration
101604
- # Created automatically on first run. Edit as needed.
101605
-
101606
- # Pager command for transcript and summary output.
101607
- # Defaults to "auto": bat -lmd --style plain → glow -p → mdcat -l -p → less
101608
- # Set to a custom command (e.g. "less -R") to override.
101609
- # Can also be set via the PROSEY_PAGER env var (takes precedence).
101610
- pager = "auto"
101611
-
101612
- # Show hints for missing tools (e.g. markdown highlighter).
101613
- # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
101614
- hints = true
101615
-
101616
- [ai]
101617
- # Default command for AI operations (summarize, transcribe).
101618
- # Can be overridden per-section via the command key below.
101619
- command = "opencode run"
101620
-
101621
- [summarize]
101622
- # Prompt sent to the command via stdin.
101623
- # Customize this to change how transcripts are summarized.
101624
- prompt = """
101625
- Write a comprehensive summary of the following transcription.
101626
- """
101627
-
101628
- # Command override for summarize. Uncomment to use a different command
101629
- # than the one specified in [ai].
101630
- # command = "opencode run"
101631
-
101632
- [transcribe]
101633
- # Prompt sent to the command via stdin.
101634
- # Customize this to change how transcripts are formatted as markdown.
101635
- prompt = """
101636
- Convert this transcript to clean, readable markdown.
101637
- """
101638
-
101639
- # Command override for transcribe. Uncomment to use a different command
101640
- # than the one specified in [ai].
101641
- # command = "opencode run"
101642
- `;
101643
101603
  async function readDefaultConfig() {
101644
101604
  const paths = [
101645
101605
  join(dirname(fileURLToPath(import.meta.url)), "default-config.toml"),
@@ -101649,7 +101609,7 @@ async function readDefaultConfig() {
101649
101609
  if (existsSync(p2))
101650
101610
  return readFile(p2, "utf8");
101651
101611
  }
101652
- return FALLBACK_CONFIG_TOML;
101612
+ throw new Error("default-config.toml not found");
101653
101613
  }
101654
101614
  function configDir() {
101655
101615
  const env = process.env.XDG_CONFIG_HOME;
@@ -103188,14 +103148,23 @@ var LOGO_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(LOGO_
103188
103148
  function escapeHtml(text) {
103189
103149
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
103190
103150
  }
103191
- async function generateHtml(markdown, title) {
103151
+ function watchMetaHtml(duration, wordCount, videoId) {
103152
+ const h2 = Math.floor(duration / 3600);
103153
+ const m3 = Math.round(duration % 3600 / 60);
103154
+ const durStr = h2 > 0 ? `${h2} h ${m3} min` : `${m3} min`;
103155
+ const readTime = Math.ceil(wordCount / 200);
103156
+ const url = `https://youtube.com/watch?v=${videoId}`;
103157
+ const separator = '<span style="opacity: 0.4">&nbsp;&nbsp;|&nbsp;&nbsp;</span>';
103158
+ return `<a href="${url}" class="watch-link">${durStr} watch</a> ${separator} ${readTime} min read`;
103159
+ }
103160
+ async function generateHtml(markdown, title, watchMeta) {
103192
103161
  const [css, body] = await Promise.all([getPicoCss(), g2.parse(markdown)]);
103193
103162
  return `<!DOCTYPE html>
103194
103163
  <html lang="en">
103195
103164
  <head>
103196
103165
  <meta charset="UTF-8">
103197
103166
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
103198
- <title>${escapeHtml(title ?? "Prosey")}</title>
103167
+ <title>${title ? escapeHtml(title) + " - Prosey" : "Prosey"}</title>
103199
103168
  <script>(function(){var m=localStorage.getItem('prosey-theme'),t=m||'auto';if(t==='auto')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);if(!m)localStorage.setItem('prosey-theme','auto')})();</script>
103200
103169
  <link rel="preconnect" href="https://fonts.googleapis.com">
103201
103170
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -103249,14 +103218,17 @@ blockquote:last-child { margin-bottom: 0; }
103249
103218
  #theme-btn:hover, #theme-btn:focus, #theme-btn:active { opacity: 1 !important; }
103250
103219
  img[alt="Prosey"] { filter: grayscale(100%); }
103251
103220
  img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { filter: grayscale(0%); }
103221
+ .watch-link { color: inherit; text-decoration: none; }
103222
+ .watch-link:hover { text-decoration: underline; }
103252
103223
  </style>
103253
103224
  </head>
103254
103225
  <body>
103255
- <div style="display:flex;justify-content:space-between;align-items:flex-start;padding:1rem 1rem 0">
103256
- <img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top">
103257
- <button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s">\uD83D\uDCA1</button>
103226
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem 0">
103227
+ <img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top;flex-shrink:0">
103228
+ <div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId) : ""}</div>
103229
+ <button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s;flex-shrink:0">\uD83D\uDCA1</button>
103258
103230
  </div>
103259
- <main style="max-width:720px;margin:0 auto;padding:1rem">
103231
+ <main style="max-width:50em;margin:0 auto;padding:1rem">
103260
103232
  ${body}
103261
103233
  </main>
103262
103234
  <script>
@@ -103291,7 +103263,7 @@ function openInBrowser(htmlPath) {
103291
103263
  // package.json
103292
103264
  var package_default = {
103293
103265
  name: "@tacone/prosey",
103294
- version: "0.5.0",
103266
+ version: "0.7.0",
103295
103267
  description: "Download YouTube video transcripts from the CLI",
103296
103268
  module: "src/index.ts",
103297
103269
  type: "module",
@@ -122284,10 +122256,12 @@ try {
122284
122256
  const dir2 = cacheDir(videoId, cacheOpts2);
122285
122257
  let segments2 = null;
122286
122258
  let summary = null;
122259
+ let cachedInfo = null;
122287
122260
  startTimer();
122288
122261
  if (!noCache) {
122289
122262
  const cachedSegments = await readCache(dir2, "transcript.json");
122290
122263
  const cachedSummary = await readCache(dir2, "summary.md");
122264
+ cachedInfo = await readCache(dir2, "info.json");
122291
122265
  if (cachedSegments && cachedSummary) {
122292
122266
  info("Transcript cached");
122293
122267
  debug("Cache hit:", dir2);
@@ -122299,23 +122273,92 @@ try {
122299
122273
  } else {
122300
122274
  debug("Cache skipped (--no-cache)");
122301
122275
  }
122302
- if (!segments2) {
122303
- info("Fetching transcript...");
122304
- segments2 = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
122305
- info(`Transcript: ${segments2.length} segments`);
122306
- await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
122307
- debug("Cache written: transcript.json");
122308
- }
122309
122276
  const prompt2 = resolveSummarizePrompt(config) ?? "";
122310
122277
  if (!prompt2) {
122311
122278
  console.error("Error: no prompt configured. Set a prompt in the [summarize] section of your config.");
122312
122279
  exitProcess(1);
122313
122280
  }
122314
- const transcriptText = toText(segments2, !noDecode);
122281
+ let structuredContent;
122282
+ let videoTitle;
122283
+ let videoDuration = 0;
122284
+ if (!segments2) {
122285
+ info("Fetching transcript...");
122286
+ const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
122287
+ const result = await fetchTranscript(videoId, opts);
122288
+ segments2 = result.segments;
122289
+ videoDuration = result.videoDetails.lengthSeconds;
122290
+ const infoJson = JSON.stringify({
122291
+ title: result.videoDetails.title,
122292
+ channel: result.videoDetails.author,
122293
+ description: result.videoDetails.description,
122294
+ duration: videoDuration
122295
+ });
122296
+ cachedInfo = infoJson;
122297
+ const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
122298
+ videoTitle = result.videoDetails.title;
122299
+ const truncatedInfo = JSON.stringify({
122300
+ title: result.videoDetails.title,
122301
+ channel: result.videoDetails.author,
122302
+ description: result.videoDetails.description.slice(0, 1000)
122303
+ });
122304
+ const transcriptText = toText(segments2, !noDecode);
122305
+ structuredContent = `INFO:
122306
+ ${truncatedInfo}
122307
+
122308
+ TEXT:
122309
+ ${transcriptText}`;
122310
+ if (dryRun) {
122311
+ await outputText(`${prompt2}
122312
+
122313
+ ${structuredContent}
122314
+ `);
122315
+ exitProcess(0);
122316
+ }
122317
+ info(`Transcript: ${segments2.length} segments`);
122318
+ await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
122319
+ await writeCache(dir2, "info.json", infoJson);
122320
+ await writeCache(dir2, "chapters.json", chapterValue);
122321
+ debug("Cache written: transcript.json, info.json, chapters.json");
122322
+ } else {
122323
+ let chapterValue;
122324
+ if (!cachedInfo) {
122325
+ debug("Cache missing info.json, re-fetching video details");
122326
+ const fallbackOpts = lang ? { lang, videoDetails: true } : { videoDetails: true };
122327
+ const fallbackResult = await fetchTranscript(videoId, fallbackOpts);
122328
+ videoDuration = fallbackResult.videoDetails.lengthSeconds;
122329
+ cachedInfo = JSON.stringify({
122330
+ title: fallbackResult.videoDetails.title,
122331
+ channel: fallbackResult.videoDetails.author,
122332
+ description: fallbackResult.videoDetails.description,
122333
+ duration: videoDuration
122334
+ });
122335
+ await writeCache(dir2, "info.json", cachedInfo);
122336
+ chapterValue = formatChaptersAsJson(extractChapters(fallbackResult.videoDetails.description));
122337
+ await writeCache(dir2, "chapters.json", chapterValue);
122338
+ debug("Cache written: info.json, chapters.json");
122339
+ } else {
122340
+ const cachedChapters = await readCache(dir2, "chapters.json");
122341
+ chapterValue = cachedChapters ?? "not available";
122342
+ }
122343
+ const transcriptText = toText(segments2, !noDecode);
122344
+ const cachedInfoObj = JSON.parse(cachedInfo);
122345
+ videoTitle = cachedInfoObj.title;
122346
+ videoDuration = cachedInfoObj.duration ?? 0;
122347
+ const truncatedInfo = JSON.stringify({
122348
+ title: cachedInfoObj.title,
122349
+ channel: cachedInfoObj.channel,
122350
+ description: cachedInfoObj.description.slice(0, 1000)
122351
+ });
122352
+ structuredContent = `INFO:
122353
+ ${truncatedInfo}
122354
+
122355
+ TEXT:
122356
+ ${transcriptText}`;
122357
+ }
122315
122358
  if (dryRun) {
122316
122359
  await outputText(`${prompt2}
122317
122360
 
122318
- ${transcriptText}
122361
+ ${structuredContent}
122319
122362
  `);
122320
122363
  exitProcess(0);
122321
122364
  }
@@ -122324,7 +122367,7 @@ ${transcriptText}
122324
122367
  summary = await summarize({
122325
122368
  prompt: prompt2,
122326
122369
  command: sumCmd,
122327
- transcript: transcriptText,
122370
+ transcript: structuredContent,
122328
122371
  cwd: dir2
122329
122372
  });
122330
122373
  info("Summary ready");
@@ -122333,7 +122376,12 @@ ${transcriptText}
122333
122376
  }
122334
122377
  const formatted = noFormat ? summary : await formatMd(summary);
122335
122378
  if (format3 === "html") {
122336
- const htmlContent = await generateHtml(formatted);
122379
+ const wordCount = summary.split(/\s+/).filter(Boolean).length;
122380
+ const htmlContent = await generateHtml(formatted, videoTitle, {
122381
+ videoId,
122382
+ duration: videoDuration,
122383
+ wordCount
122384
+ });
122337
122385
  const htmlPath = join5(dir2, "summary.html");
122338
122386
  await writeFile3(htmlPath, htmlContent, "utf8");
122339
122387
  debug("HTML written:", htmlPath);
@@ -122364,6 +122412,8 @@ ${transcriptText}
122364
122412
  const dir2 = cacheDir(videoId, cacheOpts2);
122365
122413
  let segments2 = null;
122366
122414
  let md = null;
122415
+ let videoTitle;
122416
+ let videoDuration = 0;
122367
122417
  startTimer();
122368
122418
  let cachedInfo = null;
122369
122419
  if (!noCache) {
@@ -122391,12 +122441,15 @@ ${transcriptText}
122391
122441
  const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
122392
122442
  const result = await fetchTranscript(videoId, opts);
122393
122443
  segments2 = result.segments;
122444
+ videoDuration = result.videoDetails.lengthSeconds;
122394
122445
  const infoJson = JSON.stringify({
122395
122446
  title: result.videoDetails.title,
122396
122447
  channel: result.videoDetails.author,
122397
- description: result.videoDetails.description
122448
+ description: result.videoDetails.description,
122449
+ duration: videoDuration
122398
122450
  });
122399
122451
  cachedInfo = infoJson;
122452
+ videoTitle = result.videoDetails.title;
122400
122453
  const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
122401
122454
  const truncatedInfo = JSON.stringify({
122402
122455
  title: result.videoDetails.title,
@@ -122442,10 +122495,12 @@ ${structuredContent}
122442
122495
  debug("Cache missing info.json, re-fetching video details");
122443
122496
  const fallbackOpts = lang ? { lang, videoDetails: true } : { videoDetails: true };
122444
122497
  const fallbackResult = await fetchTranscript(videoId, fallbackOpts);
122498
+ videoDuration = fallbackResult.videoDetails.lengthSeconds;
122445
122499
  cachedInfo = JSON.stringify({
122446
122500
  title: fallbackResult.videoDetails.title,
122447
122501
  channel: fallbackResult.videoDetails.author,
122448
- description: fallbackResult.videoDetails.description
122502
+ description: fallbackResult.videoDetails.description,
122503
+ duration: videoDuration
122449
122504
  });
122450
122505
  await writeCache(dir2, "info.json", cachedInfo);
122451
122506
  chapterValue = formatChaptersAsJson(extractChapters(fallbackResult.videoDetails.description));
@@ -122457,6 +122512,8 @@ ${structuredContent}
122457
122512
  }
122458
122513
  const transcriptText = toText(segments2, !noDecode);
122459
122514
  const cachedInfoObj = JSON.parse(cachedInfo);
122515
+ videoTitle = cachedInfoObj.title;
122516
+ videoDuration = cachedInfoObj.duration ?? 0;
122460
122517
  const truncatedInfo = JSON.stringify({
122461
122518
  title: cachedInfoObj.title,
122462
122519
  channel: cachedInfoObj.channel,
@@ -122485,7 +122542,12 @@ ${transcriptText}`;
122485
122542
  }
122486
122543
  const formatted = noFormat ? md : await formatMd(md);
122487
122544
  if (format3 === "html") {
122488
- const htmlContent = await generateHtml(formatted);
122545
+ const wordCount = md.split(/\s+/).filter(Boolean).length;
122546
+ const htmlContent = await generateHtml(formatted, videoTitle, {
122547
+ videoId,
122548
+ duration: videoDuration,
122549
+ wordCount
122550
+ });
122489
122551
  const htmlPath = join5(dir2, "transcript.html");
122490
122552
  await writeFile3(htmlPath, htmlContent, "utf8");
122491
122553
  debug("HTML written:", htmlPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacone/prosey",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Download YouTube video transcripts from the CLI",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/config.ts CHANGED
@@ -21,47 +21,6 @@ export interface ProseyConfig {
21
21
  };
22
22
  }
23
23
 
24
- const FALLBACK_CONFIG_TOML = `# Default prosey configuration
25
- # Created automatically on first run. Edit as needed.
26
-
27
- # Pager command for transcript and summary output.
28
- # Defaults to "auto": bat -lmd --style plain → glow -p → mdcat -l -p → less
29
- # Set to a custom command (e.g. "less -R") to override.
30
- # Can also be set via the PROSEY_PAGER env var (takes precedence).
31
- pager = "auto"
32
-
33
- # Show hints for missing tools (e.g. markdown highlighter).
34
- # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
35
- hints = true
36
-
37
- [ai]
38
- # Default command for AI operations (summarize, transcribe).
39
- # Can be overridden per-section via the command key below.
40
- command = "opencode run"
41
-
42
- [summarize]
43
- # Prompt sent to the command via stdin.
44
- # Customize this to change how transcripts are summarized.
45
- prompt = """
46
- Write a comprehensive summary of the following transcription.
47
- """
48
-
49
- # Command override for summarize. Uncomment to use a different command
50
- # than the one specified in [ai].
51
- # command = "opencode run"
52
-
53
- [transcribe]
54
- # Prompt sent to the command via stdin.
55
- # Customize this to change how transcripts are formatted as markdown.
56
- prompt = """
57
- Convert this transcript to clean, readable markdown.
58
- """
59
-
60
- # Command override for transcribe. Uncomment to use a different command
61
- # than the one specified in [ai].
62
- # command = "opencode run"
63
- `;
64
-
65
24
  async function readDefaultConfig(): Promise<string> {
66
25
  const paths = [
67
26
  join(dirname(fileURLToPath(import.meta.url)), "default-config.toml"),
@@ -70,7 +29,7 @@ async function readDefaultConfig(): Promise<string> {
70
29
  for (const p of paths) {
71
30
  if (existsSync(p)) return readFile(p, "utf8");
72
31
  }
73
- return FALLBACK_CONFIG_TOML;
32
+ throw new Error("default-config.toml not found");
74
33
  }
75
34
 
76
35
  function configDir(): string {
@@ -29,7 +29,24 @@ command = "OPENCODE_PERMISSION='{\"read\":\"allow\",\"write\":\"deny\",\"edit\":
29
29
  # Customize this to change how transcripts are summarized.
30
30
 
31
31
  prompt = """
32
- Write a comprehensive summary of the following transcription.
32
+ Write a comprehensive summary of the following video transcription:
33
+
34
+ Guidelines:
35
+ - use the INFO section data to correct spellings in the text (e.g. Pi vs Pie)
36
+ - use the INFO title value as the main title (h1 header)
37
+ - use the TIMESTAMPS only to understand the structure, don't output time markers
38
+ - content length: aim to be the same range as the original text or more.
39
+ - section titles should be h2 headers
40
+ - paragraph length soft-limit. 250 characters
41
+
42
+ Content considerations:
43
+
44
+ - findings should be presented in lists or table
45
+ - flows should be presented in ordered lists
46
+ - elaborations are better in prose
47
+ - when content mixes types within a section, lead with prose and break out lists only for discrete items
48
+
49
+ Only output the summary text without adding anything.
33
50
  """
34
51
 
35
52
  # Command override for summarize. Uncomment to use a different command
package/src/format.ts CHANGED
@@ -24,6 +24,13 @@ export function formatDuration(seconds: number): string {
24
24
  return `${m}:${String(s).padStart(2, "0")}`;
25
25
  }
26
26
 
27
+ export function formatReadableDuration(seconds: number): string {
28
+ const h = Math.floor(seconds / 3600);
29
+ const m = Math.round((seconds % 3600) / 60);
30
+ if (h > 0) return `${h}h ${m}min`;
31
+ return `${m}min`;
32
+ }
33
+
27
34
  export function decodeEntities(text: string): string {
28
35
  return text
29
36
  .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
package/src/html.test.ts CHANGED
@@ -5,7 +5,7 @@ describe("generateHtml", () => {
5
5
  test("wraps markdown in HTML with PicoCSS", async () => {
6
6
  const html = await generateHtml("# Hello\n\nWorld", "Test");
7
7
  expect(html).toStartWith("<!DOCTYPE html>");
8
- expect(html).toContain("<title>Test</title>");
8
+ expect(html).toContain("<title>Test - Prosey</title>");
9
9
  expect(html).toContain("<style>");
10
10
  expect(html).toContain("--pico-");
11
11
  expect(html).toContain("<h1>Hello</h1>");
package/src/html.ts CHANGED
@@ -34,7 +34,21 @@ function escapeHtml(text: string): string {
34
34
  .replace(/"/g, "&quot;");
35
35
  }
36
36
 
37
- export async function generateHtml(markdown: string, title?: string): Promise<string> {
37
+ function watchMetaHtml(duration: number, wordCount: number, videoId: string): string {
38
+ const h = Math.floor(duration / 3600);
39
+ const m = Math.round((duration % 3600) / 60);
40
+ const durStr = h > 0 ? `${h} h ${m} min` : `${m} min`;
41
+ const readTime = Math.ceil(wordCount / 200);
42
+ const url = `https://youtube.com/watch?v=${videoId}`;
43
+ const separator = '<span style="opacity: 0.4">&nbsp;&nbsp;|&nbsp;&nbsp;</span>';
44
+ return `<a href="${url}" class="watch-link">${durStr} watch</a> ${separator} ${readTime} min read`;
45
+ }
46
+
47
+ export async function generateHtml(
48
+ markdown: string,
49
+ title?: string,
50
+ watchMeta?: { videoId: string; duration: number; wordCount: number },
51
+ ): Promise<string> {
38
52
  const [css, body] = await Promise.all([getPicoCss(), marked.parse(markdown)]);
39
53
 
40
54
  return `<!DOCTYPE html>
@@ -42,7 +56,7 @@ export async function generateHtml(markdown: string, title?: string): Promise<st
42
56
  <head>
43
57
  <meta charset="UTF-8">
44
58
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
45
- <title>${escapeHtml(title ?? "Prosey")}</title>
59
+ <title>${title ? escapeHtml(title) + " - Prosey" : "Prosey"}</title>
46
60
  <script>(function(){var m=localStorage.getItem('prosey-theme'),t=m||'auto';if(t==='auto')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);if(!m)localStorage.setItem('prosey-theme','auto')})();</script>
47
61
  <link rel="preconnect" href="https://fonts.googleapis.com">
48
62
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -96,14 +110,17 @@ blockquote:last-child { margin-bottom: 0; }
96
110
  #theme-btn:hover, #theme-btn:focus, #theme-btn:active { opacity: 1 !important; }
97
111
  img[alt="Prosey"] { filter: grayscale(100%); }
98
112
  img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { filter: grayscale(0%); }
113
+ .watch-link { color: inherit; text-decoration: none; }
114
+ .watch-link:hover { text-decoration: underline; }
99
115
  </style>
100
116
  </head>
101
117
  <body>
102
- <div style="display:flex;justify-content:space-between;align-items:flex-start;padding:1rem 1rem 0">
103
- <img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top">
104
- <button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s">💡</button>
118
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem 0">
119
+ <img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top;flex-shrink:0">
120
+ <div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId) : ""}</div>
121
+ <button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s;flex-shrink:0">💡</button>
105
122
  </div>
106
- <main style="max-width:720px;margin:0 auto;padding:1rem">
123
+ <main style="max-width:50em;margin:0 auto;padding:1rem">
107
124
  ${body}
108
125
  </main>
109
126
  <script>
package/src/index.ts CHANGED
@@ -443,12 +443,14 @@ try {
443
443
  const dir = cacheDir(videoId, cacheOpts);
444
444
  let segments: TranscriptSegment[] | null = null;
445
445
  let summary: string | null = null;
446
+ let cachedInfo: string | null = null;
446
447
 
447
448
  startTimer();
448
449
 
449
450
  if (!noCache) {
450
451
  const cachedSegments = await readCache(dir, "transcript.json");
451
452
  const cachedSummary = await readCache(dir, "summary.md");
453
+ cachedInfo = await readCache(dir, "info.json");
452
454
  if (cachedSegments && cachedSummary) {
453
455
  info("Transcript cached");
454
456
  debug("Cache hit:", dir);
@@ -461,14 +463,6 @@ try {
461
463
  debug("Cache skipped (--no-cache)");
462
464
  }
463
465
 
464
- if (!segments) {
465
- info("Fetching transcript...");
466
- segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
467
- info(`Transcript: ${segments.length} segments`);
468
- await writeCache(dir, "transcript.json", JSON.stringify(segments));
469
- debug("Cache written: transcript.json");
470
- }
471
-
472
466
  const prompt = resolveSummarizePrompt(config) ?? "";
473
467
  if (!prompt) {
474
468
  console.error(
@@ -476,10 +470,89 @@ try {
476
470
  );
477
471
  exitProcess(1);
478
472
  }
479
- const transcriptText = toText(segments, !noDecode);
473
+
474
+ let structuredContent: string;
475
+ let videoTitle: string | undefined;
476
+ let videoDuration = 0;
477
+
478
+ if (!segments) {
479
+ info("Fetching transcript...");
480
+ const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
481
+ const result = (await fetchTranscript(videoId, opts)) as {
482
+ videoDetails: VideoDetails;
483
+ segments: TranscriptSegment[];
484
+ };
485
+ segments = result.segments;
486
+ videoDuration = result.videoDetails.lengthSeconds;
487
+ const infoJson = JSON.stringify({
488
+ title: result.videoDetails.title,
489
+ channel: result.videoDetails.author,
490
+ description: result.videoDetails.description,
491
+ duration: videoDuration,
492
+ });
493
+ cachedInfo = infoJson;
494
+ const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
495
+ videoTitle = result.videoDetails.title;
496
+ const truncatedInfo = JSON.stringify({
497
+ title: result.videoDetails.title,
498
+ channel: result.videoDetails.author,
499
+ description: result.videoDetails.description.slice(0, 1000),
500
+ });
501
+ const transcriptText = toText(segments, !noDecode);
502
+ structuredContent = `INFO:\n${truncatedInfo}\n\nTEXT:\n${transcriptText}`;
503
+
504
+ if (dryRun) {
505
+ await outputText(`${prompt}\n\n${structuredContent}\n`);
506
+ exitProcess(0);
507
+ }
508
+
509
+ info(`Transcript: ${segments.length} segments`);
510
+ await writeCache(dir, "transcript.json", JSON.stringify(segments));
511
+ await writeCache(dir, "info.json", infoJson);
512
+ await writeCache(dir, "chapters.json", chapterValue);
513
+ debug("Cache written: transcript.json, info.json, chapters.json");
514
+ } else {
515
+ let chapterValue: string;
516
+ if (!cachedInfo) {
517
+ debug("Cache missing info.json, re-fetching video details");
518
+ const fallbackOpts = lang
519
+ ? { lang, videoDetails: true as const }
520
+ : { videoDetails: true as const };
521
+ const fallbackResult = (await fetchTranscript(videoId, fallbackOpts)) as {
522
+ videoDetails: VideoDetails;
523
+ segments: TranscriptSegment[];
524
+ };
525
+ videoDuration = fallbackResult.videoDetails.lengthSeconds;
526
+ cachedInfo = JSON.stringify({
527
+ title: fallbackResult.videoDetails.title,
528
+ channel: fallbackResult.videoDetails.author,
529
+ description: fallbackResult.videoDetails.description,
530
+ duration: videoDuration,
531
+ });
532
+ await writeCache(dir, "info.json", cachedInfo);
533
+ chapterValue = formatChaptersAsJson(
534
+ extractChapters(fallbackResult.videoDetails.description),
535
+ );
536
+ await writeCache(dir, "chapters.json", chapterValue);
537
+ debug("Cache written: info.json, chapters.json");
538
+ } else {
539
+ const cachedChapters = await readCache(dir, "chapters.json");
540
+ chapterValue = cachedChapters ?? "not available";
541
+ }
542
+ const transcriptText = toText(segments, !noDecode);
543
+ const cachedInfoObj = JSON.parse(cachedInfo);
544
+ videoTitle = cachedInfoObj.title;
545
+ videoDuration = cachedInfoObj.duration ?? 0;
546
+ const truncatedInfo = JSON.stringify({
547
+ title: cachedInfoObj.title,
548
+ channel: cachedInfoObj.channel,
549
+ description: cachedInfoObj.description.slice(0, 1000),
550
+ });
551
+ structuredContent = `INFO:\n${truncatedInfo}\n\nTEXT:\n${transcriptText}`;
552
+ }
480
553
 
481
554
  if (dryRun) {
482
- await outputText(`${prompt}\n\n${transcriptText}\n`);
555
+ await outputText(`${prompt}\n\n${structuredContent}\n`);
483
556
  exitProcess(0);
484
557
  }
485
558
 
@@ -488,7 +561,7 @@ try {
488
561
  summary = await summarize({
489
562
  prompt,
490
563
  command: sumCmd,
491
- transcript: transcriptText,
564
+ transcript: structuredContent,
492
565
  cwd: dir,
493
566
  });
494
567
  info("Summary ready");
@@ -498,7 +571,12 @@ try {
498
571
 
499
572
  const formatted = noFormat ? summary : await formatMd(summary);
500
573
  if (format === "html") {
501
- const htmlContent = await generateHtml(formatted);
574
+ const wordCount = summary!.split(/\s+/).filter(Boolean).length;
575
+ const htmlContent = await generateHtml(formatted, videoTitle, {
576
+ videoId,
577
+ duration: videoDuration,
578
+ wordCount,
579
+ });
502
580
  const htmlPath = join(dir, "summary.html");
503
581
  await writeFile(htmlPath, htmlContent, "utf8");
504
582
  debug("HTML written:", htmlPath);
@@ -532,6 +610,8 @@ try {
532
610
  const dir = cacheDir(videoId, cacheOpts);
533
611
  let segments: TranscriptSegment[] | null = null;
534
612
  let md: string | null = null;
613
+ let videoTitle: string | undefined;
614
+ let videoDuration = 0;
535
615
 
536
616
  startTimer();
537
617
 
@@ -569,12 +649,15 @@ try {
569
649
  segments: TranscriptSegment[];
570
650
  };
571
651
  segments = result.segments;
652
+ videoDuration = result.videoDetails.lengthSeconds;
572
653
  const infoJson = JSON.stringify({
573
654
  title: result.videoDetails.title,
574
655
  channel: result.videoDetails.author,
575
656
  description: result.videoDetails.description,
657
+ duration: videoDuration,
576
658
  });
577
659
  cachedInfo = infoJson;
660
+ videoTitle = result.videoDetails.title;
578
661
  const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
579
662
  const truncatedInfo = JSON.stringify({
580
663
  title: result.videoDetails.title,
@@ -618,10 +701,12 @@ try {
618
701
  videoDetails: VideoDetails;
619
702
  segments: TranscriptSegment[];
620
703
  };
704
+ videoDuration = fallbackResult.videoDetails.lengthSeconds;
621
705
  cachedInfo = JSON.stringify({
622
706
  title: fallbackResult.videoDetails.title,
623
707
  channel: fallbackResult.videoDetails.author,
624
708
  description: fallbackResult.videoDetails.description,
709
+ duration: videoDuration,
625
710
  });
626
711
  await writeCache(dir, "info.json", cachedInfo);
627
712
  chapterValue = formatChaptersAsJson(
@@ -635,6 +720,8 @@ try {
635
720
  }
636
721
  const transcriptText = toText(segments, !noDecode);
637
722
  const cachedInfoObj = JSON.parse(cachedInfo);
723
+ videoTitle = cachedInfoObj.title;
724
+ videoDuration = cachedInfoObj.duration ?? 0;
638
725
  const truncatedInfo = JSON.stringify({
639
726
  title: cachedInfoObj.title,
640
727
  channel: cachedInfoObj.channel,
@@ -658,7 +745,12 @@ try {
658
745
 
659
746
  const formatted = noFormat ? md : await formatMd(md);
660
747
  if (format === "html") {
661
- const htmlContent = await generateHtml(formatted);
748
+ const wordCount = md!.split(/\s+/).filter(Boolean).length;
749
+ const htmlContent = await generateHtml(formatted, videoTitle, {
750
+ videoId,
751
+ duration: videoDuration,
752
+ wordCount,
753
+ });
662
754
  const htmlPath = join(dir, "transcript.html");
663
755
  await writeFile(htmlPath, htmlContent, "utf8");
664
756
  debug("HTML written:", htmlPath);