nixamp 0.24.1 → 0.25.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.
Files changed (45) hide show
  1. package/README.md +47 -2
  2. package/dist/captions.d.ts +12 -0
  3. package/dist/captions.js +29 -2
  4. package/dist/hash.d.ts +12 -0
  5. package/dist/hash.js +139 -0
  6. package/dist/main.js +27 -3
  7. package/dist/mcp.d.ts +3 -0
  8. package/dist/mcp.js +49 -1
  9. package/dist/media-index.d.ts +67 -0
  10. package/dist/media-index.js +114 -0
  11. package/dist/media-local.d.ts +43 -0
  12. package/dist/media-local.js +88 -0
  13. package/dist/media-page.d.ts +18 -0
  14. package/dist/media-page.js +146 -0
  15. package/dist/media.d.ts +145 -0
  16. package/dist/media.js +364 -0
  17. package/dist/server.d.ts +3 -0
  18. package/dist/server.js +200 -35
  19. package/dist/transcribe.js +20 -0
  20. package/dist/transcript-client.d.ts +19 -0
  21. package/dist/transcript-client.js +24 -0
  22. package/dist/trollbox.d.ts +5 -6
  23. package/dist/trollbox.js +0 -0
  24. package/package.json +1 -1
  25. package/src/captions.ts +32 -2
  26. package/src/hash.ts +139 -0
  27. package/src/main.ts +27 -3
  28. package/src/mcp.ts +49 -1
  29. package/src/media-index.ts +145 -0
  30. package/src/media-local.ts +117 -0
  31. package/src/media-page.ts +159 -0
  32. package/src/media.ts +433 -0
  33. package/src/server.ts +192 -34
  34. package/src/transcribe.ts +17 -0
  35. package/src/transcript-client.ts +34 -0
  36. package/src/trollbox.ts +0 -0
  37. package/web/dist/assets/{hls-3VKVEQE3-Dtl-3mpW.js → hls-3VKVEQE3-B4ltbKDh.js} +1 -1
  38. package/web/dist/assets/index-d7TvpeFZ.js +1 -0
  39. package/web/dist/assets/index-oyp61Kly.css +1 -0
  40. package/web/dist/assets/{mpegts-BJC48bFV.js → mpegts-Byy3EkfT.js} +1 -1
  41. package/web/dist/assets/{mpegts-LO6RVLD6-CUIAB9k3.js → mpegts-LO6RVLD6-C9qqolrW.js} +1 -1
  42. package/web/dist/index.html +2 -2
  43. package/web/dist/sw.js +6 -6
  44. package/web/dist/assets/index-B0h4Nexr.js +0 -1
  45. package/web/dist/assets/index-BTGV3Pi5.css +0 -1
package/dist/media.js ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * One address per file: nixamp.com/hash/<sha256>, and everything nixamp has
3
+ * learned about the bytes behind it.
4
+ *
5
+ * The transcript store keys a file by a quick fingerprint (see
6
+ * transcripts.ts), which is enough to know a file again. This is the other
7
+ * half: the record of the file itself, keyed by the SHA-256 of every byte,
8
+ * the way OpenFile (logicsrc.com/docs/openfile) names a file, so the same
9
+ * bytes on two machines are one record and a directory reading nixamp's
10
+ * listing dedupes on the same id as everybody else's.
11
+ *
12
+ * The record is an OpenFile file object. Its required keys are the id and
13
+ * a name; `size`, `contentType` and `updated` describe the bytes, `holders`
14
+ * says which nixamp servers have carried it, and everything nixamp itself
15
+ * found goes under a `nixamp` key, which is where the specification tells a
16
+ * publisher to put its own: the fingerprint, what ffprobe saw, what nichedb
17
+ * said it was, the transcripts and their languages, and when the file was
18
+ * last checked and is next due.
19
+ *
20
+ * A record is filled in by whoever meets the file: `nixamp hash`, `nixamp
21
+ * transcribe`, and a server captioning it. Each sends what it knows and the
22
+ * record keeps the union. A file that changes on disk gets a new hash and
23
+ * so a new record; the machine holding it notices (media-index.ts) and says
24
+ * which record the old one became.
25
+ */
26
+ import { createHash } from "node:crypto";
27
+ import { createReadStream, statSync } from "node:fs";
28
+ import { basename, extname } from "node:path";
29
+ /** How many places a record remembers carrying it. */
30
+ export const MAX_HOLDERS = 50;
31
+ /** The listing at /.well-known/openfile.json is this long at most. */
32
+ export const LISTING = 100;
33
+ /** A file changed an hour ago is looked at again in a quarter of an hour; one untouched for years, once a month. */
34
+ export const CHECK_MIN_MS = 15 * 60 * 1000;
35
+ export const CHECK_MAX_MS = 30 * 24 * 60 * 60 * 1000;
36
+ const TYPES = {
37
+ mp4: "video/mp4", m4v: "video/mp4", mkv: "video/x-matroska", webm: "video/webm", mov: "video/quicktime", avi: "video/x-msvideo",
38
+ ts: "video/mp2t", m2ts: "video/mp2t", mpg: "video/mpeg", mpeg: "video/mpeg", wmv: "video/x-ms-wmv", flv: "video/x-flv",
39
+ mp3: "audio/mpeg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac", wav: "audio/wav", ogg: "audio/ogg", oga: "audio/ogg",
40
+ opus: "audio/opus", wma: "audio/x-ms-wma", aiff: "audio/aiff", aif: "audio/aiff", alac: "audio/mp4",
41
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp",
42
+ pdf: "application/pdf", txt: "text/plain", md: "text/markdown", srt: "application/x-subrip", vtt: "text/vtt",
43
+ m3u: "audio/x-mpegurl", m3u8: "application/vnd.apple.mpegurl", pls: "audio/x-scpls", zip: "application/zip",
44
+ };
45
+ /** The media type a file's name suggests, or octet-stream. */
46
+ export function contentTypeOf(path) {
47
+ return TYPES[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
48
+ }
49
+ /** A media id as a request names one: bare hex or `sha256:` hex; the hex, or null. */
50
+ export function mediaId(value) {
51
+ if (typeof value !== "string")
52
+ return null;
53
+ const hex = value.trim().toLowerCase().replace(/^sha256:/, "");
54
+ return /^[0-9a-f]{64}$/.test(hex) ? hex : null;
55
+ }
56
+ /** The SHA-256 of every byte of a file, as a stream so a film does not sit in memory. */
57
+ export function contentHash(path) {
58
+ return new Promise((resolve, reject) => {
59
+ const hash = createHash("sha256");
60
+ const stream = createReadStream(path);
61
+ stream.on("data", (chunk) => hash.update(chunk));
62
+ stream.on("error", reject);
63
+ stream.on("end", () => resolve(hash.digest("hex")));
64
+ });
65
+ }
66
+ /** What the filesystem says about a file: its size, when it changed, and its type. */
67
+ export function fileFacts(path) {
68
+ const stat = statSync(path);
69
+ return { name: basename(path), size: stat.size, updated: new Date(stat.mtimeMs).toISOString(), contentType: contentTypeOf(path), mtimeMs: stat.mtimeMs };
70
+ }
71
+ /**
72
+ * How long to leave a file alone before looking at it again: a quarter of
73
+ * the time since it last changed, between a quarter of an hour and a
74
+ * month. A file being edited is looked at often; a film from 2019 is not
75
+ * stat'd every hour of every day for the rest of its life.
76
+ */
77
+ export function checkInterval(mtimeMs, nowMs) {
78
+ const age = Math.max(0, nowMs - mtimeMs);
79
+ return Math.min(CHECK_MAX_MS, Math.max(CHECK_MIN_MS, Math.floor(age / 4)));
80
+ }
81
+ /** The facts ffprobe and nichedb give a server, as the record wants them. */
82
+ export function factsFrom(codecs, enriched) {
83
+ const facts = {};
84
+ if (codecs) {
85
+ if (codecs.video || codecs.audio || codecs.container) {
86
+ facts.codecs = { ...(codecs.video ? { video: codecs.video } : {}), ...(codecs.audio ? { audio: codecs.audio } : {}), ...(codecs.container ? { container: codecs.container } : {}) };
87
+ }
88
+ if (codecs.duration)
89
+ facts.duration = Math.round(codecs.duration * 1000) / 1000;
90
+ if (codecs.width)
91
+ facts.width = codecs.width;
92
+ if (codecs.height)
93
+ facts.height = codecs.height;
94
+ if (codecs.tags && (codecs.tags.title || codecs.tags.artist || codecs.tags.album))
95
+ facts.tags = { ...codecs.tags };
96
+ }
97
+ if (enriched) {
98
+ facts.enrichment = {
99
+ kind: enriched.kind, title: enriched.title, year: enriched.year, image: enriched.image, summary: enriched.summary, page: enriched.page,
100
+ };
101
+ }
102
+ return facts;
103
+ }
104
+ /** Facts as a request hands them in: only the keys this file names, each of the right shape. */
105
+ export function factsFromRequest(value) {
106
+ if (!value || typeof value !== "object")
107
+ return {};
108
+ const raw = value;
109
+ const facts = {};
110
+ const text = (key) => (typeof raw[key] === "string" && raw[key].trim() ? raw[key].trim().slice(0, 500) : undefined);
111
+ const num = (key) => (typeof raw[key] === "number" && Number.isFinite(raw[key]) && raw[key] >= 0 ? raw[key] : undefined);
112
+ const fp = text("fingerprint");
113
+ if (fp && /^file:v1:[0-9a-f]{64}$/.test(fp))
114
+ facts.fingerprint = fp;
115
+ if (num("duration") !== undefined)
116
+ facts.duration = num("duration");
117
+ if (num("width") !== undefined)
118
+ facts.width = num("width");
119
+ if (num("height") !== undefined)
120
+ facts.height = num("height");
121
+ if (raw["codecs"] && typeof raw["codecs"] === "object") {
122
+ const c = raw["codecs"];
123
+ const codecs = {};
124
+ for (const key of ["video", "audio", "container"])
125
+ if (typeof c[key] === "string" && c[key])
126
+ codecs[key] = c[key].slice(0, 80);
127
+ if (Object.keys(codecs).length > 0)
128
+ facts.codecs = codecs;
129
+ }
130
+ if (raw["tags"] && typeof raw["tags"] === "object") {
131
+ const t = raw["tags"];
132
+ const tags = {};
133
+ for (const key of ["title", "artist", "album"])
134
+ if (typeof t[key] === "string" && t[key])
135
+ tags[key] = t[key].slice(0, 300);
136
+ if (Object.keys(tags).length > 0)
137
+ facts.tags = tags;
138
+ }
139
+ if (raw["enrichment"] && typeof raw["enrichment"] === "object") {
140
+ const e = raw["enrichment"];
141
+ const enrichment = {};
142
+ for (const key of ["kind", "title", "image", "summary", "page"])
143
+ if (typeof e[key] === "string")
144
+ enrichment[key] = e[key].slice(0, 2000);
145
+ if (typeof e["year"] === "number")
146
+ enrichment.year = e["year"];
147
+ if (Object.keys(enrichment).length > 0)
148
+ facts.enrichment = enrichment;
149
+ }
150
+ for (const key of ["supersededBy", "supersedes"]) {
151
+ const id = mediaId(raw[key]);
152
+ if (id)
153
+ facts[key] = `sha256:${id}`;
154
+ }
155
+ for (const key of ["checkedAt", "checkAfter"]) {
156
+ const when = text(key);
157
+ if (when && !Number.isNaN(Date.parse(when)))
158
+ facts[key] = new Date(when).toISOString();
159
+ }
160
+ return facts;
161
+ }
162
+ /** A holder as a request hands it in, or null. */
163
+ export function holderFrom(value) {
164
+ if (!value || typeof value !== "object")
165
+ return null;
166
+ const raw = value;
167
+ const url = typeof raw["url"] === "string" ? raw["url"].trim().slice(0, 500) : "";
168
+ if (!/^https?:\/\//.test(url))
169
+ return null;
170
+ const kind = raw["kind"] === "peer" || raw["kind"] === "seeder" ? raw["kind"] : "gateway";
171
+ return {
172
+ kind,
173
+ url,
174
+ seenAt: typeof raw["seenAt"] === "string" && !Number.isNaN(Date.parse(raw["seenAt"])) ? new Date(raw["seenAt"]).toISOString() : new Date().toISOString(),
175
+ ...(typeof raw["channel"] === "string" && raw["channel"] ? { channel: raw["channel"].slice(0, 80) } : {}),
176
+ ...(typeof raw["name"] === "string" && raw["name"] ? { name: raw["name"].slice(0, 200) } : {}),
177
+ };
178
+ }
179
+ /** Holders with one entry per address, the latest sighting kept, newest first. */
180
+ export function mergeHolders(had, added) {
181
+ const byUrl = new Map();
182
+ for (const holder of [...had, ...added]) {
183
+ const key = holder.url.replace(/\/+$/, "");
184
+ const before = byUrl.get(key);
185
+ if (!before || Date.parse(holder.seenAt) >= Date.parse(before.seenAt))
186
+ byUrl.set(key, { ...before, ...holder });
187
+ }
188
+ return [...byUrl.values()].sort((a, b) => Date.parse(b.seenAt) - Date.parse(a.seenAt)).slice(0, MAX_HOLDERS);
189
+ }
190
+ // --- the store ---------------------------------------------------------------
191
+ const SCHEMA = `
192
+ CREATE TABLE IF NOT EXISTS media (
193
+ id TEXT PRIMARY KEY,
194
+ fingerprint TEXT NOT NULL DEFAULT '',
195
+ name TEXT NOT NULL DEFAULT '',
196
+ size BIGINT NOT NULL DEFAULT 0,
197
+ content_type TEXT NOT NULL DEFAULT '',
198
+ updated TIMESTAMPTZ,
199
+ facts JSONB NOT NULL DEFAULT '{}'::jsonb,
200
+ holders JSONB NOT NULL DEFAULT '[]'::jsonb,
201
+ by_account TEXT NOT NULL DEFAULT '',
202
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
203
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
204
+ );
205
+ CREATE INDEX IF NOT EXISTS media_by_fingerprint ON media (fingerprint);
206
+ CREATE INDEX IF NOT EXISTS media_recent ON media (updated_at DESC);
207
+ `;
208
+ export class Media {
209
+ db;
210
+ onEvent;
211
+ ready = null;
212
+ constructor(db, onEvent = () => { }) {
213
+ this.db = db;
214
+ this.onEvent = onEvent;
215
+ }
216
+ async ensure() {
217
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
218
+ await this.ready;
219
+ }
220
+ /**
221
+ * Keep what somebody knows about a file. A new id is a new record; a known
222
+ * one keeps its union: facts merge key by key with the newer winning,
223
+ * holders merge by address, a name or a size given replaces one that was
224
+ * not. The account that first kept it stays its keeper.
225
+ */
226
+ async save(ask) {
227
+ await this.ensure();
228
+ const had = await this.get(ask.id);
229
+ const facts = { ...(had?.facts ?? {}), ...(ask.facts ?? {}) };
230
+ const holders = mergeHolders(had?.holders ?? [], ask.holder ? [ask.holder] : []);
231
+ const { rows } = await this.db.query(`INSERT INTO media (id, fingerprint, name, size, content_type, updated, facts, holders, by_account)
232
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)
233
+ ON CONFLICT (id) DO UPDATE SET
234
+ fingerprint = CASE WHEN EXCLUDED.fingerprint = '' THEN media.fingerprint ELSE EXCLUDED.fingerprint END,
235
+ name = CASE WHEN EXCLUDED.name = '' THEN media.name ELSE EXCLUDED.name END,
236
+ size = CASE WHEN EXCLUDED.size = 0 THEN media.size ELSE EXCLUDED.size END,
237
+ content_type = CASE WHEN EXCLUDED.content_type = '' THEN media.content_type ELSE EXCLUDED.content_type END,
238
+ updated = COALESCE(EXCLUDED.updated, media.updated),
239
+ facts = EXCLUDED.facts,
240
+ holders = EXCLUDED.holders,
241
+ updated_at = now()
242
+ RETURNING *`, [
243
+ ask.id,
244
+ facts.fingerprint ?? "",
245
+ (ask.name ?? "").slice(0, 300),
246
+ ask.size ?? 0,
247
+ ask.contentType ?? "",
248
+ ask.updated ?? null,
249
+ JSON.stringify(facts),
250
+ JSON.stringify(holders),
251
+ ask.by,
252
+ ]);
253
+ const row = rows[0];
254
+ return row ? rowToRecord(row) : null;
255
+ }
256
+ async get(id) {
257
+ await this.ensure();
258
+ const { rows } = await this.db.query("SELECT * FROM media WHERE id = $1 LIMIT 1", [id]);
259
+ const row = rows[0];
260
+ return row ? rowToRecord(row) : null;
261
+ }
262
+ /** The record of the file a transcript fingerprint belongs to, the latest when there are several. */
263
+ async byFingerprint(fingerprint) {
264
+ await this.ensure();
265
+ const { rows } = await this.db.query("SELECT * FROM media WHERE fingerprint = $1 ORDER BY updated_at DESC LIMIT 1", [fingerprint]);
266
+ const row = rows[0];
267
+ return row ? rowToRecord(row) : null;
268
+ }
269
+ /** The latest records, newest first, for the listing. */
270
+ async recent(limit = LISTING) {
271
+ await this.ensure();
272
+ const { rows } = await this.db.query("SELECT * FROM media ORDER BY updated_at DESC LIMIT $1", [Math.max(1, Math.min(LISTING, limit))]);
273
+ return rows.map(rowToRecord);
274
+ }
275
+ /** Never throws: a store that is having a moment costs the record, not the request. */
276
+ async quietly(what, run) {
277
+ try {
278
+ return await run();
279
+ }
280
+ catch (error) {
281
+ this.onEvent(` ${what} did not persist: ${error.message}`);
282
+ return null;
283
+ }
284
+ }
285
+ }
286
+ function iso(value) {
287
+ if (value instanceof Date)
288
+ return value.toISOString();
289
+ if (typeof value === "string" && !Number.isNaN(Date.parse(value)))
290
+ return new Date(value).toISOString();
291
+ return "";
292
+ }
293
+ function parsed(value, fallback) {
294
+ if (typeof value === "string") {
295
+ try {
296
+ return JSON.parse(value);
297
+ }
298
+ catch {
299
+ return fallback;
300
+ }
301
+ }
302
+ return value ?? fallback;
303
+ }
304
+ function rowToRecord(row) {
305
+ const facts = parsed(row["facts"], {});
306
+ return {
307
+ id: String(row["id"] ?? ""),
308
+ name: String(row["name"] ?? ""),
309
+ size: Number(row["size"] ?? 0),
310
+ contentType: String(row["content_type"] ?? ""),
311
+ updated: iso(row["updated"]),
312
+ facts: facts && typeof facts === "object" ? facts : {},
313
+ holders: (parsed(row["holders"], []) ?? []).filter((one) => one && typeof one.url === "string"),
314
+ by: String(row["by_account"] ?? ""),
315
+ createdAt: iso(row["created_at"]),
316
+ updatedAt: iso(row["updated_at"]),
317
+ };
318
+ }
319
+ /**
320
+ * The record as an OpenFile file object: the specification's keys at the
321
+ * top, nixamp's own under `nixamp`. `fetch` is empty on purpose: nixamp.com
322
+ * has no bytes to hand out, only what it knows about them; the holders are
323
+ * where they were last carried.
324
+ */
325
+ export function openFileOf(record, site, transcripts = []) {
326
+ const base = site.replace(/\/+$/, "");
327
+ const facts = { ...record.facts };
328
+ return {
329
+ id: `sha256:${record.id}`,
330
+ name: record.name || record.id,
331
+ url: `${base}/hash/${record.id}`,
332
+ descriptor: `${base}/hash/${record.id}.openfile.json`,
333
+ ...(record.size ? { size: record.size } : {}),
334
+ ...(record.contentType ? { contentType: record.contentType } : {}),
335
+ encryption: "none",
336
+ fetch: [],
337
+ holders: record.holders.map((holder) => ({ kind: holder.kind, url: holder.url, seenAt: holder.seenAt, ...(holder.channel ? { channel: holder.channel } : {}), ...(holder.name ? { name: holder.name } : {}) })),
338
+ ...(record.updated ? { updated: record.updated } : {}),
339
+ nixamp: {
340
+ ...facts,
341
+ transcripts: transcripts.map((one) => ({
342
+ ...one,
343
+ url: `${base}/hash/${record.id}.srt${one.language ? `?language=${one.language}` : ""}`,
344
+ })),
345
+ kept: record.createdAt,
346
+ seen: record.updatedAt,
347
+ },
348
+ };
349
+ }
350
+ /** The publisher's descriptor: nixamp.com and its latest records. */
351
+ export function openFileListing(records, site) {
352
+ const base = site.replace(/\/+$/, "");
353
+ return {
354
+ publisher: {
355
+ name: "nixamp",
356
+ web: base,
357
+ developer: {
358
+ cli: { name: "nixamp", install: { curl: "curl -fsSL https://nixamp.com/install.sh | sh" }, docs: "https://github.com/profullstack/nixamp#readme", repo: "https://github.com/profullstack/nixamp" },
359
+ },
360
+ },
361
+ updated: records[0]?.updatedAt ?? new Date(0).toISOString(),
362
+ files: records.map((record) => openFileOf(record, base)),
363
+ };
364
+ }
package/dist/server.d.ts CHANGED
@@ -35,6 +35,7 @@ import { Captions } from "./captions.ts";
35
35
  import { Translator } from "./translate.ts";
36
36
  import { StoredTranslations } from "./translate-jobs.ts";
37
37
  import { Transcripts } from "./transcripts.ts";
38
+ import { Media } from "./media.ts";
38
39
  import { Profiles, Voices } from "./voices.ts";
39
40
  import { type Codecs } from "./audio.ts";
40
41
  import { type Tools, type Track } from "./audio.ts";
@@ -708,6 +709,8 @@ export interface HandlerOptions {
708
709
  transcripts?: Transcripts;
709
710
  /** Stored transcripts translated, as jobs. */
710
711
  translations?: StoredTranslations;
712
+ /** The record of every file nixamp has met, by its SHA-256: nixamp.com/hash/<id>. */
713
+ records?: Media;
711
714
  /** nixamp.com's address, for the tools reached over /mcp to call. */
712
715
  site?: string;
713
716
  /** Other people's OpenProfiles, for the voice their lines are read in. */