nixamp 0.24.2 → 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.
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. */
package/dist/server.js CHANGED
@@ -71,6 +71,10 @@ import { LANGUAGES, Translator } from "./translate.js";
71
71
  import { StoredTranslations } from "./translate-jobs.js";
72
72
  import { Transcripts, fileFingerprint, formatOf, idFrom, languageCode, linesFrom, mediaOfLive, mediaOfUrl, toSrt, toText, toVtt, transcriptIdOf, wire, } from "./transcripts.js";
73
73
  import { handleMessage as mcpMessage } from "./mcp.js";
74
+ import { Media, factsFromRequest, holderFrom, mediaId, openFileListing, openFileOf } from "./media.js";
75
+ import { mediaPage } from "./media-page.js";
76
+ import { describeFile, refreshChanged } from "./media-local.js";
77
+ import { WATCH_EVERY_MS, remember, watchOnce } from "./media-index.js";
74
78
  import { Outro } from "./outro.js";
75
79
  import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.js";
76
80
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
@@ -2367,6 +2371,141 @@ export function createHandler(engine, options) {
2367
2371
  }
2368
2372
  return;
2369
2373
  }
2374
+ /*
2375
+ * One address per file: /hash/<sha256>, the record of the bytes (see
2376
+ * media.ts). As a page for a person, as an OpenFile file object or plain
2377
+ * JSON for a program, and its transcript as subtitles. Reading is open:
2378
+ * the id is the hash of the bytes, and whoever has it has the file.
2379
+ * Keeping (PUT on the API) is signed in, like the ear.
2380
+ */
2381
+ if (path === "/.well-known/openfile.json" && options.records && options.site) {
2382
+ json(response, 200, openFileListing(await options.records.recent(), options.site));
2383
+ return;
2384
+ }
2385
+ if ((path.startsWith("/hash/") || path === "/api/v1/media" || path.startsWith("/api/v1/media/")) && options.records && options.transcripts && options.site) {
2386
+ const media = options.records;
2387
+ const store = options.transcripts;
2388
+ const site = options.site;
2389
+ const viaApi = path.startsWith("/api/v1/media");
2390
+ const tail = viaApi ? path.slice("/api/v1/media".length).replace(/^\//, "") : path.slice("/hash/".length);
2391
+ const parts = /^([^/.]+)(?:\.(openfile\.json|json|srt|vtt|txt))?\/?$/.exec(tail);
2392
+ if (!parts) {
2393
+ json(response, 404, { error: "a file is /hash/<sha256>, with .json, .openfile.json, .srt, .vtt or .txt after it" });
2394
+ return;
2395
+ }
2396
+ let named = "";
2397
+ try {
2398
+ named = decodeURIComponent(parts[1]);
2399
+ }
2400
+ catch {
2401
+ json(response, 400, { error: "bad id" });
2402
+ return;
2403
+ }
2404
+ const format = parts[2] ?? "";
2405
+ try {
2406
+ if (request.method === "PUT" && viaApi) {
2407
+ const who = await options.accounts?.whoIs(tokenFrom(request.headers));
2408
+ if (!who) {
2409
+ json(response, 401, { error: "sign in to nixamp.com to keep a file's record" });
2410
+ return;
2411
+ }
2412
+ const id = mediaId(named);
2413
+ if (!id) {
2414
+ json(response, 400, { error: "the id is the file's SHA-256, hex" });
2415
+ return;
2416
+ }
2417
+ let body = {};
2418
+ try {
2419
+ body = JSON.parse(await readBody(request, 256 * 1024));
2420
+ }
2421
+ catch {
2422
+ json(response, 400, { error: "bad JSON" });
2423
+ return;
2424
+ }
2425
+ const saved = await media.save({
2426
+ id,
2427
+ ...(typeof body.name === "string" ? { name: body.name.trim() } : {}),
2428
+ ...(typeof body.size === "number" && body.size >= 0 ? { size: Math.floor(body.size) } : {}),
2429
+ ...(typeof body.contentType === "string" ? { contentType: body.contentType.trim().slice(0, 100) } : {}),
2430
+ ...(typeof body.updated === "string" && !Number.isNaN(Date.parse(body.updated)) ? { updated: new Date(body.updated).toISOString() } : {}),
2431
+ facts: factsFromRequest(body.facts),
2432
+ holder: holderFrom(body.holder),
2433
+ by: who.id,
2434
+ });
2435
+ if (!saved) {
2436
+ json(response, 500, { error: "the record was not kept" });
2437
+ return;
2438
+ }
2439
+ json(response, 200, openFileOf(saved, site, saved.facts.fingerprint ? await store.languages(transcriptIdOf(saved.facts.fingerprint)) : []));
2440
+ return;
2441
+ }
2442
+ if (request.method !== "GET") {
2443
+ json(response, 405, { error: "GET the record, or PUT what you know" });
2444
+ return;
2445
+ }
2446
+ // By the SHA-256, by the transcript store's fingerprint, or by a transcript id.
2447
+ let record = null;
2448
+ const hex = mediaId(named);
2449
+ if (hex)
2450
+ record = await media.get(hex);
2451
+ if (!record && /^file:v1:[0-9a-f]{64}$/.test(named))
2452
+ record = await media.byFingerprint(named);
2453
+ if (!record && hex) {
2454
+ const transcript = await store.get(hex);
2455
+ if (transcript && transcript.kind === "file")
2456
+ record = await media.byFingerprint(transcript.media);
2457
+ }
2458
+ if (!record) {
2459
+ json(response, 404, { error: "nixamp.com does not know that file yet. `nixamp hash FILE` tells it." });
2460
+ return;
2461
+ }
2462
+ const transcriptId = record.facts.fingerprint ? transcriptIdOf(record.facts.fingerprint) : null;
2463
+ const languages = transcriptId ? await store.languages(transcriptId) : [];
2464
+ if (format === "srt" || format === "vtt" || format === "txt") {
2465
+ const language = languageCode(url.searchParams.get("language"));
2466
+ if (language === null) {
2467
+ json(response, 400, { error: "language is a two-letter code" });
2468
+ return;
2469
+ }
2470
+ const transcript = transcriptId ? await store.get(transcriptId, language) : null;
2471
+ if (!transcript) {
2472
+ json(response, 404, { error: language ? `not written down in ${language}` : "not written down yet" });
2473
+ return;
2474
+ }
2475
+ const text = format === "srt" ? toSrt(transcript.lines) : format === "vtt" ? toVtt(transcript.lines) : toText(transcript.lines);
2476
+ const name = (record.name.replace(/\.[^.]+$/, "") || record.id.slice(0, 12)).replace(/[^\w.-]+/g, "_").slice(0, 60);
2477
+ response.writeHead(200, {
2478
+ ...CORS,
2479
+ "content-type": `${format === "vtt" ? "text/vtt" : "text/plain"}; charset=utf-8`,
2480
+ "content-length": Buffer.byteLength(text),
2481
+ "cache-control": "public, max-age=300",
2482
+ "content-disposition": `inline; filename="${name}${transcript.language ? `.${transcript.language}` : ""}.${format}"`,
2483
+ });
2484
+ response.end(text);
2485
+ return;
2486
+ }
2487
+ const descriptor = openFileOf(record, site, languages);
2488
+ const accept = String(request.headers["accept"] ?? "");
2489
+ const wantsJson = viaApi || format === "json" || format === "openfile.json" || (accept.includes("application/json") && !accept.includes("text/html"));
2490
+ if (wantsJson) {
2491
+ json(response, 200, descriptor);
2492
+ return;
2493
+ }
2494
+ const original = transcriptId ? await store.get(transcriptId) : null;
2495
+ const forPage = languages
2496
+ .slice()
2497
+ .sort((a, b) => (a.translatedFrom === null ? -1 : 0) - (b.translatedFrom === null ? -1 : 0))
2498
+ .map((one) => (original && one.language === original.language && one.translatedFrom === null ? { ...one, shown: original.lines } : one));
2499
+ html(response, 200, mediaPage(record, site, forPage));
2500
+ }
2501
+ catch (error) {
2502
+ if (error instanceof SpeechError)
2503
+ json(response, error.status, { error: error.message });
2504
+ else
2505
+ throw error;
2506
+ }
2507
+ return;
2508
+ }
2370
2509
  /*
2371
2510
  * Translation: texts in one language, said in another, by a model on
2372
2511
  * this CPU (see translate.ts). Signed in only, like the ear. GET says
@@ -5158,6 +5297,8 @@ export async function serve(argv, version = "0.1.0") {
5158
5297
  // What was heard, kept: where the accounts are, under the media's identity.
5159
5298
  const transcripts = pool ? new Transcripts(pool, (message) => console.log(message)) : undefined;
5160
5299
  const translations = transcripts ? new StoredTranslations(transcripts, translator, (message) => console.log(message)) : undefined;
5300
+ // The record of every file nixamp has met, by its bytes: nixamp.com/hash/<id>.
5301
+ const records = pool ? new Media(pool, (message) => console.log(message)) : undefined;
5161
5302
  // nixamp as an OAuth 2.1 authorization server, and the watch parties a
5162
5303
  // client site bridges through it. Both need the same three things -- a
5163
5304
  // database, accounts, and a site to be the issuer of -- so both appear or
@@ -5363,7 +5504,29 @@ export async function serve(argv, version = "0.1.0") {
5363
5504
  return { ...base, media: mediaOfUrl(pasted), position: info.position };
5364
5505
  }
5365
5506
  try {
5366
- return { ...base, media: fileFingerprint(info.source), position: info.position };
5507
+ const source = info.source;
5508
+ return {
5509
+ ...base,
5510
+ media: fileFingerprint(source),
5511
+ position: info.position,
5512
+ // The file itself, for nixamp.com/hash/<id>: hashed here, once,
5513
+ // with what ffprobe and nichedb said and where it is on the air.
5514
+ describe: async () => {
5515
+ const listen = publishable_ ? shareLink(publishable_.url, listenKey, false) : (options.publicUrl || `http://${options.host}:${options.port}`);
5516
+ const described = await describeFile(source, {
5517
+ tools,
5518
+ enricher: enricher ?? null,
5519
+ holder: { kind: "gateway", url: listen, seenAt: new Date().toISOString(), channel: id, name: streamName },
5520
+ });
5521
+ try {
5522
+ remember(source, { id: described.id, fingerprint: described.fingerprint, size: described.size, mtimeMs: described.mtimeMs });
5523
+ }
5524
+ catch {
5525
+ // The index is a convenience.
5526
+ }
5527
+ return { id: described.id, keep: described.keep };
5528
+ },
5529
+ };
5367
5530
  }
5368
5531
  catch {
5369
5532
  return null;
@@ -5374,6 +5537,27 @@ export async function serve(argv, version = "0.1.0") {
5374
5537
  },
5375
5538
  })
5376
5539
  : undefined;
5540
+ // The files this machine has told nixamp.com about, looked at again on
5541
+ // their own schedule (media-index.ts): a changed file is a new record.
5542
+ if (tools.carries) {
5543
+ const watch = async () => {
5544
+ const signed = readSession();
5545
+ if (!signed)
5546
+ return;
5547
+ const counts = await watchOnce({
5548
+ refresh: (file, before) => refreshChanged(signed, file, before, { tools, enricher: enricher ?? null }),
5549
+ onEvent: (message) => console.log(` ${message}`),
5550
+ }).catch((error) => {
5551
+ console.log(` the media index could not be read: ${error.message}`);
5552
+ return null;
5553
+ });
5554
+ if (counts && counts.changed > 0)
5555
+ console.log(` ${counts.changed} file(s) changed since nixamp.com last heard of them; told it`);
5556
+ };
5557
+ const watching = setInterval(() => void watch(), WATCH_EVERY_MS);
5558
+ watching.unref?.();
5559
+ setTimeout(() => void watch(), 60_000).unref?.();
5560
+ }
5377
5561
  const owner = new Owner({
5378
5562
  ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
5379
5563
  site: session?.site ?? DEFAULT_DIRECTORY,
@@ -5578,6 +5762,7 @@ export async function serve(argv, version = "0.1.0") {
5578
5762
  ...(translator ? { translator } : {}),
5579
5763
  ...(transcripts ? { transcripts } : {}),
5580
5764
  ...(translations ? { translations } : {}),
5765
+ ...(records ? { records } : {}),
5581
5766
  site: nixampSite,
5582
5767
  // Other people's OpenProfiles, read for the voice a line is spoken in,
5583
5768
  // and the voices to speak in: ElevenLabs when the Telnyx account holds
@@ -24,6 +24,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { basename, extname, join } from "node:path";
25
25
  import { detectTools } from "./audio.js";
26
26
  import { isQuiet } from "./captions.js";
27
+ import { Enricher } from "./enrich.js";
28
+ import { describeFile, keepFile } from "./media-local.js";
27
29
  import { readSession } from "./session.js";
28
30
  import { MAX_SECONDS, RATE, isWav } from "./speech.js";
29
31
  import { fetchTranscript, keepLines } from "./transcript-client.js";
@@ -372,6 +374,24 @@ export async function transcribe(argv, deps = {}) {
372
374
  }
373
375
  const id = transcriptIdOf(media);
374
376
  const title = /^https?:\/\//.test(file) ? file : basename(file, extname(file));
377
+ // A file gets its address at nixamp.com/hash/<sha256> too, with what this
378
+ // machine knows about it, so the transcript has somewhere to hang.
379
+ if (!/^https?:\/\//.test(file) && !deps.fingerprint) {
380
+ void (async () => {
381
+ try {
382
+ const tools = detectTools();
383
+ const described = await describeFile(file, { tools, enricher: new Enricher() });
384
+ const refused = await keepFile(signed, file, described, { fetcher });
385
+ if (refused)
386
+ console.error(` The file's record was not kept: ${refused}`);
387
+ else
388
+ console.error(` ${site}/hash/${described.id}`);
389
+ }
390
+ catch {
391
+ // The transcript is the point; the record is a bonus.
392
+ }
393
+ })();
394
+ }
375
395
  const wanted = (flag(argv, "--translate") ?? "").split(",").map((one) => languageCode(one)).filter((one) => typeof one === "string" && one !== "");
376
396
  if (argv.includes("--translate") && wanted.length === 0) {
377
397
  console.error("nixamp: --translate is one or more two-letter codes, such as de,sv.");
@@ -69,6 +69,25 @@ export interface Translated {
69
69
  }
70
70
  /** Texts in another language. `from` may be "" when nixamp.com should tell. */
71
71
  export declare function translateTexts(signed: Signed, texts: string[], from: string, to: string, fetcher?: typeof fetch): Promise<Got<Translated>>;
72
+ /** What a machine tells nixamp.com about a file it has, for the record at /hash/<id>. */
73
+ export interface MediaToKeep {
74
+ name?: string;
75
+ size?: number;
76
+ contentType?: string;
77
+ updated?: string;
78
+ facts?: Record<string, unknown>;
79
+ holder?: {
80
+ kind?: string;
81
+ url: string;
82
+ seenAt?: string;
83
+ channel?: string;
84
+ name?: string;
85
+ };
86
+ }
87
+ /** Keep what is known about a file, by its SHA-256 hex. */
88
+ export declare function keepMedia(signed: Signed, id: string, ask: MediaToKeep, fetcher?: typeof fetch): Promise<Got<Record<string, unknown>>>;
89
+ /** The record of a file, by id, fingerprint or transcript id. Public: no token needed. */
90
+ export declare function fetchMedia(site: string, id: string, fetcher?: typeof fetch): Promise<Got<Record<string, unknown>>>;
72
91
  /** Forget some media's transcripts. Only whoever stored them may. */
73
92
  export declare function forgetTranscript(signed: Signed, id: string, fetcher?: typeof fetch): Promise<Got<{
74
93
  ok: true;