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/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";
@@ -2241,14 +2245,20 @@ export function createHandler(engine, options) {
2241
2245
  * The trollbox: the chat for one live room, keyed by the server and
2242
2246
  * the channel so every viewer of a stream is in the same box whichever
2243
2247
  * page they came from. Reading is open; a line needs a nixamp.com
2244
- * account and is signed with its public handle; taking one down is the
2245
- * author's, or the listing owner's.
2248
+ * account and is signed with its public handle; and once sent it is
2249
+ * public record, so there is no DELETE here for anybody.
2246
2250
  */
2247
2251
  if ((path === "/api/v1/trollbox" || path.startsWith("/api/v1/trollbox/")) && options.trollbox && options.accounts) {
2248
2252
  const trollbox = options.trollbox;
2249
2253
  const child = path.startsWith("/api/v1/trollbox/") ? path.slice("/api/v1/trollbox/".length) : "";
2254
+ // Nothing lives under a line's id: a sent line is public record, so
2255
+ // there is no line to address, whatever the method.
2256
+ if (child !== "") {
2257
+ json(response, 404, { error: "no such endpoint" });
2258
+ return;
2259
+ }
2250
2260
  try {
2251
- if (request.method === "GET" && child === "") {
2261
+ if (request.method === "GET") {
2252
2262
  const where = roomFor(url.searchParams.get("server"), url.searchParams.get("channel"));
2253
2263
  if (!where) {
2254
2264
  json(response, 400, { error: "a room is a server address and a channel" });
@@ -2259,13 +2269,11 @@ export function createHandler(engine, options) {
2259
2269
  json(response, 200, {
2260
2270
  room: where.room,
2261
2271
  you: who ? ((await options.handles?.of(who.id)) || fallbackHandle(who.id)) : "",
2262
- messages: lines.map((one) => ({
2263
- id: one.id, handle: one.handle, body: one.body, createdAt: one.createdAt, mine: who !== null && one.authorId === who.id,
2264
- })),
2272
+ messages: lines.map((one) => ({ id: one.id, handle: one.handle, body: one.body, createdAt: one.createdAt })),
2265
2273
  });
2266
2274
  return;
2267
2275
  }
2268
- if (request.method === "POST" && child === "") {
2276
+ if (request.method === "POST") {
2269
2277
  const who = await options.accounts.whoIs(tokenFrom(request.headers));
2270
2278
  if (who === null) {
2271
2279
  json(response, 401, { error: "sign in to nixamp.com to chat" });
@@ -2287,34 +2295,10 @@ export function createHandler(engine, options) {
2287
2295
  const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2288
2296
  const line = await trollbox.post(where, who.id, handle, body.body);
2289
2297
  readOnThePhone(where, who.id, line.handle, line.body);
2290
- json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
2291
- return;
2292
- }
2293
- if (request.method === "DELETE" && child !== "") {
2294
- const who = await options.accounts.whoIs(tokenFrom(request.headers));
2295
- if (who === null) {
2296
- json(response, 401, { error: "sign in to nixamp.com first" });
2297
- return;
2298
- }
2299
- const where = roomFor(url.searchParams.get("server"), url.searchParams.get("channel"));
2300
- if (!where) {
2301
- json(response, 400, { error: "a room is a server address and a channel" });
2302
- return;
2303
- }
2304
- // The listing owner moderates their own server's rooms.
2305
- const moderator = (options.directory?.list() ?? []).some((listing) => {
2306
- try {
2307
- return listing.ownerId === who.id && new URL(listing.url).origin === where.server;
2308
- }
2309
- catch {
2310
- return false;
2311
- }
2312
- });
2313
- const removed = await trollbox.remove(where.room, child, who.id, moderator);
2314
- json(response, removed ? 200 : 404, removed ? { ok: true } : { error: "not your line, or already gone" });
2298
+ json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt } });
2315
2299
  return;
2316
2300
  }
2317
- json(response, 405, { error: "GET, POST or DELETE" });
2301
+ json(response, 405, { error: "GET or POST" });
2318
2302
  }
2319
2303
  catch (error) {
2320
2304
  if (error instanceof TrollboxError)
@@ -2373,7 +2357,7 @@ export function createHandler(engine, options) {
2373
2357
  readOnThePhone(where, who.id, line.handle, line.body);
2374
2358
  json(response, 201, {
2375
2359
  ...said,
2376
- message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
2360
+ message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt },
2377
2361
  });
2378
2362
  return;
2379
2363
  }
@@ -2387,6 +2371,141 @@ export function createHandler(engine, options) {
2387
2371
  }
2388
2372
  return;
2389
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
+ }
2390
2509
  /*
2391
2510
  * Translation: texts in one language, said in another, by a model on
2392
2511
  * this CPU (see translate.ts). Signed in only, like the ear. GET says
@@ -5178,6 +5297,8 @@ export async function serve(argv, version = "0.1.0") {
5178
5297
  // What was heard, kept: where the accounts are, under the media's identity.
5179
5298
  const transcripts = pool ? new Transcripts(pool, (message) => console.log(message)) : undefined;
5180
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;
5181
5302
  // nixamp as an OAuth 2.1 authorization server, and the watch parties a
5182
5303
  // client site bridges through it. Both need the same three things -- a
5183
5304
  // database, accounts, and a site to be the issuer of -- so both appear or
@@ -5383,7 +5504,29 @@ export async function serve(argv, version = "0.1.0") {
5383
5504
  return { ...base, media: mediaOfUrl(pasted), position: info.position };
5384
5505
  }
5385
5506
  try {
5386
- 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
+ };
5387
5530
  }
5388
5531
  catch {
5389
5532
  return null;
@@ -5394,6 +5537,27 @@ export async function serve(argv, version = "0.1.0") {
5394
5537
  },
5395
5538
  })
5396
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
+ }
5397
5561
  const owner = new Owner({
5398
5562
  ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
5399
5563
  site: session?.site ?? DEFAULT_DIRECTORY,
@@ -5598,6 +5762,7 @@ export async function serve(argv, version = "0.1.0") {
5598
5762
  ...(translator ? { translator } : {}),
5599
5763
  ...(transcripts ? { transcripts } : {}),
5600
5764
  ...(translations ? { translations } : {}),
5765
+ ...(records ? { records } : {}),
5601
5766
  site: nixampSite,
5602
5767
  // Other people's OpenProfiles, read for the voice a line is spoken in,
5603
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;
@@ -51,6 +51,30 @@ export async function translateTexts(signed, texts, from, to, fetcher = fetch) {
51
51
  return unreachable(signed.site, error);
52
52
  }
53
53
  }
54
+ /** Keep what is known about a file, by its SHA-256 hex. */
55
+ export async function keepMedia(signed, id, ask, fetcher = fetch) {
56
+ try {
57
+ const response = await fetcher(`${base(signed.site)}/api/v1/media/${encodeURIComponent(id)}`, {
58
+ method: "PUT",
59
+ headers: { authorization: `Bearer ${signed.token}`, "content-type": "application/json" },
60
+ body: JSON.stringify(ask),
61
+ });
62
+ return await asJson(response);
63
+ }
64
+ catch (error) {
65
+ return unreachable(signed.site, error);
66
+ }
67
+ }
68
+ /** The record of a file, by id, fingerprint or transcript id. Public: no token needed. */
69
+ export async function fetchMedia(site, id, fetcher = fetch) {
70
+ try {
71
+ const response = await fetcher(`${base(site)}/api/v1/media/${encodeURIComponent(id)}`, { headers: { accept: "application/json" } });
72
+ return await asJson(response);
73
+ }
74
+ catch (error) {
75
+ return unreachable(site, error);
76
+ }
77
+ }
54
78
  /** Forget some media's transcripts. Only whoever stored them may. */
55
79
  export async function forgetTranscript(signed, id, fetcher = fetch) {
56
80
  try {
@@ -40,14 +40,13 @@ export declare class Trollbox {
40
40
  private readonly spoke;
41
41
  constructor(db: Queryable, now?: () => number);
42
42
  private ensure;
43
- /** The room's lines, oldest first, after a moment when given. */
43
+ /**
44
+ * The room's lines, oldest first, after a moment when given. Nothing
45
+ * writes deleted_at any more; the filter only keeps the lines taken down
46
+ * before a sent line became public record out of view.
47
+ */
44
48
  messages(room: string, after?: string, limit?: number): Promise<TrollboxMessage[]>;
45
49
  /** Whether this account may speak now, and the bookkeeping if so. */
46
50
  private allow;
47
51
  post(where: TrollboxRoom, authorId: string, handle: string, body: unknown): Promise<TrollboxMessage>;
48
- /**
49
- * A line taken down: by its author, or by a moderator (whoever owns the
50
- * server's listing). Anybody else's ask removes nothing and says so.
51
- */
52
- remove(room: string, id: string, byAccount: string, moderator: boolean): Promise<boolean>;
53
52
  }
package/dist/trollbox.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/captions.ts CHANGED
@@ -33,7 +33,7 @@
33
33
  import { spawn } from "node:child_process";
34
34
  import type { Listener } from "./channels.ts";
35
35
  import { RATE } from "./speech.ts";
36
- import { fetchTranscript, keepLines, translateTexts } from "./transcript-client.ts";
36
+ import { fetchTranscript, keepLines, keepMedia, translateTexts, type MediaToKeep } from "./transcript-client.ts";
37
37
  import { covered, lineAt, transcriptIdOf, type TranscriptLine } from "./transcripts.ts";
38
38
 
39
39
  export interface CaptionLine {
@@ -69,6 +69,12 @@ export interface ChannelMedia {
69
69
  startedAt: number;
70
70
  /** How many seconds behind the live edge a new listener's sound starts. */
71
71
  backlog: number;
72
+ /**
73
+ * The file itself, described for the record at nixamp.com/hash/<id>:
74
+ * its SHA-256 and what is known about it. Absent for anything that is
75
+ * not a file on this machine. Asked once when the captioner starts.
76
+ */
77
+ describe?: () => Promise<{ id: string; keep: MediaToKeep } | null>;
72
78
  }
73
79
 
74
80
  export interface CaptionsOptions {
@@ -220,6 +226,8 @@ export interface CaptionStatus {
220
226
  known: number;
221
227
  /** The languages lines are being given in besides the original. */
222
228
  languages: string[];
229
+ /** The file's SHA-256, once it has been described for nixamp.com/hash/<id>; "" until then, or for a live. */
230
+ hash: string;
223
231
  }
224
232
 
225
233
  class Captioner {
@@ -232,6 +240,8 @@ class Captioner {
232
240
  error = "";
233
241
  /** What the ear says the sound is in, or the store said it was; "" until one of them has. */
234
242
  language = "";
243
+ /** The file's SHA-256, once described. */
244
+ hash = "";
235
245
  private model = "";
236
246
  private decoder: Decoder | null = null;
237
247
  private detach: (() => void) | null = null;
@@ -288,9 +298,28 @@ class Captioner {
288
298
  return false;
289
299
  }
290
300
  void this.consult("");
301
+ void this.record();
291
302
  return true;
292
303
  }
293
304
 
305
+ /** Tell nixamp.com about the file itself, once: its hash and what this server knows of it. */
306
+ private async record(): Promise<void> {
307
+ const describe = this.media?.describe;
308
+ if (!describe) return;
309
+ const session = this.options.session();
310
+ if (session === null) return;
311
+ try {
312
+ const described = await describe();
313
+ if (!described || this.stopped) return;
314
+ this.hash = described.id;
315
+ const got = await keepMedia(session, described.id, described.keep, this.options.fetcher ?? fetch);
316
+ if (!got.ok) this.complain(`the record was not kept: ${got.error}`);
317
+ else this.options.onEvent?.(`captions for "${this.id}": nixamp.com/hash/${described.id.slice(0, 12)}… knows this file`);
318
+ } catch (error) {
319
+ this.complain(`could not describe the file: ${(error as Error).message}`);
320
+ }
321
+ }
322
+
294
323
  /** Ask the store what it already knows of this media in a language, once. */
295
324
  private async consult(language: string): Promise<void> {
296
325
  if (!this.transcriptId || this.asked.has(language)) return;
@@ -512,6 +541,7 @@ class Captioner {
512
541
  language: this.language,
513
542
  known: this.known.get("")?.length ?? 0,
514
543
  languages: [...this.linesBy.keys()],
544
+ hash: this.hash,
515
545
  };
516
546
  }
517
547
 
@@ -572,7 +602,7 @@ export class Captions {
572
602
  /** Whether a channel is being captioned, and what last went wrong if the lines are not coming. */
573
603
  status(id: string): CaptionStatus {
574
604
  const captioner = this.running.get(id);
575
- return captioner ? captioner.status() : { on: false, lines: 0, error: "", language: "", known: 0, languages: [] };
605
+ return captioner ? captioner.status() : { on: false, lines: 0, error: "", language: "", known: 0, languages: [], hash: "" };
576
606
  }
577
607
 
578
608
  stopAll(): void {
package/src/hash.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `nixamp hash` -- one address for a file: nixamp.com/hash/<sha256>.
3
+ *
4
+ * Hashes every byte, says the address, and unless told not to tells
5
+ * nixamp.com what this machine knows about the file (media-local.ts) so
6
+ * the address answers with it: what is inside, what nichedb says it is,
7
+ * and, once `nixamp transcribe` or a server has been at it, the
8
+ * transcript in every language. The file goes into this machine's index
9
+ * too, so the daemon looks at it again on a schedule and a changed file
10
+ * gets a new address that points back at this one.
11
+ */
12
+ import { detectTools } from "./audio.ts";
13
+ import { Enricher } from "./enrich.ts";
14
+ import { describeFile, keepFile, type Described } from "./media-local.ts";
15
+ import { readSession, type Session } from "./session.ts";
16
+ import { fetchMedia } from "./transcript-client.ts";
17
+
18
+ const HELP = `nixamp hash — one address for a file.
19
+
20
+ nixamp hash FILE [FILE...] sha256, and nixamp.com/hash/<sha256>, kept there with what is known
21
+ nixamp hash FILE --no-keep the hash and the address only; nothing sent
22
+ nixamp hash FILE --json the record as nixamp.com keeps it
23
+ nixamp hash --get ID what nixamp.com knows about a file, by hash or fingerprint
24
+
25
+ The address is the SHA-256 of the file's bytes, the way OpenFile
26
+ (logicsrc.com/docs/openfile) names a file, so the same file on two
27
+ machines is one page. What is kept: the size, the type, when the file last
28
+ changed, what ffprobe found inside, what nichedb.dev says it is, where it
29
+ has been carried, and its transcripts. Keeping needs a sign-in
30
+ (\`nixamp login\`); the hash needs nothing.
31
+ `;
32
+
33
+ export interface HashDeps {
34
+ fetcher?: typeof fetch;
35
+ session?: Pick<Session, "site" | "token"> | null;
36
+ describe?: (path: string) => Promise<Described>;
37
+ index?: string;
38
+ now?: () => number;
39
+ }
40
+
41
+ function flag(argv: string[], name: string): string | undefined {
42
+ const at = argv.indexOf(name);
43
+ return at === -1 ? undefined : argv[at + 1];
44
+ }
45
+
46
+ /** The site an address is printed for: the session's, or nixamp.com. */
47
+ function siteOf(session: Pick<Session, "site"> | null): string {
48
+ return (session?.site ?? "https://nixamp.com").replace(/\/+$/, "");
49
+ }
50
+
51
+ export async function hash(argv: string[], deps: HashDeps = {}): Promise<number> {
52
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
53
+ console.log(HELP);
54
+ return argv.length === 0 ? 64 : 0;
55
+ }
56
+ const session = deps.session === undefined ? readSession() : deps.session;
57
+ const fetcher = deps.fetcher ?? fetch;
58
+ const asJson = argv.includes("--json");
59
+ const site = siteOf(session);
60
+
61
+ const get = flag(argv, "--get");
62
+ if (argv.includes("--get")) {
63
+ if (!get) {
64
+ console.error("nixamp: --get needs the file's hash, or its fingerprint.");
65
+ return 64;
66
+ }
67
+ const got = await fetchMedia(site, get, fetcher);
68
+ if (!got.ok) {
69
+ console.error(`nixamp: ${got.error}`);
70
+ return 1;
71
+ }
72
+ if (asJson) console.log(JSON.stringify(got.body, null, 2));
73
+ else console.log(describeRecord(got.body));
74
+ return 0;
75
+ }
76
+
77
+ const files = argv.filter((one, at) => !one.startsWith("-") && !(at > 0 && argv[at - 1] === "--get"));
78
+ if (files.length === 0) {
79
+ console.error("nixamp: which file? `nixamp hash film.mkv`.");
80
+ return 64;
81
+ }
82
+ const keep = !argv.includes("--no-keep");
83
+ if (keep && session === null) console.error("nixamp: not signed in, so nothing is kept; `nixamp login` to keep the record. The hash still is:");
84
+ let tools: ReturnType<typeof detectTools> | null = null;
85
+ let enricher: Enricher | null = null;
86
+ const describe = deps.describe ?? (async (path: string) => {
87
+ tools ??= detectTools();
88
+ enricher ??= new Enricher();
89
+ return describeFile(path, { tools, enricher, now: deps.now });
90
+ });
91
+ let failed = 0;
92
+ const records: unknown[] = [];
93
+ for (const file of files) {
94
+ let described: Described;
95
+ try {
96
+ described = await describe(file);
97
+ } catch (error) {
98
+ console.error(`nixamp: ${file}: ${(error as Error).message}`);
99
+ failed += 1;
100
+ continue;
101
+ }
102
+ let record: Record<string, unknown> | null = null;
103
+ if (keep && session) {
104
+ const refused = await keepFile({ site: session.site, token: session.token }, file, described, { fetcher, ...(deps.index ? { index: deps.index } : {}), ...(deps.now ? { now: deps.now } : {}) });
105
+ if (refused) {
106
+ console.error(`nixamp: ${file}: not kept: ${refused}`);
107
+ failed += 1;
108
+ } else if (asJson) {
109
+ const got = await fetchMedia(site, described.id, fetcher);
110
+ record = got.ok ? got.body : null;
111
+ }
112
+ }
113
+ if (asJson) {
114
+ records.push(record ?? { id: `sha256:${described.id}`, name: described.keep.name, url: `${site}/hash/${described.id}`, nixamp: described.keep.facts });
115
+ } else {
116
+ console.log(`sha256:${described.id} ${described.keep.name ?? file}\n ${site}/hash/${described.id}${keep && session ? "" : " (not kept)"}`);
117
+ }
118
+ }
119
+ if (asJson) console.log(JSON.stringify(records.length === 1 ? records[0] : records, null, 2));
120
+ return failed > 0 ? 1 : 0;
121
+ }
122
+
123
+ /** A record as the terminal prints it. */
124
+ export function describeRecord(record: Record<string, unknown>): string {
125
+ const nixamp = (record["nixamp"] ?? {}) as Record<string, unknown>;
126
+ const lines = [`${record["name"] ?? ""} ${record["id"] ?? ""}`, ` ${record["url"] ?? ""}`];
127
+ if (typeof record["size"] === "number") lines.push(` size: ${record["size"]} bytes${record["contentType"] ? `, ${record["contentType"]}` : ""}`);
128
+ if (typeof nixamp["duration"] === "number") lines.push(` length: ${Math.round(nixamp["duration"] as number)} s`);
129
+ const enrichment = nixamp["enrichment"] as { title?: string; year?: number | null } | undefined;
130
+ if (enrichment?.title) lines.push(` nichedb: ${enrichment.title}${enrichment.year ? ` (${enrichment.year})` : ""}`);
131
+ if (record["updated"]) lines.push(` file changed: ${record["updated"]}`);
132
+ if (nixamp["checkAfter"]) lines.push(` next check: ${nixamp["checkAfter"]}`);
133
+ const holders = (record["holders"] ?? []) as { url: string; channel?: string; seenAt: string }[];
134
+ for (const holder of holders) lines.push(` carried by ${holder.url}${holder.channel ? ` as ${holder.channel}` : ""} at ${holder.seenAt}`);
135
+ const transcripts = (nixamp["transcripts"] ?? []) as { language: string; translatedFrom: string | null; lines: number; complete: boolean; url: string }[];
136
+ for (const one of transcripts) lines.push(` transcript ${one.language || "as spoken"}${one.translatedFrom ? ` (from ${one.translatedFrom})` : ""}: ${one.lines} lines${one.complete ? "" : " so far"} ${one.url}`);
137
+ if (nixamp["supersededBy"]) lines.push(` became: ${nixamp["supersededBy"]}`);
138
+ return lines.join("\n");
139
+ }