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/src/main.ts
CHANGED
|
@@ -87,6 +87,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
87
87
|
nixamp transcribe FILE [--translate sv] a recording or a whole film written down, kept, and in other languages
|
|
88
88
|
nixamp transcript --channel ID [--follow] what a channel is saying, as it says it; --kept for what nixamp.com keeps
|
|
89
89
|
nixamp translate --to sv TEXT say it in another language
|
|
90
|
+
nixamp hash FILE one address for a file: nixamp.com/hash/<sha256>, with all that is known
|
|
90
91
|
nixamp profile [--handle H] [--voice V] [--profile URL] who the rooms know you as
|
|
91
92
|
nixamp voices the voices a line is read in on the phone
|
|
92
93
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
@@ -274,9 +275,10 @@ posted straight into a trollbox), say a line in a room (trollbox_say), read
|
|
|
274
275
|
a room (trollbox_read), read what a channel is saying (transcript_read, in
|
|
275
276
|
any language). The transcript tools: a kept transcript by its id or its
|
|
276
277
|
media (transcript_get), what this account has had written down
|
|
277
|
-
(transcripts_list), and text in another language (translate_text).
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
(transcripts_list), and text in another language (translate_text). The file
|
|
279
|
+
tools: one address for a file (media_hash) and what nixamp.com knows about
|
|
280
|
+
one (media_get). And who you are in the rooms and how you sound on the
|
|
281
|
+
phone (profile_get, profile_set, voices_list). It acts as whoever this machine is signed in as,
|
|
280
282
|
so \`nixamp login\` (or NIXAMP_TOKEN) comes first.
|
|
281
283
|
|
|
282
284
|
Point an MCP client at it as a stdio server running \`nixamp mcp\`. The same
|
|
@@ -309,6 +311,23 @@ with an open-source model (OPUS-MT), and kept beside the original.
|
|
|
309
311
|
|
|
310
312
|
The same ear is behind the microphone button in every nixamp.com trollbox,
|
|
311
313
|
and behind the transcribe_audio tool of \`nixamp mcp\`.
|
|
314
|
+
`,
|
|
315
|
+
hash: `nixamp hash — one address for a file.
|
|
316
|
+
|
|
317
|
+
nixamp hash FILE [FILE...] sha256, and nixamp.com/hash/<sha256>, kept there with what is known
|
|
318
|
+
nixamp hash FILE --no-keep the hash and the address only; nothing sent
|
|
319
|
+
nixamp hash FILE --json the record as nixamp.com keeps it
|
|
320
|
+
nixamp hash --get ID what nixamp.com knows about a file, by hash or fingerprint
|
|
321
|
+
|
|
322
|
+
The address is the SHA-256 of the file's bytes, the way OpenFile
|
|
323
|
+
(logicsrc.com/docs/openfile) names a file, so the same file on two machines
|
|
324
|
+
is one page. The page, and /hash/<id>.openfile.json beside it, carry the
|
|
325
|
+
size, the type, when the file last changed, what ffprobe found inside, what
|
|
326
|
+
nichedb.dev says it is, which servers have carried it, and its transcripts
|
|
327
|
+
in every language, as .srt, .vtt or text. \`nixamp transcribe\` and a server
|
|
328
|
+
captioning a file keep the same record. The daemon looks at a kept file
|
|
329
|
+
again on a schedule set by how recently it changed, and a changed file
|
|
330
|
+
gets a new address that points back at the old one.
|
|
312
331
|
`,
|
|
313
332
|
translate: `nixamp translate — say it in another language.
|
|
314
333
|
|
|
@@ -588,6 +607,11 @@ export async function main(): Promise<void> {
|
|
|
588
607
|
process.exitCode = await translate(rest);
|
|
589
608
|
return;
|
|
590
609
|
}
|
|
610
|
+
if (first === "hash") {
|
|
611
|
+
const { hash } = await import("./hash.ts");
|
|
612
|
+
process.exitCode = await hash(rest);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
591
615
|
if (first === "token" || first === "tokens") {
|
|
592
616
|
const { tokens } = await import("./session.ts");
|
|
593
617
|
process.exitCode = await tokens(rest);
|
package/src/mcp.ts
CHANGED
|
@@ -23,7 +23,11 @@ import { clock, type PartyRow } from "./party.ts";
|
|
|
23
23
|
import { readSession } from "./session.ts";
|
|
24
24
|
import { askToHear, awaitTranscript, hearWhole, rendered, wavOf, type Window } from "./transcribe.ts";
|
|
25
25
|
import { readTranscript } from "./transcript.ts";
|
|
26
|
-
import { fetchTranscript, listTranscripts, translateTexts, type StoredTranscript } from "./transcript-client.ts";
|
|
26
|
+
import { fetchMedia, fetchTranscript, listTranscripts, translateTexts, type StoredTranscript } from "./transcript-client.ts";
|
|
27
|
+
import { detectTools } from "./audio.ts";
|
|
28
|
+
import { Enricher } from "./enrich.ts";
|
|
29
|
+
import { describeRecord } from "./hash.ts";
|
|
30
|
+
import { describeFile, keepFile, type Described } from "./media-local.ts";
|
|
27
31
|
import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.ts";
|
|
28
32
|
import { personaLines, readPersona, readVoices, writePersona } from "./profile.ts";
|
|
29
33
|
|
|
@@ -134,6 +138,24 @@ export const TOOLS: ToolDefinition[] = [
|
|
|
134
138
|
required: ["media"],
|
|
135
139
|
},
|
|
136
140
|
},
|
|
141
|
+
{
|
|
142
|
+
name: "media_hash",
|
|
143
|
+
description:
|
|
144
|
+
"One address for a file on this machine: its SHA-256 and nixamp.com/hash/<sha256>, kept there with what this machine knows (size, type, when it changed, what is inside, what nichedb says it is). The same file anywhere is the same page.",
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: {
|
|
148
|
+
path: { ...STRING, description: "The file's path on this machine." },
|
|
149
|
+
keep: { type: "boolean", description: "Tell nixamp.com about it (default true)." },
|
|
150
|
+
},
|
|
151
|
+
required: ["path"],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "media_get",
|
|
156
|
+
description: "What nixamp.com knows about a file, by its SHA-256 (with or without sha256:), its transcript fingerprint, or a transcript id: the OpenFile record with nixamp's facts, holders and transcripts.",
|
|
157
|
+
inputSchema: { type: "object", properties: { id: { ...STRING, description: "The hash, fingerprint or transcript id." } }, required: ["id"] },
|
|
158
|
+
},
|
|
137
159
|
{
|
|
138
160
|
name: "transcripts_list",
|
|
139
161
|
description: "What this account has had written down on nixamp.com: each transcript's id, what it is, its language, how many lines, and when.",
|
|
@@ -245,6 +267,8 @@ export interface McpOptions {
|
|
|
245
267
|
sleep?: (ms: number) => Promise<void>;
|
|
246
268
|
/** How many times a translation is asked about before answering with its progress. */
|
|
247
269
|
polls?: number;
|
|
270
|
+
/** How a file is described for its record; the tests hand in a fake. */
|
|
271
|
+
describe?: (path: string) => Promise<Described>;
|
|
248
272
|
say?: (line: string) => void;
|
|
249
273
|
}
|
|
250
274
|
|
|
@@ -439,6 +463,30 @@ export async function callTool(name: string, args: Record<string, unknown>, opti
|
|
|
439
463
|
return text(transcriptText(got.body, typeof args["format"] === "string" ? args["format"] : "lines"));
|
|
440
464
|
}
|
|
441
465
|
|
|
466
|
+
if (name === "media_hash") {
|
|
467
|
+
const path = typeof args["path"] === "string" ? args["path"] : "";
|
|
468
|
+
if (!path) return failed("Which file? Pass its path.");
|
|
469
|
+
let described: Described;
|
|
470
|
+
try {
|
|
471
|
+
described = await (options.describe ?? ((file: string) => describeFile(file, { tools: detectTools(), enricher: new Enricher() })))(path);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
return failed(`${path}: ${(error as Error).message}`);
|
|
474
|
+
}
|
|
475
|
+
if (args["keep"] !== false) {
|
|
476
|
+
const refused = await keepFile({ site, token: session.token }, path, described, { fetcher: send });
|
|
477
|
+
if (refused) return failed(`hashed, but not kept: ${refused}`);
|
|
478
|
+
}
|
|
479
|
+
return text(`sha256:${described.id}\n${site}/hash/${described.id}${args["keep"] === false ? "\n(not kept)" : ""}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (name === "media_get") {
|
|
483
|
+
const id = typeof args["id"] === "string" ? args["id"].trim() : "";
|
|
484
|
+
if (!id) return failed("Which file? Pass its hash, fingerprint or transcript id.");
|
|
485
|
+
const got = await fetchMedia(site, id, send);
|
|
486
|
+
if (!got.ok) return failed(got.error);
|
|
487
|
+
return text(describeRecord(got.body));
|
|
488
|
+
}
|
|
489
|
+
|
|
442
490
|
if (name === "transcripts_list") {
|
|
443
491
|
const got = await listTranscripts({ site, token: session.token }, send);
|
|
444
492
|
if (!got.ok) return failed(got.error);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files this machine has told nixamp.com about, and when to look again.
|
|
3
|
+
*
|
|
4
|
+
* nixamp.com keeps the record (media.ts); the bytes stay here. So whether a
|
|
5
|
+
* file has changed is a question only this machine can answer, and it
|
|
6
|
+
* answers it on a schedule that follows the file: a quarter of the time
|
|
7
|
+
* since it last changed, never more often than every quarter of an hour
|
|
8
|
+
* and never less often than monthly (see checkInterval). Looking is one
|
|
9
|
+
* stat. Only a file whose size or modification time moved is hashed
|
|
10
|
+
* again, and a new hash is a new record on nixamp.com, with the old one
|
|
11
|
+
* told what it became.
|
|
12
|
+
*
|
|
13
|
+
* The index is a JSON file beside the daemon's other state. The CLI adds
|
|
14
|
+
* to it when it hashes or transcribes a file, the server when it captions
|
|
15
|
+
* one, and the server's watcher reads it every few minutes for what is
|
|
16
|
+
* due. Written whole and renamed into place, so two of them writing at
|
|
17
|
+
* once lose an entry at worst, never the file.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { stateDir } from "./daemon.ts";
|
|
22
|
+
import { checkInterval } from "./media.ts";
|
|
23
|
+
|
|
24
|
+
export interface IndexedFile {
|
|
25
|
+
/** The SHA-256 hex nixamp.com knows it by. */
|
|
26
|
+
id: string;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
size: number;
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
checkedAt: number;
|
|
31
|
+
checkAfter: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IndexFile {
|
|
35
|
+
version: 1;
|
|
36
|
+
files: Record<string, IndexedFile>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function indexPath(): string {
|
|
40
|
+
return process.env["NIXAMP_MEDIA_INDEX"] ?? join(stateDir(), "media.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readIndex(path = indexPath()): IndexFile {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<IndexFile>;
|
|
46
|
+
if (parsed && parsed.version === 1 && parsed.files && typeof parsed.files === "object") return { version: 1, files: parsed.files };
|
|
47
|
+
} catch {
|
|
48
|
+
// No index yet, or one this cannot read: start again.
|
|
49
|
+
}
|
|
50
|
+
return { version: 1, files: {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function writeIndex(index: IndexFile, path = indexPath()): void {
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
56
|
+
writeFileSync(temp, JSON.stringify(index, null, 2));
|
|
57
|
+
renameSync(temp, path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Note a file this machine has told nixamp.com about, with when to look at it next. */
|
|
61
|
+
export function remember(file: string, entry: { id: string; fingerprint: string; size: number; mtimeMs: number }, now = Date.now(), path = indexPath()): IndexedFile {
|
|
62
|
+
const index = readIndex(path);
|
|
63
|
+
const kept: IndexedFile = { ...entry, checkedAt: now, checkAfter: now + checkInterval(entry.mtimeMs, now) };
|
|
64
|
+
index.files[file] = kept;
|
|
65
|
+
writeIndex(index, path);
|
|
66
|
+
return kept;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The files whose turn it is. */
|
|
70
|
+
export function due(index: IndexFile, now = Date.now()): string[] {
|
|
71
|
+
return Object.entries(index.files).filter(([, one]) => one.checkAfter <= now).map(([file]) => file);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Looked =
|
|
75
|
+
| { state: "same"; entry: IndexedFile }
|
|
76
|
+
| { state: "changed"; entry: IndexedFile; size: number; mtimeMs: number }
|
|
77
|
+
| { state: "gone" };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One look at a file: the same as it was, changed, or gone. Nothing is
|
|
81
|
+
* hashed here; the caller hashes a changed file, since that is the part
|
|
82
|
+
* that costs, and then remembers the new record.
|
|
83
|
+
*/
|
|
84
|
+
export function look(file: string, entry: IndexedFile, now = Date.now()): Looked {
|
|
85
|
+
let stat: { size: number; mtimeMs: number };
|
|
86
|
+
try {
|
|
87
|
+
stat = statSync(file);
|
|
88
|
+
} catch {
|
|
89
|
+
return { state: "gone" };
|
|
90
|
+
}
|
|
91
|
+
if (stat.size === entry.size && Math.floor(stat.mtimeMs) === Math.floor(entry.mtimeMs)) {
|
|
92
|
+
return { state: "same", entry: { ...entry, checkedAt: now, checkAfter: now + checkInterval(entry.mtimeMs, now) } };
|
|
93
|
+
}
|
|
94
|
+
return { state: "changed", entry, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How often the watcher reads the index for what is due. */
|
|
98
|
+
export const WATCH_EVERY_MS = 10 * 60 * 1000;
|
|
99
|
+
|
|
100
|
+
export interface WatchOptions {
|
|
101
|
+
path?: string;
|
|
102
|
+
now?: () => number;
|
|
103
|
+
/** Hash the changed file, tell nixamp.com, and answer the new entry; null when it could not. */
|
|
104
|
+
refresh: (file: string, before: IndexedFile, stat: { size: number; mtimeMs: number }) => Promise<{ id: string; fingerprint: string } | null>;
|
|
105
|
+
onEvent?: (message: string) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One pass over what is due. Files that are the same are pushed out to
|
|
110
|
+
* their next check; changed ones are refreshed; gone ones are forgotten.
|
|
111
|
+
* Answers how many of each, for the log.
|
|
112
|
+
*/
|
|
113
|
+
export async function watchOnce(options: WatchOptions): Promise<{ same: number; changed: number; gone: number }> {
|
|
114
|
+
const now = options.now ?? Date.now;
|
|
115
|
+
const path = options.path ?? indexPath();
|
|
116
|
+
const index = readIndex(path);
|
|
117
|
+
const counts = { same: 0, changed: 0, gone: 0 };
|
|
118
|
+
for (const file of due(index, now())) {
|
|
119
|
+
const entry = index.files[file];
|
|
120
|
+
if (!entry) continue;
|
|
121
|
+
const looked = look(file, entry, now());
|
|
122
|
+
if (looked.state === "gone") {
|
|
123
|
+
delete index.files[file];
|
|
124
|
+
counts.gone += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (looked.state === "same") {
|
|
128
|
+
index.files[file] = looked.entry;
|
|
129
|
+
counts.same += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const fresh = await options.refresh(file, entry, { size: looked.size, mtimeMs: looked.mtimeMs });
|
|
133
|
+
const at = now();
|
|
134
|
+
if (fresh) {
|
|
135
|
+
index.files[file] = { ...fresh, size: looked.size, mtimeMs: looked.mtimeMs, checkedAt: at, checkAfter: at + checkInterval(looked.mtimeMs, at) };
|
|
136
|
+
counts.changed += 1;
|
|
137
|
+
options.onEvent?.(`${file} changed: now sha256:${fresh.id.slice(0, 12)} (was ${entry.id.slice(0, 12)})`);
|
|
138
|
+
} else {
|
|
139
|
+
// Could not say; ask again in a quarter of an hour rather than never.
|
|
140
|
+
index.files[file] = { ...entry, checkedAt: at, checkAfter: at + 15 * 60 * 1000 };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
writeIndex(index, path);
|
|
144
|
+
return counts;
|
|
145
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file on this machine, described for nixamp.com's record of it.
|
|
3
|
+
*
|
|
4
|
+
* The same few steps whoever meets the file: hash every byte (the OpenFile
|
|
5
|
+
* id), take the quick fingerprint (the transcript key), ask ffprobe what is
|
|
6
|
+
* inside, ask nichedb what it is, note where it is being carried, and say
|
|
7
|
+
* when this machine will look at it again. `nixamp hash` does it for a
|
|
8
|
+
* file you name, `nixamp transcribe` for the one it is writing down, and a
|
|
9
|
+
* server for a file it puts on the air. A file that changed since the
|
|
10
|
+
* machine last looked is described again as a new record, and the two
|
|
11
|
+
* records point at each other.
|
|
12
|
+
*/
|
|
13
|
+
import { basename, extname } from "node:path";
|
|
14
|
+
import { codecsOf, type Tools } from "./audio.ts";
|
|
15
|
+
import type { Enricher } from "./enrich.ts";
|
|
16
|
+
import { checkInterval, contentHash, factsFrom, fileFacts, type Holder, type MediaFacts } from "./media.ts";
|
|
17
|
+
import { remember, type IndexedFile } from "./media-index.ts";
|
|
18
|
+
import { keepMedia, type MediaToKeep, type Signed } from "./transcript-client.ts";
|
|
19
|
+
import { fileFingerprint } from "./transcripts.ts";
|
|
20
|
+
|
|
21
|
+
export interface Described {
|
|
22
|
+
id: string;
|
|
23
|
+
fingerprint: string;
|
|
24
|
+
size: number;
|
|
25
|
+
mtimeMs: number;
|
|
26
|
+
keep: MediaToKeep;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DescribeDeps {
|
|
30
|
+
/** ffprobe, for what is inside. Absent: only what the filesystem says. */
|
|
31
|
+
tools?: Tools | null;
|
|
32
|
+
enricher?: Pick<Enricher, "lookup"> | null;
|
|
33
|
+
holder?: Holder | null;
|
|
34
|
+
now?: () => number;
|
|
35
|
+
/** The tests hand in fakes for the two things that cost. */
|
|
36
|
+
hash?: (path: string) => Promise<string>;
|
|
37
|
+
fingerprint?: (path: string) => string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Everything this machine can say about a file, ready to keep. */
|
|
41
|
+
export async function describeFile(path: string, deps: DescribeDeps = {}): Promise<Described> {
|
|
42
|
+
const now = deps.now ?? Date.now;
|
|
43
|
+
const file = fileFacts(path);
|
|
44
|
+
const id = await (deps.hash ?? contentHash)(path);
|
|
45
|
+
const fingerprint = (deps.fingerprint ?? fileFingerprint)(path);
|
|
46
|
+
const codecs = deps.tools && deps.tools.carries !== false ? await codecsOf(deps.tools, path) : null;
|
|
47
|
+
const name = codecs?.tags?.title || basename(path, extname(path));
|
|
48
|
+
let enriched = null;
|
|
49
|
+
try {
|
|
50
|
+
enriched = deps.enricher ? await deps.enricher.lookup(name) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
enriched = null;
|
|
53
|
+
}
|
|
54
|
+
const at = now();
|
|
55
|
+
const facts: MediaFacts = {
|
|
56
|
+
...factsFrom(codecs, enriched),
|
|
57
|
+
fingerprint,
|
|
58
|
+
checkedAt: new Date(at).toISOString(),
|
|
59
|
+
checkAfter: new Date(at + checkInterval(file.mtimeMs, at)).toISOString(),
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
fingerprint,
|
|
64
|
+
size: file.size,
|
|
65
|
+
mtimeMs: file.mtimeMs,
|
|
66
|
+
keep: {
|
|
67
|
+
name: file.name,
|
|
68
|
+
size: file.size,
|
|
69
|
+
contentType: file.contentType,
|
|
70
|
+
updated: file.updated,
|
|
71
|
+
facts: facts as Record<string, unknown>,
|
|
72
|
+
...(deps.holder ? { holder: deps.holder } : {}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Keep a described file on nixamp.com and in this machine's index. Answers the store's sentence when it refused. */
|
|
78
|
+
export async function keepFile(
|
|
79
|
+
signed: Signed,
|
|
80
|
+
path: string,
|
|
81
|
+
described: Described,
|
|
82
|
+
deps: { fetcher?: typeof fetch; index?: string; now?: () => number } = {},
|
|
83
|
+
): Promise<string | null> {
|
|
84
|
+
const got = await keepMedia(signed, described.id, described.keep, deps.fetcher ?? fetch);
|
|
85
|
+
if (!got.ok) return got.error;
|
|
86
|
+
try {
|
|
87
|
+
remember(path, { id: described.id, fingerprint: described.fingerprint, size: described.size, mtimeMs: described.mtimeMs }, (deps.now ?? Date.now)(), deps.index);
|
|
88
|
+
} catch {
|
|
89
|
+
// The index is a convenience; the record is what matters.
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A file that changed since it was last described: described again as a
|
|
96
|
+
* new record that says what it was, while the old record says what it
|
|
97
|
+
* became. Answers the new identity, or null when nixamp.com would not
|
|
98
|
+
* keep it.
|
|
99
|
+
*/
|
|
100
|
+
export async function refreshChanged(
|
|
101
|
+
signed: Signed,
|
|
102
|
+
path: string,
|
|
103
|
+
before: Pick<IndexedFile, "id">,
|
|
104
|
+
deps: DescribeDeps & { fetcher?: typeof fetch; index?: string } = {},
|
|
105
|
+
): Promise<{ id: string; fingerprint: string } | null> {
|
|
106
|
+
const described = await describeFile(path, deps);
|
|
107
|
+
if (described.id === before.id) {
|
|
108
|
+
// Touched but not changed: the same bytes, a new modification time.
|
|
109
|
+
const failed = await keepFile(signed, path, described, deps);
|
|
110
|
+
return failed ? null : { id: described.id, fingerprint: described.fingerprint };
|
|
111
|
+
}
|
|
112
|
+
described.keep.facts = { ...(described.keep.facts ?? {}), supersedes: `sha256:${before.id}` };
|
|
113
|
+
const failed = await keepFile(signed, path, described, deps);
|
|
114
|
+
if (failed) return null;
|
|
115
|
+
await keepMedia(signed, before.id, { facts: { supersededBy: `sha256:${described.id}` } }, deps.fetcher ?? fetch);
|
|
116
|
+
return { id: described.id, fingerprint: described.fingerprint };
|
|
117
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nixamp.com/hash/<id>, as a page: everything nixamp knows about one file,
|
|
3
|
+
* for a person with a browser. The same facts as the OpenFile descriptor
|
|
4
|
+
* beside it, laid out: what it is, how long, what is inside, who said what
|
|
5
|
+
* it was, where it has been carried, and the transcript in every language
|
|
6
|
+
* it has been written down in, with subtitle files to take away.
|
|
7
|
+
*
|
|
8
|
+
* Rendered on the server from a string, with the terminal's look, so a
|
|
9
|
+
* crawler and a link preview read it and nothing has to load first.
|
|
10
|
+
*/
|
|
11
|
+
import type { MediaRecord, TranscriptRef } from "./media.ts";
|
|
12
|
+
import type { TranscriptLine } from "./transcripts.ts";
|
|
13
|
+
|
|
14
|
+
function esc(text: string): string {
|
|
15
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function bytes(size: number): string {
|
|
19
|
+
if (size >= 1024 ** 3) return `${(size / 1024 ** 3).toFixed(2)} GB`;
|
|
20
|
+
if (size >= 1024 ** 2) return `${(size / 1024 ** 2).toFixed(1)} MB`;
|
|
21
|
+
if (size >= 1024) return `${(size / 1024).toFixed(0)} KB`;
|
|
22
|
+
return `${size} B`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clock(seconds: number): string {
|
|
26
|
+
const whole = Math.max(0, Math.floor(seconds));
|
|
27
|
+
const h = Math.floor(whole / 3600);
|
|
28
|
+
const m = Math.floor((whole % 3600) / 60);
|
|
29
|
+
const s = whole % 60;
|
|
30
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function when(iso: string): string {
|
|
34
|
+
return iso ? esc(iso.replace("T", " ").replace(/\.\d+Z$/, " UTC")) : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const STYLE = `
|
|
38
|
+
:root { color-scheme: dark; --bg:#080c09; --panel:#0c120e; --edge:#1d2c22; --green:#4af689; --dim:#227a4a; --fg:#cfe8d8; --muted:#6d8a79; --accent:#7ef0c4; }
|
|
39
|
+
* { box-sizing: border-box; }
|
|
40
|
+
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
41
|
+
main { max-width: 960px; margin: 0 auto; padding: 20px 16px 48px; }
|
|
42
|
+
a { color: var(--accent); }
|
|
43
|
+
.brand { color: var(--green); font-weight: 700; letter-spacing: .14em; text-decoration: none; }
|
|
44
|
+
h1 { font-size: 20px; margin: 18px 0 4px; color: var(--green); overflow-wrap: anywhere; }
|
|
45
|
+
.id { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
46
|
+
.panel { position: relative; border: 1px solid var(--edge); border-radius: 6px; background: var(--panel); padding: 18px 12px 12px; margin-top: 16px; }
|
|
47
|
+
.panel::before { content: attr(data-title); position: absolute; top: -9px; left: 10px; padding: 0 6px; background: var(--panel); color: var(--dim); font-size: 12px; letter-spacing: .06em; }
|
|
48
|
+
table { border-collapse: collapse; width: 100%; }
|
|
49
|
+
td { padding: 3px 8px 3px 0; vertical-align: top; overflow-wrap: anywhere; }
|
|
50
|
+
td:first-child { color: var(--muted); white-space: nowrap; width: 11em; }
|
|
51
|
+
.poster { float: right; max-width: 160px; margin: 0 0 8px 12px; border: 1px solid var(--edge); border-radius: 4px; }
|
|
52
|
+
ul { margin: 0; padding-left: 1.2em; }
|
|
53
|
+
.lines { list-style: none; padding: 0; max-height: 420px; overflow-y: auto; font-size: 13px; }
|
|
54
|
+
.lines li { display: flex; gap: 8px; padding: 2px 0; border-bottom: 1px dotted var(--edge); }
|
|
55
|
+
.lines .t { color: var(--muted); flex: 0 0 auto; }
|
|
56
|
+
.chips a { display: inline-block; margin: 0 8px 6px 0; border: 1px solid var(--edge); border-radius: 999px; padding: 1px 10px; font-size: 12px; text-decoration: none; }
|
|
57
|
+
.muted { color: var(--muted); }
|
|
58
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font-size: 12px; }
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export interface PageTranscript extends TranscriptRef {
|
|
62
|
+
/** The lines themselves, for the one transcript the page shows. */
|
|
63
|
+
shown?: TranscriptLine[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The page, whole. `transcripts[0]` carries the lines shown; the rest are offered as files. */
|
|
67
|
+
export function mediaPage(record: MediaRecord, site: string, transcripts: PageTranscript[] = []): string {
|
|
68
|
+
const base = site.replace(/\/+$/, "");
|
|
69
|
+
const f = record.facts;
|
|
70
|
+
const title = f.enrichment?.title || f.tags?.title || record.name || record.id.slice(0, 12);
|
|
71
|
+
const picture = f.enrichment?.image ?? null;
|
|
72
|
+
const url = `${base}/hash/${record.id}`;
|
|
73
|
+
const summary = f.enrichment?.summary ?? "";
|
|
74
|
+
const description = summary || `${record.name}${f.duration ? `, ${clock(f.duration)}` : ""}${transcripts.length > 0 ? `, transcribed in ${transcripts.map((one) => one.language || "its own language").join(", ")}` : ""}`;
|
|
75
|
+
|
|
76
|
+
const rows: [string, string][] = [];
|
|
77
|
+
if (record.name) rows.push(["File", esc(record.name)]);
|
|
78
|
+
if (record.size) rows.push(["Size", bytes(record.size)]);
|
|
79
|
+
if (record.contentType) rows.push(["Type", esc(record.contentType)]);
|
|
80
|
+
if (f.duration) rows.push(["Length", clock(f.duration)]);
|
|
81
|
+
if (f.width && f.height) rows.push(["Picture", `${f.width}×${f.height}`]);
|
|
82
|
+
if (f.codecs) rows.push(["Inside", esc([f.codecs.video, f.codecs.audio, f.codecs.container].filter(Boolean).join(" / "))]);
|
|
83
|
+
if (f.tags?.artist || f.tags?.album) rows.push(["Tagged", esc([f.tags.artist, f.tags.album].filter(Boolean).join(" · "))]);
|
|
84
|
+
if (record.updated) rows.push(["File changed", when(record.updated)]);
|
|
85
|
+
if (f.checkedAt) rows.push(["Last checked", `${when(f.checkedAt)}${f.checkAfter ? `, next ${when(f.checkAfter)}` : ""}`]);
|
|
86
|
+
if (f.fingerprint) rows.push(["Fingerprint", `<span class="id">${esc(f.fingerprint)}</span>`]);
|
|
87
|
+
if (f.supersedes) rows.push(["Was", `<a href="${base}/hash/${esc(f.supersedes.replace(/^sha256:/, ""))}">${esc(f.supersedes)}</a>`]);
|
|
88
|
+
if (f.supersededBy) rows.push(["Became", `<a href="${base}/hash/${esc(f.supersededBy.replace(/^sha256:/, ""))}">${esc(f.supersededBy)}</a>`]);
|
|
89
|
+
rows.push(["Kept", `${when(record.createdAt)}${record.updatedAt !== record.createdAt ? `, last added to ${when(record.updatedAt)}` : ""}`]);
|
|
90
|
+
|
|
91
|
+
const enrichment = f.enrichment
|
|
92
|
+
? `<section class="panel" data-title="What nichedb says it is">
|
|
93
|
+
${picture ? `<img class="poster" src="${esc(picture)}" alt="" />` : ""}
|
|
94
|
+
<table>
|
|
95
|
+
<tr><td>Title</td><td>${esc(f.enrichment.title ?? "")}${f.enrichment.year ? ` (${f.enrichment.year})` : ""}</td></tr>
|
|
96
|
+
${f.enrichment.kind ? `<tr><td>Kind</td><td>${esc(f.enrichment.kind)}</td></tr>` : ""}
|
|
97
|
+
${summary ? `<tr><td>About</td><td>${esc(summary)}</td></tr>` : ""}
|
|
98
|
+
${f.enrichment.page ? `<tr><td>Page</td><td><a href="${esc(f.enrichment.page)}">${esc(f.enrichment.page)}</a></td></tr>` : ""}
|
|
99
|
+
</table>
|
|
100
|
+
</section>`
|
|
101
|
+
: "";
|
|
102
|
+
|
|
103
|
+
const holders = record.holders.length > 0
|
|
104
|
+
? `<section class="panel" data-title="Carried by">
|
|
105
|
+
<ul>${record.holders.map((h) => `<li><a href="${esc(h.url)}">${esc(h.name || h.url)}</a>${h.channel ? ` as <code>${esc(h.channel)}</code>` : ""} <span class="muted">${when(h.seenAt)}</span></li>`).join("")}</ul>
|
|
106
|
+
</section>`
|
|
107
|
+
: "";
|
|
108
|
+
|
|
109
|
+
const shown = transcripts[0];
|
|
110
|
+
const transcript = transcripts.length > 0
|
|
111
|
+
? `<section class="panel" data-title="Transcript">
|
|
112
|
+
<p class="chips">${transcripts.map((one) => {
|
|
113
|
+
const q = one.language ? `?language=${esc(one.language)}` : "";
|
|
114
|
+
return `<a href="${url}.srt${q}">${esc(one.language || "as spoken")}${one.translatedFrom ? ` (from ${esc(one.translatedFrom)})` : ""} · ${one.lines} lines${one.complete ? "" : " so far"} · SRT</a> <a href="${url}.vtt${q}">VTT</a> <a href="${url}.txt${q}">text</a>`;
|
|
115
|
+
}).join("<br />")}</p>
|
|
116
|
+
${shown?.shown && shown.shown.length > 0
|
|
117
|
+
? `<ul class="lines">${shown.shown.map((line) => `<li><span class="t">${clock(line.start)}</span><span>${esc(line.text)}</span></li>`).join("")}</ul>`
|
|
118
|
+
: ""}
|
|
119
|
+
</section>`
|
|
120
|
+
: `<section class="panel" data-title="Transcript"><p class="muted">Not written down yet. <code>nixamp transcribe FILE</code> keeps it here, and a server captioning it does too.</p></section>`;
|
|
121
|
+
|
|
122
|
+
return `<!doctype html>
|
|
123
|
+
<html lang="en">
|
|
124
|
+
<head>
|
|
125
|
+
<meta charset="utf-8" />
|
|
126
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
127
|
+
<title>${esc(title)} · nixamp</title>
|
|
128
|
+
<meta name="description" content="${esc(description.slice(0, 300))}" />
|
|
129
|
+
<link rel="canonical" href="${url}" />
|
|
130
|
+
<link rel="openfile" href="${url}.openfile.json" />
|
|
131
|
+
<link rel="alternate" type="application/json" href="${url}.json" />
|
|
132
|
+
<meta property="og:title" content="${esc(title)}" />
|
|
133
|
+
<meta property="og:description" content="${esc(description.slice(0, 300))}" />
|
|
134
|
+
<meta property="og:url" content="${url}" />
|
|
135
|
+
<meta property="og:type" content="${record.contentType.startsWith("video/") ? "video.other" : record.contentType.startsWith("audio/") ? "music.song" : "website"}" />
|
|
136
|
+
${picture ? `<meta property="og:image" content="${esc(picture)}" />` : ""}
|
|
137
|
+
<style>${STYLE}</style>
|
|
138
|
+
</head>
|
|
139
|
+
<body>
|
|
140
|
+
<main>
|
|
141
|
+
<a class="brand" href="${base}/">NIXAMP</a>
|
|
142
|
+
<h1>${esc(title)}</h1>
|
|
143
|
+
<div class="id">sha256:${esc(record.id)}</div>
|
|
144
|
+
<section class="panel" data-title="The file">
|
|
145
|
+
<table>${rows.map(([k, v]) => `<tr><td>${k}</td><td>${v}</td></tr>`).join("")}</table>
|
|
146
|
+
</section>
|
|
147
|
+
${enrichment}
|
|
148
|
+
${holders}
|
|
149
|
+
${transcript}
|
|
150
|
+
<section class="panel" data-title="For a program">
|
|
151
|
+
<p class="muted">This page as <a href="${url}.openfile.json">OpenFile</a> (<a href="https://logicsrc.com/docs/openfile">the specification</a>) or <a href="${url}.json">JSON</a>. Every file nixamp.com knows: <a href="${base}/.well-known/openfile.json">/.well-known/openfile.json</a>.</p>
|
|
152
|
+
<pre>nixamp hash FILE the same address for a file of yours
|
|
153
|
+
nixamp transcribe FILE and its transcript, kept here</pre>
|
|
154
|
+
</section>
|
|
155
|
+
</main>
|
|
156
|
+
</body>
|
|
157
|
+
</html>
|
|
158
|
+
`;
|
|
159
|
+
}
|