@tacone/prosey 0.9.0 → 0.10.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
@@ -101628,6 +101628,7 @@ function mergeOverDefaults(user, defaults) {
101628
101628
  pager: user.pager ?? defaults.pager,
101629
101629
  hints: user.hints ?? defaults.hints,
101630
101630
  format: user.format ?? defaults.format,
101631
+ dataDir: user.dataDir ?? defaults.dataDir,
101631
101632
  ai: user.ai?.command !== undefined ? user.ai : defaults.ai,
101632
101633
  summarize: {
101633
101634
  command: user.summarize?.command ?? defaults.summarize?.command,
@@ -101639,9 +101640,17 @@ function mergeOverDefaults(user, defaults) {
101639
101640
  }
101640
101641
  };
101641
101642
  }
101643
+ function camelizeKeys(obj) {
101644
+ const out = {};
101645
+ for (const [key, value] of Object.entries(obj)) {
101646
+ const camel = key.replace(/_([a-z])/g, (_2, c2) => c2.toUpperCase());
101647
+ out[camel] = value !== null && typeof value === "object" && !Array.isArray(value) ? camelizeKeys(value) : value;
101648
+ }
101649
+ return out;
101650
+ }
101642
101651
  async function parseOrEmpty(text) {
101643
101652
  try {
101644
- return lt(text);
101653
+ return camelizeKeys(lt(text));
101645
101654
  } catch {
101646
101655
  return {};
101647
101656
  }
@@ -101734,6 +101743,7 @@ import { createHash } from "node:crypto";
101734
101743
  import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
101735
101744
  import { existsSync as existsSync2 } from "node:fs";
101736
101745
  import { join as join2 } from "node:path";
101746
+ import { homedir as homedir2 } from "node:os";
101737
101747
  var RE_YOUTUBE2 = /(?:v=|\/|v\/|embed\/|watch\?.*v=|youtu\.be\/|\/v\/|e\/|watch\?.*vi?=|\/embed\/|\/v\/|vi?\/|watch\?.*vi?=|youtu\.be\/|\/vi?\/|\/e\/)([a-zA-Z0-9_-]{11})/i;
101738
101748
  var RE_BARE_ID = /^[a-zA-Z0-9_-]{11}$/;
101739
101749
  function extractVideoId(input) {
@@ -101750,8 +101760,19 @@ function hashOptions(opts) {
101750
101760
  function cacheKey(videoId, opts) {
101751
101761
  return `${videoId}_${hashOptions(opts)}`;
101752
101762
  }
101753
- function cacheDir(videoId, opts) {
101754
- return join2("/tmp", "prosey", cacheKey(videoId, opts));
101763
+ function dataDir(configDataDir) {
101764
+ const env = process.env.PROSEY_DATA_PATH;
101765
+ if (env)
101766
+ return env;
101767
+ if (configDataDir)
101768
+ return configDataDir;
101769
+ const xdg = process.env.XDG_DATA_HOME;
101770
+ if (xdg)
101771
+ return join2(xdg, "prosey");
101772
+ return join2(homedir2(), ".local", "share", "prosey");
101773
+ }
101774
+ function cacheDir(videoId, opts, baseDir) {
101775
+ return join2(baseDir ?? dataDir(), cacheKey(videoId, opts));
101755
101776
  }
101756
101777
  async function readCache(dir, filename) {
101757
101778
  try {
@@ -103317,10 +103338,30 @@ function openInBrowser(htmlPath) {
103317
103338
  setTimeout(() => resolve(), 5000);
103318
103339
  });
103319
103340
  }
103341
+
103342
+ // src/channel-description.ts
103343
+ async function fetchChannelDescription(channelId) {
103344
+ try {
103345
+ const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse`, {
103346
+ method: "POST",
103347
+ headers: { "Content-Type": "application/json" },
103348
+ body: JSON.stringify({
103349
+ context: {
103350
+ client: { clientName: "WEB", clientVersion: "2.20240101.00.00" }
103351
+ },
103352
+ browseId: channelId
103353
+ })
103354
+ });
103355
+ const data = await resp.json();
103356
+ return data?.metadata?.channelMetadataRenderer?.description;
103357
+ } catch {
103358
+ return;
103359
+ }
103360
+ }
103320
103361
  // package.json
103321
103362
  var package_default = {
103322
103363
  name: "@tacone/prosey",
103323
- version: "0.9.0",
103364
+ version: "0.10.0",
103324
103365
  description: "Download YouTube video transcripts from the CLI",
103325
103366
  module: "src/index.ts",
103326
103367
  type: "module",
@@ -121947,25 +121988,6 @@ var debugApis = {
121947
121988
  };
121948
121989
 
121949
121990
  // src/index.ts
121950
- var YT_INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
121951
- async function fetchChannelDescription(channelId) {
121952
- try {
121953
- const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${YT_INNERTUBE_API_KEY}`, {
121954
- method: "POST",
121955
- headers: { "Content-Type": "application/json" },
121956
- body: JSON.stringify({
121957
- context: {
121958
- client: { clientName: "WEB", clientVersion: "2.20240101.00.00" }
121959
- },
121960
- browseId: channelId
121961
- })
121962
- });
121963
- const data = await resp.json();
121964
- return data?.metadata?.channelMetadataRenderer?.description;
121965
- } catch {
121966
- return;
121967
- }
121968
- }
121969
121991
  process.stdout.on("error", (err) => {
121970
121992
  if (err.code === "EPIPE")
121971
121993
  process.exit(0);
@@ -122322,6 +122344,7 @@ if (extractTimestamps) {
122322
122344
  exitProcess(0);
122323
122345
  }
122324
122346
  try {
122347
+ const activeDataDir = dataDir(config.dataDir);
122325
122348
  if (mode === "info") {
122326
122349
  const result = await fetchTranscript(videoId, { videoDetails: true, lang });
122327
122350
  if (outputJson) {
@@ -122338,7 +122361,7 @@ try {
122338
122361
  exitProcess(1);
122339
122362
  }
122340
122363
  const cacheOpts2 = { lang, mode: "summarize", noDecode };
122341
- const dir2 = cacheDir(videoId, cacheOpts2);
122364
+ const dir2 = cacheDir(videoId, cacheOpts2, activeDataDir);
122342
122365
  let segments2 = null;
122343
122366
  let summary = null;
122344
122367
  let cachedInfo = null;
@@ -122513,7 +122536,7 @@ ${structuredContent}
122513
122536
  exitProcess(1);
122514
122537
  }
122515
122538
  const cacheOpts2 = { lang, mode: "transcribe", noDecode };
122516
- const dir2 = cacheDir(videoId, cacheOpts2);
122539
+ const dir2 = cacheDir(videoId, cacheOpts2, activeDataDir);
122517
122540
  let segments2 = null;
122518
122541
  let md = null;
122519
122542
  let videoTitle;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacone/prosey",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Download YouTube video transcripts from the CLI",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/cache.test.ts CHANGED
@@ -2,12 +2,18 @@ import { describe, expect, test, afterEach } from "bun:test";
2
2
  import { rm, readFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { cacheKey, cacheDir, readCache, writeCache, extractVideoId } from "./cache";
5
+ import { cacheKey, cacheDir, readCache, writeCache, extractVideoId, dataDir } from "./cache";
6
6
 
7
- const testDir = "/tmp/prosey/test-cache-spec";
7
+ const testDir = "/tmp/prosey-test/test-cache-spec";
8
+ const ORIGINAL_DATA_PATH = process.env.PROSEY_DATA_PATH;
9
+ const ORIGINAL_XDG_DATA = process.env.XDG_DATA_HOME;
8
10
 
9
11
  afterEach(async () => {
10
12
  await rm(testDir, { recursive: true, force: true });
13
+ if (ORIGINAL_DATA_PATH) process.env.PROSEY_DATA_PATH = ORIGINAL_DATA_PATH;
14
+ else delete process.env.PROSEY_DATA_PATH;
15
+ if (ORIGINAL_XDG_DATA) process.env.XDG_DATA_HOME = ORIGINAL_XDG_DATA;
16
+ else delete process.env.XDG_DATA_HOME;
11
17
  });
12
18
 
13
19
  describe("cacheKey", () => {
@@ -36,11 +42,32 @@ describe("cacheKey", () => {
36
42
  });
37
43
  });
38
44
 
45
+ describe("dataDir", () => {
46
+ test("PROSEY_DATA_PATH takes precedence", () => {
47
+ process.env.PROSEY_DATA_PATH = "/custom/data";
48
+ delete process.env.XDG_DATA_HOME;
49
+ expect(dataDir()).toBe("/custom/data");
50
+ });
51
+
52
+ test("config dataDir argument used when set", () => {
53
+ delete process.env.PROSEY_DATA_PATH;
54
+ delete process.env.XDG_DATA_HOME;
55
+ expect(dataDir("/from/config")).toBe("/from/config");
56
+ });
57
+
58
+ test("falls back to XDG_DATA_HOME", () => {
59
+ delete process.env.PROSEY_DATA_PATH;
60
+ process.env.XDG_DATA_HOME = "/custom/xdg-data";
61
+ expect(dataDir()).toBe("/custom/xdg-data/prosey");
62
+ });
63
+ });
64
+
39
65
  describe("cacheDir", () => {
40
- test("returns path under /tmp/prosey with cache key", () => {
66
+ test("returns path under the data dir with cache key", () => {
67
+ process.env.PROSEY_DATA_PATH = "/custom/data";
41
68
  const key = cacheKey("abc123def45", {});
42
69
  const dir = cacheDir("abc123def45", {});
43
- expect(dir).toBe(`/tmp/prosey/${key}`);
70
+ expect(dir).toBe(`/custom/data/${key}`);
44
71
  });
45
72
  });
46
73
 
package/src/cache.ts CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { homedir } from "node:os";
5
6
 
6
7
  const RE_YOUTUBE =
7
8
  /(?:v=|\/|v\/|embed\/|watch\?.*v=|youtu\.be\/|\/v\/|e\/|watch\?.*vi?=|\/embed\/|\/v\/|vi?\/|watch\?.*vi?=|youtu\.be\/|\/vi?\/|\/e\/)([a-zA-Z0-9_-]{11})/i;
@@ -30,8 +31,17 @@ export function cacheKey(videoId: string, opts: CacheOptions): string {
30
31
  return `${videoId}_${hashOptions(opts)}`;
31
32
  }
32
33
 
33
- export function cacheDir(videoId: string, opts: CacheOptions): string {
34
- return join("/tmp", "prosey", cacheKey(videoId, opts));
34
+ export function dataDir(configDataDir?: string): string {
35
+ const env = process.env.PROSEY_DATA_PATH;
36
+ if (env) return env;
37
+ if (configDataDir) return configDataDir;
38
+ const xdg = process.env.XDG_DATA_HOME;
39
+ if (xdg) return join(xdg, "prosey");
40
+ return join(homedir(), ".local", "share", "prosey");
41
+ }
42
+
43
+ export function cacheDir(videoId: string, opts: CacheOptions, baseDir?: string): string {
44
+ return join(baseDir ?? dataDir(), cacheKey(videoId, opts));
35
45
  }
36
46
 
37
47
  export async function readCache(dir: string, filename: string): Promise<string | null> {
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import { fetchChannelDescription } from "./channel-description";
3
+
4
+ function mockFetchResponse(data: unknown): void {
5
+ globalThis.fetch = mock(() =>
6
+ Promise.resolve({
7
+ ok: true,
8
+ json: () => Promise.resolve(data),
9
+ }),
10
+ ) as unknown as typeof fetch;
11
+ }
12
+
13
+ describe("fetchChannelDescription", () => {
14
+ test("calls the browse endpoint without an API key", async () => {
15
+ let capturedUrl = "";
16
+ let capturedInit: RequestInit | undefined;
17
+ globalThis.fetch = ((url: any, init: any) => {
18
+ capturedUrl = String(url);
19
+ capturedInit = init;
20
+ return Promise.resolve({
21
+ ok: true,
22
+ json: () =>
23
+ Promise.resolve({
24
+ metadata: {
25
+ channelMetadataRenderer: { description: "Channel bio" },
26
+ },
27
+ }),
28
+ });
29
+ }) as unknown as typeof fetch;
30
+
31
+ const desc = await fetchChannelDescription("UC123");
32
+ expect(desc).toBe("Channel bio");
33
+
34
+ expect(capturedUrl).toBe("https://www.youtube.com/youtubei/v1/browse");
35
+ expect(capturedUrl).not.toContain("key=");
36
+ expect(capturedInit!.method).toBe("POST");
37
+ const body = JSON.parse(String(capturedInit!.body));
38
+ expect(body.browseId).toBe("UC123");
39
+ });
40
+
41
+ test("returns undefined when response has no description", async () => {
42
+ mockFetchResponse({ metadata: {} });
43
+ expect(await fetchChannelDescription("UC123")).toBeUndefined();
44
+ });
45
+
46
+ test("returns undefined on network error", async () => {
47
+ globalThis.fetch = mock(() => Promise.reject(new Error("boom"))) as unknown as typeof fetch;
48
+ expect(await fetchChannelDescription("UC123")).toBeUndefined();
49
+ });
50
+ });
@@ -0,0 +1,22 @@
1
+ export interface ChannelDescriptionResult {
2
+ description?: string;
3
+ }
4
+
5
+ export async function fetchChannelDescription(channelId: string): Promise<string | undefined> {
6
+ try {
7
+ const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse`, {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify({
11
+ context: {
12
+ client: { clientName: "WEB", clientVersion: "2.20240101.00.00" },
13
+ },
14
+ browseId: channelId,
15
+ }),
16
+ });
17
+ const data: ChannelDescriptionResult = (await resp.json()) as ChannelDescriptionResult;
18
+ return (data as any)?.metadata?.channelMetadataRenderer?.description;
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
@@ -53,6 +53,7 @@ describe("loadConfig", () => {
53
53
  expect(config.pager).toBe("auto");
54
54
  expect(config.hints).toBe(true);
55
55
  expect(config.format).toBe("html");
56
+ expect(config.dataDir).toBe("");
56
57
  expect(config.ai?.command).toBeString();
57
58
  expect(config.summarize?.prompt).toBeString();
58
59
  expect(existsSync(tmpConfig)).toBe(true);
@@ -125,6 +126,7 @@ describe("mergeOverDefaults", () => {
125
126
  pager: "auto",
126
127
  hints: true,
127
128
  format: "html",
129
+ dataDir: "",
128
130
  ai: { command: "opencode run" },
129
131
  summarize: { prompt: "default summary", command: "opencode run" },
130
132
  transcribe: { prompt: "default transcribe" },
@@ -135,6 +137,7 @@ describe("mergeOverDefaults", () => {
135
137
  expect(result.pager).toBe("auto");
136
138
  expect(result.hints).toBe(true);
137
139
  expect(result.format).toBe("html");
140
+ expect(result.dataDir).toBe("");
138
141
  expect(result.ai?.command).toBe("opencode run");
139
142
  expect(result.summarize?.prompt).toBe("default summary");
140
143
  expect(result.transcribe?.prompt).toBe("default transcribe");
@@ -158,6 +161,12 @@ describe("mergeOverDefaults", () => {
158
161
  expect(result.hints).toBe(true);
159
162
  });
160
163
 
164
+ test("user dataDir overrides default", () => {
165
+ const result = mergeOverDefaults({ dataDir: "/custom/data" }, defaults);
166
+ expect(result.dataDir).toBe("/custom/data");
167
+ expect(result.pager).toBe("auto");
168
+ });
169
+
161
170
  test("user ai.command overrides default", () => {
162
171
  const result = mergeOverDefaults({ ai: { command: "my-cmd" } }, defaults);
163
172
  expect(result.ai?.command).toBe("my-cmd");
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@ export interface ProseyConfig {
9
9
  pager?: string;
10
10
  hints?: boolean;
11
11
  format?: string;
12
+ dataDir?: string;
12
13
  ai?: {
13
14
  command?: string;
14
15
  };
@@ -52,6 +53,7 @@ export function mergeOverDefaults(user: ProseyConfig, defaults: ProseyConfig): P
52
53
  pager: user.pager ?? defaults.pager,
53
54
  hints: user.hints ?? defaults.hints,
54
55
  format: user.format ?? defaults.format,
56
+ dataDir: user.dataDir ?? defaults.dataDir,
55
57
  ai: user.ai?.command !== undefined ? user.ai : defaults.ai,
56
58
  summarize: {
57
59
  command: user.summarize?.command ?? defaults.summarize?.command,
@@ -64,9 +66,21 @@ export function mergeOverDefaults(user: ProseyConfig, defaults: ProseyConfig): P
64
66
  };
65
67
  }
66
68
 
69
+ function camelizeKeys(obj: Record<string, unknown>): Record<string, unknown> {
70
+ const out: Record<string, unknown> = {};
71
+ for (const [key, value] of Object.entries(obj)) {
72
+ const camel = key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
73
+ out[camel] =
74
+ value !== null && typeof value === "object" && !Array.isArray(value)
75
+ ? camelizeKeys(value as Record<string, unknown>)
76
+ : value;
77
+ }
78
+ return out;
79
+ }
80
+
67
81
  async function parseOrEmpty(text: string): Promise<ProseyConfig> {
68
82
  try {
69
- return load(text) as ProseyConfig;
83
+ return camelizeKeys(load(text) as Record<string, unknown>) as ProseyConfig;
70
84
  } catch {
71
85
  return {};
72
86
  }
@@ -15,6 +15,11 @@ pager = "auto"
15
15
  # Can also be set via PROSEY_HINTS env var (yes, no, 1, 0, true, false).
16
16
  hints = true
17
17
 
18
+ # Data directory for cached transcripts, summaries, and generated HTML.
19
+ # Empty (default): $XDG_DATA_HOME/prosey, or ~/.local/share/prosey.
20
+ # Can also be set via the PROSEY_DATA_PATH env var (takes precedence).
21
+ data_dir = ""
22
+
18
23
  [ai]
19
24
 
20
25
  # Default command for AI operations (summarize, transcribe).
package/src/index.ts CHANGED
@@ -18,37 +18,14 @@ import {
18
18
  resolveTranscribeCmd,
19
19
  resolveTranscribePrompt,
20
20
  } from "./config-resolve";
21
- import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
21
+ import { cacheDir, readCache, writeCache, extractVideoId, dataDir } from "./cache";
22
22
  import { extractChapters, formatChaptersAsText, formatChaptersAsJson } from "./extract-chapters";
23
23
  import { generateHtml, openInBrowser } from "./html";
24
+ import { fetchChannelDescription } from "./channel-description";
24
25
  import { checkVersion } from "./version-check";
25
26
  import pkg from "../package.json";
26
27
  import prettier from "prettier";
27
28
 
28
- const YT_INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
29
-
30
- async function fetchChannelDescription(channelId: string): Promise<string | undefined> {
31
- try {
32
- const resp = await fetch(
33
- `https://www.youtube.com/youtubei/v1/browse?key=${YT_INNERTUBE_API_KEY}`,
34
- {
35
- method: "POST",
36
- headers: { "Content-Type": "application/json" },
37
- body: JSON.stringify({
38
- context: {
39
- client: { clientName: "WEB", clientVersion: "2.20240101.00.00" },
40
- },
41
- browseId: channelId,
42
- }),
43
- },
44
- );
45
- const data: any = await resp.json();
46
- return data?.metadata?.channelMetadataRenderer?.description;
47
- } catch {
48
- return undefined;
49
- }
50
- }
51
-
52
29
  process.stdout.on("error", (err: NodeJS.ErrnoException) => {
53
30
  if (err.code === "EPIPE") process.exit(0);
54
31
  });
@@ -454,6 +431,8 @@ if (extractTimestamps) {
454
431
  }
455
432
 
456
433
  try {
434
+ const activeDataDir = dataDir(config.dataDir);
435
+
457
436
  if (mode === "info") {
458
437
  const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
459
438
  if (outputJson) {
@@ -474,7 +453,7 @@ try {
474
453
  }
475
454
 
476
455
  const cacheOpts = { lang, mode: "summarize", noDecode };
477
- const dir = cacheDir(videoId, cacheOpts);
456
+ const dir = cacheDir(videoId, cacheOpts, activeDataDir);
478
457
  let segments: TranscriptSegment[] | null = null;
479
458
  let summary: string | null = null;
480
459
  let cachedInfo: string | null = null;
@@ -660,7 +639,7 @@ try {
660
639
  }
661
640
 
662
641
  const cacheOpts = { lang, mode: "transcribe", noDecode };
663
- const dir = cacheDir(videoId, cacheOpts);
642
+ const dir = cacheDir(videoId, cacheOpts, activeDataDir);
664
643
  let segments: TranscriptSegment[] | null = null;
665
644
  let md: string | null = null;
666
645
  let videoTitle: string | undefined;