nixamp 0.60.3 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/known-servers.d.ts +33 -0
- package/dist/known-servers.js +40 -0
- package/dist/music-hub.js +33 -3
- package/dist/music-seo.d.ts +41 -0
- package/dist/music-seo.js +56 -0
- package/dist/server.js +22 -7
- package/dist/veo-studio.d.ts +18 -5
- package/dist/veo-studio.js +60 -21
- package/package.json +1 -1
- package/src/known-servers.ts +53 -0
- package/src/music-hub.ts +26 -3
- package/src/music-seo.ts +81 -0
- package/src/server.ts +21 -7
- package/src/veo-studio.ts +67 -25
- package/web/dist/assets/{ar-C7psoyOy.js → ar-a32-Hj6_.js} +1 -1
- package/web/dist/assets/{cs-IRnKEUzW.js → cs-CxuOXnwT.js} +1 -1
- package/web/dist/assets/{da-CceasMGe.js → da-Bp3UIwlY.js} +1 -1
- package/web/dist/assets/{de-CHN3y_5N.js → de-C-1FCWth.js} +1 -1
- package/web/dist/assets/{es-DQY1pBZl.js → es-6xqhs-Gh.js} +1 -1
- package/web/dist/assets/{fi-DRm07ra-.js → fi-DxcNMXAK.js} +1 -1
- package/web/dist/assets/{fr-BoyQjqiJ.js → fr-CWqXTENP.js} +1 -1
- package/web/dist/assets/{hi-htS106bK.js → hi-CWRULGV1.js} +1 -1
- package/web/dist/assets/{hu-aJuYy5Q1.js → hu-BoA-Yeym.js} +1 -1
- package/web/dist/assets/{id-DjhPS4Qs.js → id-CKslltWx.js} +1 -1
- package/web/dist/assets/{it-i1I9NlWC.js → it-CL8gEi58.js} +1 -1
- package/web/dist/assets/{main-ImY-JbNz.js → main-Cj3YY0GY.js} +2 -2
- package/web/dist/assets/music-CQa4G4bG.js +1 -0
- package/web/dist/assets/music-CVLJhYTF.css +1 -0
- package/web/dist/assets/{nl-CeUR4KXE.js → nl-BePQfdXl.js} +1 -1
- package/web/dist/assets/{ru-BJkAVfoj.js → ru-C2zYQTOO.js} +1 -1
- package/web/dist/assets/{sv-F3gM7kjC.js → sv-BIF1MTUK.js} +1 -1
- package/web/dist/assets/{uk-Dtj0jb_m.js → uk-B0b55c6z.js} +1 -1
- package/web/dist/assets/{vi-CLFmHDR1.js → vi-DBrZrfoI.js} +1 -1
- package/web/dist/assets/{zh-C37VYmRU.js → zh-Dghotb2J.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/music.html +2 -2
- package/web/dist/sw.js +21 -21
- package/web/dist/assets/music-B-SPdokI.js +0 -1
- package/web/dist/assets/music-DzqKKt0c.css +0 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The servers nixamp.com has seen listed lately, and who owns each, kept in
|
|
3
|
+
* Postgres.
|
|
4
|
+
*
|
|
5
|
+
* The directory lives in memory and fills again only as servers heartbeat, so
|
|
6
|
+
* for a minute after every deploy nixamp.com/music had no catalogue and a
|
|
7
|
+
* label server's Veo or release call was refused as "not listed". This keeps
|
|
8
|
+
* the last day's servers so both work straight away; a server still listed
|
|
9
|
+
* always wins over what is remembered.
|
|
10
|
+
*/
|
|
11
|
+
import type { Queryable } from "./veo-studio.ts";
|
|
12
|
+
export interface KnownServer {
|
|
13
|
+
base: string;
|
|
14
|
+
ownerId: string;
|
|
15
|
+
seenAt: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class KnownServers {
|
|
18
|
+
private readonly db;
|
|
19
|
+
private readonly now;
|
|
20
|
+
private known;
|
|
21
|
+
private ready;
|
|
22
|
+
constructor(db: Queryable, now?: () => number);
|
|
23
|
+
private ensure;
|
|
24
|
+
/** What the last process saw in the last day. */
|
|
25
|
+
load(): Promise<void>;
|
|
26
|
+
/** The servers listed now: remembered here and in the database. */
|
|
27
|
+
sync(listed: Array<{
|
|
28
|
+
base: string;
|
|
29
|
+
ownerId: string;
|
|
30
|
+
}>): Promise<void>;
|
|
31
|
+
/** Servers seen in the last day. */
|
|
32
|
+
list(): KnownServer[];
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
export class KnownServers {
|
|
3
|
+
db;
|
|
4
|
+
now;
|
|
5
|
+
known = new Map();
|
|
6
|
+
ready = null;
|
|
7
|
+
constructor(db, now = Date.now) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.now = now;
|
|
10
|
+
}
|
|
11
|
+
ensure() {
|
|
12
|
+
return (this.ready ??= this.db.query("CREATE TABLE IF NOT EXISTS music_servers (base TEXT PRIMARY KEY, owner_id TEXT NOT NULL, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())").catch((error) => { this.ready = null; throw error; }));
|
|
13
|
+
}
|
|
14
|
+
/** What the last process saw in the last day. */
|
|
15
|
+
async load() {
|
|
16
|
+
await this.ensure();
|
|
17
|
+
const { rows } = await this.db.query("SELECT base, owner_id, seen_at FROM music_servers WHERE seen_at > $1", [new Date(this.now() - DAY_MS)]);
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
const base = String(row["base"]);
|
|
20
|
+
if (!this.known.has(base))
|
|
21
|
+
this.known.set(base, { base, ownerId: String(row["owner_id"]), seenAt: new Date(String(row["seen_at"])).getTime() });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The servers listed now: remembered here and in the database. */
|
|
25
|
+
async sync(listed) {
|
|
26
|
+
await this.ensure();
|
|
27
|
+
const at = this.now();
|
|
28
|
+
for (const one of listed) {
|
|
29
|
+
if (!one.base || !one.ownerId)
|
|
30
|
+
continue;
|
|
31
|
+
this.known.set(one.base, { base: one.base, ownerId: one.ownerId, seenAt: at });
|
|
32
|
+
await this.db.query("INSERT INTO music_servers (base, owner_id, seen_at) VALUES ($1, $2, now()) ON CONFLICT (base) DO UPDATE SET owner_id = EXCLUDED.owner_id, seen_at = now()", [one.base, one.ownerId]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Servers seen in the last day. */
|
|
36
|
+
list() {
|
|
37
|
+
const since = this.now() - DAY_MS;
|
|
38
|
+
return [...this.known.values()].filter((one) => one.seenAt > since);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/music-hub.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* POST /api/v1/music/released a server says an album or song landed: its followers hear
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
-
import { musicPage, renderMusicPage } from "./music-seo.js";
|
|
15
|
+
import { memberProfile, musicPage, renderMusicPage } from "./music-seo.js";
|
|
16
|
+
import { readMember } from "./label-profiles.js";
|
|
16
17
|
/** A server's opaque id: the first 10 hex of the SHA-256 of its https origin. Both sides can work it out. */
|
|
17
18
|
export function serverId(base) {
|
|
18
19
|
return createHash("sha256").update(base.replace(/\/+$/, "")).digest("hex").slice(0, 10);
|
|
@@ -136,8 +137,10 @@ export async function musicPageFor(hub, path) {
|
|
|
136
137
|
}
|
|
137
138
|
return "";
|
|
138
139
|
}
|
|
140
|
+
/** A band member's OpenProfile as JSON (music-seo.ts memberProfile), read from their page on the server that holds it. */
|
|
141
|
+
const MEMBER_JSON = /^\/api\/v1\/music\/member\/([a-f0-9]{10})\/([a-f0-9]{10})\/([a-z0-9-]{1,40})\.json$/;
|
|
139
142
|
export function isMusicPath(path) {
|
|
140
|
-
return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
|
|
143
|
+
return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || MEMBER_JSON.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
|
|
141
144
|
}
|
|
142
145
|
export async function handleMusicRoutes(request, response, path, ctx) {
|
|
143
146
|
if (!isMusicPath(path))
|
|
@@ -182,6 +185,33 @@ export async function handleMusicRoutes(request, response, path, ctx) {
|
|
|
182
185
|
ctx.json(response, 200, await ctx.hub.catalogue());
|
|
183
186
|
return true;
|
|
184
187
|
}
|
|
188
|
+
const person = MEMBER_JSON.exec(path);
|
|
189
|
+
if (person) {
|
|
190
|
+
const [, server, artistKey, slug] = person;
|
|
191
|
+
const base = ctx.hub.resolve(server);
|
|
192
|
+
if (!base) {
|
|
193
|
+
ctx.json(response, 404, { error: "no such server" });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const upstream = await fetch(`${base}/api/v1/label/${artistKey}/members/${slug}.md`, { signal: AbortSignal.timeout(10_000) });
|
|
198
|
+
const member = upstream.ok ? readMember(await upstream.text(), slug) : null;
|
|
199
|
+
if (!member) {
|
|
200
|
+
ctx.json(response, 404, { error: "no such person" });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const catalogue = await ctx.hub.catalogue().catch(() => null);
|
|
204
|
+
const band = catalogue?.genres.flatMap((one) => one.artists).find((one) => one.server === server && one.key === artistKey)?.name ?? "";
|
|
205
|
+
const profile = memberProfile(member, { site: (ctx.site ?? "https://nixamp.com").replace(/\/+$/, ""), server, artist: artistKey, band, holder: catalogue?.holders[0] ?? "Profullstack, Inc." });
|
|
206
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "public, max-age=300", "access-control-allow-origin": "*" });
|
|
207
|
+
response.end(method === "HEAD" ? undefined : JSON.stringify(profile, null, 2));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
if (!response.headersSent)
|
|
211
|
+
ctx.json(response, 502, { error: "the server holding it did not answer" });
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
185
215
|
const file = FILE.exec(path);
|
|
186
216
|
if (file) {
|
|
187
217
|
const base = ctx.hub.resolve(file[1]);
|
|
@@ -239,7 +269,7 @@ export async function handleMusicRoutes(request, response, path, ctx) {
|
|
|
239
269
|
// one without the server a page names is not knowing, not "no such page": the
|
|
240
270
|
// shell as it is, never a 404 a crawler would drop the page for.
|
|
241
271
|
const servers = new Set(catalogue?.genres.flatMap((one) => one.artists.map((artist) => artist.server)) ?? []);
|
|
242
|
-
const named = /^\/music\/(?:artist|album)\/([a-f0-9]{10})\//.exec(path)?.[1];
|
|
272
|
+
const named = /^\/music\/(?:artist|album|member)\/([a-f0-9]{10})\//.exec(path)?.[1];
|
|
243
273
|
const known = servers.size > 0 && (!named || servers.has(named));
|
|
244
274
|
const view = catalogue && known ? musicPage(path, catalogue, site) : null;
|
|
245
275
|
const html = view ? renderMusicPage(shell, view, `${site}${path.replace(/\/+$/, "") || "/music"}`) : shell;
|
package/dist/music-seo.d.ts
CHANGED
|
@@ -9,9 +9,50 @@
|
|
|
9
9
|
* /music/genre/<slug> a genre's artists
|
|
10
10
|
* /music/artist/<server>/<artist> an artist: bio, members, albums
|
|
11
11
|
* /music/album/<server>/<artist>/<album> an album: cover, songs
|
|
12
|
+
* /music/member/<server>/<artist>/<slug> a band member: photo, job, OpenProfile (JSON at /api/v1/music/member/...json)
|
|
12
13
|
* /music/me the listener's own page: never indexed
|
|
13
14
|
*/
|
|
14
15
|
import type { HubCatalogue } from "./music-hub.ts";
|
|
16
|
+
/** What someone does, said as a job: "lead vocals in Hollow Sovereign", "bass in Harbor Lights Society". */
|
|
17
|
+
export declare function memberJob(role: string, band: string): string;
|
|
18
|
+
/** A band member's OpenProfile, as JSON: the page's fields, with every address absolute. */
|
|
19
|
+
export interface MemberProfile {
|
|
20
|
+
openprofile: string;
|
|
21
|
+
type: "person";
|
|
22
|
+
kind: string;
|
|
23
|
+
name: string;
|
|
24
|
+
role: string;
|
|
25
|
+
job: string;
|
|
26
|
+
band: {
|
|
27
|
+
name: string;
|
|
28
|
+
url: string;
|
|
29
|
+
};
|
|
30
|
+
avatar: string;
|
|
31
|
+
bio: string;
|
|
32
|
+
look: string;
|
|
33
|
+
voice: string;
|
|
34
|
+
personality: string;
|
|
35
|
+
influences: string;
|
|
36
|
+
parent: string;
|
|
37
|
+
url: string;
|
|
38
|
+
source: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function memberProfile(member: {
|
|
41
|
+
slug: string;
|
|
42
|
+
name: string;
|
|
43
|
+
role: string;
|
|
44
|
+
bio: string;
|
|
45
|
+
look: string;
|
|
46
|
+
voice: string;
|
|
47
|
+
personality: string;
|
|
48
|
+
influences: string;
|
|
49
|
+
}, where: {
|
|
50
|
+
site: string;
|
|
51
|
+
server: string;
|
|
52
|
+
artist: string;
|
|
53
|
+
band: string;
|
|
54
|
+
holder: string;
|
|
55
|
+
}): MemberProfile;
|
|
15
56
|
interface Head {
|
|
16
57
|
title: string;
|
|
17
58
|
description: string;
|
package/dist/music-seo.js
CHANGED
|
@@ -5,6 +5,32 @@ const clip = (text, max) => {
|
|
|
5
5
|
return one.length <= max ? one : `${one.slice(0, max - 1).replace(/\s+\S*$/, "")}…`;
|
|
6
6
|
};
|
|
7
7
|
const clock = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
|
|
8
|
+
const capital = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
9
|
+
/** What someone does, said as a job: "lead vocals in Hollow Sovereign", "bass in Harbor Lights Society". */
|
|
10
|
+
export function memberJob(role, band) {
|
|
11
|
+
return `${role || "member"} in ${band}`;
|
|
12
|
+
}
|
|
13
|
+
export function memberProfile(member, where) {
|
|
14
|
+
const files = `${where.site}/api/v1/music/file/${where.server}/${where.artist}/members/${member.slug}`;
|
|
15
|
+
return {
|
|
16
|
+
openprofile: "1",
|
|
17
|
+
type: "person",
|
|
18
|
+
kind: "fictional musician, made with AI",
|
|
19
|
+
name: member.name,
|
|
20
|
+
role: member.role,
|
|
21
|
+
job: memberJob(member.role, where.band),
|
|
22
|
+
band: { name: where.band, url: `${where.site}/music/artist/${where.server}/${where.artist}` },
|
|
23
|
+
avatar: `${files}.jpg`,
|
|
24
|
+
bio: member.bio,
|
|
25
|
+
look: member.look,
|
|
26
|
+
voice: member.voice,
|
|
27
|
+
personality: member.personality,
|
|
28
|
+
influences: member.influences,
|
|
29
|
+
parent: where.holder,
|
|
30
|
+
url: `${where.site}/music/member/${where.server}/${where.artist}/${member.slug}`,
|
|
31
|
+
source: `${files}.md`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
8
34
|
/** The head and summary of a /music address, from the catalogue. `site` is https://nixamp.com. */
|
|
9
35
|
export function musicPage(path, catalogue, site) {
|
|
10
36
|
const at = (rest) => `${site}${rest}`;
|
|
@@ -32,6 +58,36 @@ export function musicPage(path, catalogue, site) {
|
|
|
32
58
|
body: "<h1>My music</h1><p>Sign in to see what you bought and who you follow.</p>",
|
|
33
59
|
};
|
|
34
60
|
}
|
|
61
|
+
if (parts[0] === "member" && parts[1] && parts[2] && parts[3]) {
|
|
62
|
+
const artist = artists.find((one) => one.server === parts[1] && one.key === parts[2]);
|
|
63
|
+
const member = artist?.members?.find((one) => one.slug === parts[3]);
|
|
64
|
+
if (!artist || !member)
|
|
65
|
+
return missing("person");
|
|
66
|
+
const job = memberJob(member.role, artist.name);
|
|
67
|
+
const portrait = file(artist.server, `${artist.key}/members/${member.slug}.jpg`);
|
|
68
|
+
const json = `/api/v1/music/member/${artist.server}/${artist.key}/${member.slug}.json`;
|
|
69
|
+
return {
|
|
70
|
+
status: 200,
|
|
71
|
+
head: {
|
|
72
|
+
title: `${member.name} · ${job} · nixamp music`,
|
|
73
|
+
description: clip(member.bio || `${member.name}, ${job}.`, 200),
|
|
74
|
+
type: "profile",
|
|
75
|
+
image: { url: portrait, alt: `${member.name} of ${artist.name}` },
|
|
76
|
+
extra: [["profile:username", member.slug]],
|
|
77
|
+
ld: {
|
|
78
|
+
"@context": "https://schema.org", "@type": "Person", name: member.name, url: at(`/music/member/${artist.server}/${artist.key}/${member.slug}`),
|
|
79
|
+
...(member.role ? { jobTitle: member.role } : {}),
|
|
80
|
+
...(member.bio ? { description: member.bio } : {}),
|
|
81
|
+
image: portrait,
|
|
82
|
+
memberOf: { "@type": "MusicGroup", name: artist.name, url: at(artistLink(artist)) },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
body: `<p><a href="/music">Music</a> / <a href="${artistLink(artist)}">${escape(artist.name)}</a></p><h1>${escape(member.name)}</h1>`
|
|
86
|
+
+ `<p>${escape(capital(member.role || "member"))} in <a href="${artistLink(artist)}">${escape(artist.name)}</a></p>`
|
|
87
|
+
+ (member.bio ? `<p>${escape(member.bio)}</p>` : "")
|
|
88
|
+
+ `<p><a href="${json}">OpenProfile as JSON</a></p>`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
35
91
|
if (parts[0] === "genre" && parts[1]) {
|
|
36
92
|
const genre = catalogue.genres.find((one) => slug(one.genre) === parts[1]);
|
|
37
93
|
if (!genre)
|
package/dist/server.js
CHANGED
|
@@ -65,7 +65,8 @@ import { AiLabel, VeoUnavailable } from "./ai-label.js";
|
|
|
65
65
|
import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
|
|
66
66
|
import { GrantVerifier, MusicStore, PRICES } from "./music-store.js";
|
|
67
67
|
import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, serverId } from "./music-hub.js";
|
|
68
|
-
import { VeoBudgetError, VeoStudio } from "./veo-studio.js";
|
|
68
|
+
import { fileLedger, pgLedger, VeoBudgetError, VeoStudio } from "./veo-studio.js";
|
|
69
|
+
import { KnownServers } from "./known-servers.js";
|
|
69
70
|
import { handleStoreRoutes, isStorePath } from "./store-routes.js";
|
|
70
71
|
import { AiRadio } from "./ai-radio.js";
|
|
71
72
|
import { ListenerShares } from "./listener-shares.js";
|
|
@@ -7490,9 +7491,16 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7490
7491
|
// The AI label's songs for sale: purchases kept here, the music zipped by the server that holds it.
|
|
7491
7492
|
const jwtSecret = process.env["NIXAMP_JWT_SECRET"] ?? "";
|
|
7492
7493
|
// The servers whose music nixamp.com sells and plays: the directory and the guide's peers.
|
|
7494
|
+
// Remembered in Postgres too, so the catalogue and owners are there the moment a deploy restarts us.
|
|
7495
|
+
const knownServers = options.directory && pool ? new KnownServers(pool) : undefined;
|
|
7496
|
+
const listedServers = () => directory ? directory.list().map((one) => ({ base: peerBase(one.url), ownerId: one.ownerId ?? "" })).filter((one) => one.base) : [];
|
|
7497
|
+
if (knownServers) {
|
|
7498
|
+
void knownServers.load().catch((error) => console.log(` music servers not remembered: ${error instanceof Error ? error.message : String(error)}`));
|
|
7499
|
+
setInterval(() => void knownServers.sync(listedServers()).catch(() => undefined), 60_000).unref();
|
|
7500
|
+
}
|
|
7493
7501
|
const musicServers = () => {
|
|
7494
|
-
const listed =
|
|
7495
|
-
return [...new Set([...guidePeers(), ...listed])];
|
|
7502
|
+
const listed = listedServers().map((one) => one.base);
|
|
7503
|
+
return [...new Set([...guidePeers(), ...listed, ...(knownServers?.list().map((one) => one.base) ?? [])])];
|
|
7496
7504
|
};
|
|
7497
7505
|
// nixamp.com/music: every server's label in one catalogue (music-hub.ts).
|
|
7498
7506
|
const musicHub = options.directory ? new MusicHub({ peers: musicServers }) : undefined;
|
|
@@ -7514,14 +7522,21 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7514
7522
|
// Veo shots for label music videos, under a monthly dollar cap (veo-studio.ts). nixamp.com only.
|
|
7515
7523
|
const veoStudio = options.directory && process.env["NIXAMP_VEO_API_KEY"] ? new VeoStudio({
|
|
7516
7524
|
key: process.env["NIXAMP_VEO_API_KEY"],
|
|
7517
|
-
|
|
7525
|
+
// The month's spend in Postgres: a deploy recreates the container, and a file there would forget it.
|
|
7526
|
+
ledger: pool ? pgLedger(pool) : fileLedger(VeoStudio.fileFor(stateDir(), options.port)),
|
|
7518
7527
|
monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
|
|
7519
7528
|
usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
|
|
7520
7529
|
}) : undefined;
|
|
7521
7530
|
/** The signed-in account owns a server listed in the directory at this base. */
|
|
7522
7531
|
const ownsListed = async (request, base) => {
|
|
7523
7532
|
const me = accounts ? await accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
|
|
7524
|
-
|
|
7533
|
+
if (!me || !base)
|
|
7534
|
+
return false;
|
|
7535
|
+
const listed = listedServers();
|
|
7536
|
+
// A server listed now answers for itself; one not (yet) back since a restart, by who last owned it.
|
|
7537
|
+
if (listed.some((one) => one.base === base))
|
|
7538
|
+
return listed.some((one) => one.base === base && one.ownerId === me.id);
|
|
7539
|
+
return Boolean(knownServers?.list().some((one) => one.base === base && one.ownerId === me.id));
|
|
7525
7540
|
};
|
|
7526
7541
|
const musicVeo = async (request) => {
|
|
7527
7542
|
if (!veoStudio)
|
|
@@ -7530,7 +7545,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7530
7545
|
if (!(await ownsListed(request, base)))
|
|
7531
7546
|
return { status: 403, body: { error: "only the owner of a listed server" } };
|
|
7532
7547
|
if (request.method === "GET")
|
|
7533
|
-
return { status: 200, body: veoStudio.status() };
|
|
7548
|
+
return { status: 200, body: await veoStudio.status() };
|
|
7534
7549
|
if (request.method !== "POST")
|
|
7535
7550
|
return { status: 405, body: { error: "GET or POST" } };
|
|
7536
7551
|
let input;
|
|
@@ -7549,7 +7564,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7549
7564
|
}
|
|
7550
7565
|
catch (error) {
|
|
7551
7566
|
if (error instanceof VeoBudgetError)
|
|
7552
|
-
return { status: 402, body: { error: error.message, code: "budget", ...veoStudio.status() } };
|
|
7567
|
+
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7553
7568
|
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
7554
7569
|
}
|
|
7555
7570
|
};
|
package/dist/veo-studio.d.ts
CHANGED
|
@@ -2,9 +2,24 @@ export declare class VeoBudgetError extends Error {
|
|
|
2
2
|
}
|
|
3
3
|
export declare class VeoRefused extends Error {
|
|
4
4
|
}
|
|
5
|
+
/** Where the month's spend lives. `reserve` adds `usd` only if that keeps the month at or under `cap`. */
|
|
6
|
+
export interface VeoLedger {
|
|
7
|
+
spent(month: string): Promise<number>;
|
|
8
|
+
reserve(month: string, usd: number, cap: number): Promise<boolean>;
|
|
9
|
+
refund(month: string, usd: number): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface Queryable {
|
|
12
|
+
query: (text: string, params?: unknown[]) => Promise<{
|
|
13
|
+
rows: Array<Record<string, unknown>>;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/** The spend in Postgres, one row a month, reserved atomically. */
|
|
17
|
+
export declare function pgLedger(db: Queryable): VeoLedger;
|
|
18
|
+
/** The spend in a file, for a nixamp with no database (its own state directory must survive restarts). */
|
|
19
|
+
export declare function fileLedger(file: string): VeoLedger;
|
|
5
20
|
export interface VeoStudioOptions {
|
|
6
21
|
key: string;
|
|
7
|
-
|
|
22
|
+
ledger: VeoLedger;
|
|
8
23
|
monthlyUsd?: number;
|
|
9
24
|
usdPerSecond?: number;
|
|
10
25
|
model?: string;
|
|
@@ -14,21 +29,19 @@ export interface VeoStudioOptions {
|
|
|
14
29
|
}
|
|
15
30
|
export declare class VeoStudio {
|
|
16
31
|
private readonly options;
|
|
17
|
-
private spent;
|
|
18
32
|
constructor(options: VeoStudioOptions);
|
|
19
33
|
static fileFor(stateDir: string, port: number): string;
|
|
20
34
|
private month;
|
|
21
35
|
private get cap();
|
|
22
36
|
private get rate();
|
|
23
|
-
|
|
24
|
-
status(): {
|
|
37
|
+
status(): Promise<{
|
|
25
38
|
month: string;
|
|
26
39
|
spentUsd: number;
|
|
27
40
|
capUsd: number;
|
|
28
41
|
leftUsd: number;
|
|
29
42
|
usdPerSecond: number;
|
|
30
43
|
secondsLeft: number;
|
|
31
|
-
}
|
|
44
|
+
}>;
|
|
32
45
|
/** One shot: 4, 6 or 8 seconds, 16:9, with up to three reference images (JPEG). */
|
|
33
46
|
clip(input: {
|
|
34
47
|
prompt: string;
|
package/dist/veo-studio.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The key is NIXAMP_VEO_API_KEY, its own name, so nothing else on nixamp.com
|
|
13
13
|
* starts spending Gemini credit because a Gemini key appeared.
|
|
14
|
+
*
|
|
15
|
+
* The month's spend is kept in Postgres on nixamp.com (a deploy recreates its
|
|
16
|
+
* container and would lose a file, and with it the cap), reserved in one
|
|
17
|
+
* statement that refuses to go over; a file only where there is no database.
|
|
14
18
|
*/
|
|
15
19
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
20
|
import { dirname, join } from "node:path";
|
|
@@ -18,18 +22,62 @@ export class VeoBudgetError extends Error {
|
|
|
18
22
|
}
|
|
19
23
|
export class VeoRefused extends Error {
|
|
20
24
|
}
|
|
25
|
+
/** The spend in Postgres, one row a month, reserved atomically. */
|
|
26
|
+
export function pgLedger(db) {
|
|
27
|
+
let ready = null;
|
|
28
|
+
const ensure = () => (ready ??= db.query("CREATE TABLE IF NOT EXISTS veo_spend (month TEXT PRIMARY KEY, usd NUMERIC NOT NULL DEFAULT 0)").catch((error) => { ready = null; throw error; }));
|
|
29
|
+
return {
|
|
30
|
+
async spent(month) {
|
|
31
|
+
await ensure();
|
|
32
|
+
return Number((await db.query("SELECT usd FROM veo_spend WHERE month = $1", [month])).rows[0]?.["usd"] ?? 0);
|
|
33
|
+
},
|
|
34
|
+
async reserve(month, usd, cap) {
|
|
35
|
+
await ensure();
|
|
36
|
+
await db.query("INSERT INTO veo_spend (month, usd) VALUES ($1, 0) ON CONFLICT (month) DO NOTHING", [month]);
|
|
37
|
+
const done = await db.query("UPDATE veo_spend SET usd = usd + $2 WHERE month = $1 AND usd + $2 <= $3 RETURNING usd", [month, usd, cap + 1e-9]);
|
|
38
|
+
return done.rows.length > 0;
|
|
39
|
+
},
|
|
40
|
+
async refund(month, usd) {
|
|
41
|
+
await ensure();
|
|
42
|
+
await db.query("UPDATE veo_spend SET usd = GREATEST(0, usd - $2) WHERE month = $1", [month, usd]);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The spend in a file, for a nixamp with no database (its own state directory must survive restarts). */
|
|
47
|
+
export function fileLedger(file) {
|
|
48
|
+
let spent = {};
|
|
49
|
+
try {
|
|
50
|
+
spent = JSON.parse(readFileSync(file, "utf8")).spent ?? {};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
spent = {};
|
|
54
|
+
}
|
|
55
|
+
const save = () => {
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
58
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, spent }, null, 2)}\n`, { mode: 0o600 });
|
|
59
|
+
renameSync(temporary, file);
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
spent: async (month) => spent[month] ?? 0,
|
|
63
|
+
async reserve(month, usd, cap) {
|
|
64
|
+
if ((spent[month] ?? 0) + usd > cap + 1e-9)
|
|
65
|
+
return false;
|
|
66
|
+
spent[month] = (spent[month] ?? 0) + usd;
|
|
67
|
+
save();
|
|
68
|
+
return true;
|
|
69
|
+
},
|
|
70
|
+
async refund(month, usd) {
|
|
71
|
+
spent[month] = Math.max(0, (spent[month] ?? 0) - usd);
|
|
72
|
+
save();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
21
76
|
const BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
22
77
|
export class VeoStudio {
|
|
23
78
|
options;
|
|
24
|
-
spent = {};
|
|
25
79
|
constructor(options) {
|
|
26
80
|
this.options = options;
|
|
27
|
-
try {
|
|
28
|
-
this.spent = JSON.parse(readFileSync(options.file, "utf8")).spent ?? {};
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
this.spent = {};
|
|
32
|
-
}
|
|
33
81
|
}
|
|
34
82
|
static fileFor(stateDir, port) {
|
|
35
83
|
return join(stateDir, `veo-${port}.json`);
|
|
@@ -39,14 +87,8 @@ export class VeoStudio {
|
|
|
39
87
|
}
|
|
40
88
|
get cap() { return this.options.monthlyUsd ?? 50; }
|
|
41
89
|
get rate() { return this.options.usdPerSecond ?? 0.15; }
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const temporary = `${this.options.file}.${process.pid}.tmp`;
|
|
45
|
-
writeFileSync(temporary, `${JSON.stringify({ version: 1, spent: this.spent }, null, 2)}\n`, { mode: 0o600 });
|
|
46
|
-
renameSync(temporary, this.options.file);
|
|
47
|
-
}
|
|
48
|
-
status() {
|
|
49
|
-
const spent = this.spent[this.month()] ?? 0;
|
|
90
|
+
async status() {
|
|
91
|
+
const spent = await this.options.ledger.spent(this.month());
|
|
50
92
|
const left = Math.max(0, this.cap - spent);
|
|
51
93
|
return { month: this.month(), spentUsd: Math.round(spent * 100) / 100, capUsd: this.cap, leftUsd: Math.round(left * 100) / 100, usdPerSecond: this.rate, secondsLeft: Math.floor(left / this.rate) };
|
|
52
94
|
}
|
|
@@ -55,17 +97,14 @@ export class VeoStudio {
|
|
|
55
97
|
const seconds = [4, 6, 8].includes(input.seconds) ? input.seconds : 8;
|
|
56
98
|
const cost = seconds * this.rate;
|
|
57
99
|
const month = this.month();
|
|
58
|
-
|
|
100
|
+
// Reserved first, in one step that will not go over: two at once cannot both squeeze under the cap.
|
|
101
|
+
if (!(await this.options.ledger.reserve(month, cost, this.cap)))
|
|
59
102
|
throw new VeoBudgetError(`this month's $${this.cap} of Veo is spent`);
|
|
60
|
-
// Reserved first, so two at once cannot both squeeze under the cap.
|
|
61
|
-
this.spent[month] = (this.spent[month] ?? 0) + cost;
|
|
62
|
-
this.save();
|
|
63
103
|
try {
|
|
64
104
|
return await this.make(input.prompt, seconds, input.references.slice(0, 3));
|
|
65
105
|
}
|
|
66
106
|
catch (error) {
|
|
67
|
-
this.
|
|
68
|
-
this.save();
|
|
107
|
+
await this.options.ledger.refund(month, cost).catch(() => undefined);
|
|
69
108
|
throw error;
|
|
70
109
|
}
|
|
71
110
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The servers nixamp.com has seen listed lately, and who owns each, kept in
|
|
3
|
+
* Postgres.
|
|
4
|
+
*
|
|
5
|
+
* The directory lives in memory and fills again only as servers heartbeat, so
|
|
6
|
+
* for a minute after every deploy nixamp.com/music had no catalogue and a
|
|
7
|
+
* label server's Veo or release call was refused as "not listed". This keeps
|
|
8
|
+
* the last day's servers so both work straight away; a server still listed
|
|
9
|
+
* always wins over what is remembered.
|
|
10
|
+
*/
|
|
11
|
+
import type { Queryable } from "./veo-studio.ts";
|
|
12
|
+
|
|
13
|
+
export interface KnownServer { base: string; ownerId: string; seenAt: number }
|
|
14
|
+
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export class KnownServers {
|
|
18
|
+
private known = new Map<string, KnownServer>();
|
|
19
|
+
private ready: Promise<unknown> | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(private readonly db: Queryable, private readonly now: () => number = Date.now) {}
|
|
22
|
+
|
|
23
|
+
private ensure(): Promise<unknown> {
|
|
24
|
+
return (this.ready ??= this.db.query("CREATE TABLE IF NOT EXISTS music_servers (base TEXT PRIMARY KEY, owner_id TEXT NOT NULL, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())").catch((error: unknown) => { this.ready = null; throw error; }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the last process saw in the last day. */
|
|
28
|
+
async load(): Promise<void> {
|
|
29
|
+
await this.ensure();
|
|
30
|
+
const { rows } = await this.db.query("SELECT base, owner_id, seen_at FROM music_servers WHERE seen_at > $1", [new Date(this.now() - DAY_MS)]);
|
|
31
|
+
for (const row of rows) {
|
|
32
|
+
const base = String(row["base"]);
|
|
33
|
+
if (!this.known.has(base)) this.known.set(base, { base, ownerId: String(row["owner_id"]), seenAt: new Date(String(row["seen_at"])).getTime() });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The servers listed now: remembered here and in the database. */
|
|
38
|
+
async sync(listed: Array<{ base: string; ownerId: string }>): Promise<void> {
|
|
39
|
+
await this.ensure();
|
|
40
|
+
const at = this.now();
|
|
41
|
+
for (const one of listed) {
|
|
42
|
+
if (!one.base || !one.ownerId) continue;
|
|
43
|
+
this.known.set(one.base, { base: one.base, ownerId: one.ownerId, seenAt: at });
|
|
44
|
+
await this.db.query("INSERT INTO music_servers (base, owner_id, seen_at) VALUES ($1, $2, now()) ON CONFLICT (base) DO UPDATE SET owner_id = EXCLUDED.owner_id, seen_at = now()", [one.base, one.ownerId]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Servers seen in the last day. */
|
|
49
|
+
list(): KnownServer[] {
|
|
50
|
+
const since = this.now() - DAY_MS;
|
|
51
|
+
return [...this.known.values()].filter((one) => one.seenAt > since);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/music-hub.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
-
import { musicPage, renderMusicPage } from "./music-seo.ts";
|
|
16
|
+
import { memberProfile, musicPage, renderMusicPage } from "./music-seo.ts";
|
|
17
|
+
import { readMember } from "./label-profiles.ts";
|
|
17
18
|
|
|
18
19
|
/** A server's opaque id: the first 10 hex of the SHA-256 of its https origin. Both sides can work it out. */
|
|
19
20
|
export function serverId(base: string): string {
|
|
@@ -149,8 +150,11 @@ export async function musicPageFor(hub: MusicHub, path: string): Promise<string>
|
|
|
149
150
|
return "";
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/** A band member's OpenProfile as JSON (music-seo.ts memberProfile), read from their page on the server that holds it. */
|
|
154
|
+
const MEMBER_JSON = /^\/api\/v1\/music\/member\/([a-f0-9]{10})\/([a-f0-9]{10})\/([a-z0-9-]{1,40})\.json$/;
|
|
155
|
+
|
|
152
156
|
export function isMusicPath(path: string): boolean {
|
|
153
|
-
return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
|
|
157
|
+
return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || MEMBER_JSON.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
export interface MusicRoutesContext {
|
|
@@ -192,6 +196,25 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
|
|
|
192
196
|
ctx.json(response, 200, await ctx.hub.catalogue());
|
|
193
197
|
return true;
|
|
194
198
|
}
|
|
199
|
+
const person = MEMBER_JSON.exec(path);
|
|
200
|
+
if (person) {
|
|
201
|
+
const [, server, artistKey, slug] = person as unknown as [string, string, string, string];
|
|
202
|
+
const base = ctx.hub.resolve(server);
|
|
203
|
+
if (!base) { ctx.json(response, 404, { error: "no such server" }); return true; }
|
|
204
|
+
try {
|
|
205
|
+
const upstream = await fetch(`${base}/api/v1/label/${artistKey}/members/${slug}.md`, { signal: AbortSignal.timeout(10_000) });
|
|
206
|
+
const member = upstream.ok ? readMember(await upstream.text(), slug) : null;
|
|
207
|
+
if (!member) { ctx.json(response, 404, { error: "no such person" }); return true; }
|
|
208
|
+
const catalogue = await ctx.hub.catalogue().catch(() => null);
|
|
209
|
+
const band = catalogue?.genres.flatMap((one) => one.artists).find((one) => one.server === server && one.key === artistKey)?.name ?? "";
|
|
210
|
+
const profile = memberProfile(member, { site: (ctx.site ?? "https://nixamp.com").replace(/\/+$/, ""), server, artist: artistKey, band, holder: catalogue?.holders[0] ?? "Profullstack, Inc." });
|
|
211
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "public, max-age=300", "access-control-allow-origin": "*" });
|
|
212
|
+
response.end(method === "HEAD" ? undefined : JSON.stringify(profile, null, 2));
|
|
213
|
+
} catch {
|
|
214
|
+
if (!response.headersSent) ctx.json(response, 502, { error: "the server holding it did not answer" });
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
195
218
|
const file = FILE.exec(path);
|
|
196
219
|
if (file) {
|
|
197
220
|
const base = ctx.hub.resolve(file[1]!);
|
|
@@ -232,7 +255,7 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
|
|
|
232
255
|
// one without the server a page names is not knowing, not "no such page": the
|
|
233
256
|
// shell as it is, never a 404 a crawler would drop the page for.
|
|
234
257
|
const servers = new Set(catalogue?.genres.flatMap((one) => one.artists.map((artist) => artist.server)) ?? []);
|
|
235
|
-
const named = /^\/music\/(?:artist|album)\/([a-f0-9]{10})\//.exec(path)?.[1];
|
|
258
|
+
const named = /^\/music\/(?:artist|album|member)\/([a-f0-9]{10})\//.exec(path)?.[1];
|
|
236
259
|
const known = servers.size > 0 && (!named || servers.has(named));
|
|
237
260
|
const view = catalogue && known ? musicPage(path, catalogue, site) : null;
|
|
238
261
|
const html = view ? renderMusicPage(shell, view, `${site}${path.replace(/\/+$/, "") || "/music"}`) : shell;
|