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