@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 +59 -19
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/doctor.js +28 -1
- package/src/extract-video-metadata.js +48 -21
- package/src/generate-markdown.js +54 -12
- package/src/organize-files.js +3 -1
- package/src/pricing.js +177 -0
- package/src/vtt.js +3 -1
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
153
|
-
and specific to OpenCode.
|
|
154
|
-
|
|
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
|
|
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
|
|
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. `
|
|
303
|
-
|
|
304
|
-
`
|
|
305
|
-
|
|
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.
|
|
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
|
|
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}
|
|
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(
|
|
85
|
-
const
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
113
|
-
.
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 }));
|
package/src/generate-markdown.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
{
|
|
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(
|
|
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(
|
package/src/organize-files.js
CHANGED
|
@@ -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 =
|
|
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,
|