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.
- package/README.md +47 -2
- package/dist/captions.d.ts +12 -0
- package/dist/captions.js +29 -2
- package/dist/hash.d.ts +12 -0
- package/dist/hash.js +139 -0
- package/dist/main.js +27 -3
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +49 -1
- package/dist/media-index.d.ts +67 -0
- package/dist/media-index.js +114 -0
- package/dist/media-local.d.ts +43 -0
- package/dist/media-local.js +88 -0
- package/dist/media-page.d.ts +18 -0
- package/dist/media-page.js +146 -0
- package/dist/media.d.ts +145 -0
- package/dist/media.js +364 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +200 -35
- package/dist/transcribe.js +20 -0
- package/dist/transcript-client.d.ts +19 -0
- package/dist/transcript-client.js +24 -0
- package/dist/trollbox.d.ts +5 -6
- package/dist/trollbox.js +0 -0
- package/package.json +1 -1
- package/src/captions.ts +32 -2
- package/src/hash.ts +139 -0
- package/src/main.ts +27 -3
- package/src/mcp.ts +49 -1
- package/src/media-index.ts +145 -0
- package/src/media-local.ts +117 -0
- package/src/media-page.ts +159 -0
- package/src/media.ts +433 -0
- package/src/server.ts +192 -34
- package/src/transcribe.ts +17 -0
- package/src/transcript-client.ts +34 -0
- package/src/trollbox.ts +0 -0
- package/web/dist/assets/{hls-3VKVEQE3-Dtl-3mpW.js → hls-3VKVEQE3-B4ltbKDh.js} +1 -1
- package/web/dist/assets/index-d7TvpeFZ.js +1 -0
- package/web/dist/assets/index-oyp61Kly.css +1 -0
- package/web/dist/assets/{mpegts-BJC48bFV.js → mpegts-Byy3EkfT.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CUIAB9k3.js → mpegts-LO6RVLD6-C9qqolrW.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-B0h4Nexr.js +0 -1
- 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;
|
|
2245
|
-
*
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/transcribe.js
CHANGED
|
@@ -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 {
|
package/dist/trollbox.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
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
|
+
}
|