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.
Files changed (39) hide show
  1. package/dist/known-servers.d.ts +33 -0
  2. package/dist/known-servers.js +40 -0
  3. package/dist/music-hub.js +33 -3
  4. package/dist/music-seo.d.ts +41 -0
  5. package/dist/music-seo.js +56 -0
  6. package/dist/server.js +22 -7
  7. package/dist/veo-studio.d.ts +18 -5
  8. package/dist/veo-studio.js +60 -21
  9. package/package.json +1 -1
  10. package/src/known-servers.ts +53 -0
  11. package/src/music-hub.ts +26 -3
  12. package/src/music-seo.ts +81 -0
  13. package/src/server.ts +21 -7
  14. package/src/veo-studio.ts +67 -25
  15. package/web/dist/assets/{ar-C7psoyOy.js → ar-a32-Hj6_.js} +1 -1
  16. package/web/dist/assets/{cs-IRnKEUzW.js → cs-CxuOXnwT.js} +1 -1
  17. package/web/dist/assets/{da-CceasMGe.js → da-Bp3UIwlY.js} +1 -1
  18. package/web/dist/assets/{de-CHN3y_5N.js → de-C-1FCWth.js} +1 -1
  19. package/web/dist/assets/{es-DQY1pBZl.js → es-6xqhs-Gh.js} +1 -1
  20. package/web/dist/assets/{fi-DRm07ra-.js → fi-DxcNMXAK.js} +1 -1
  21. package/web/dist/assets/{fr-BoyQjqiJ.js → fr-CWqXTENP.js} +1 -1
  22. package/web/dist/assets/{hi-htS106bK.js → hi-CWRULGV1.js} +1 -1
  23. package/web/dist/assets/{hu-aJuYy5Q1.js → hu-BoA-Yeym.js} +1 -1
  24. package/web/dist/assets/{id-DjhPS4Qs.js → id-CKslltWx.js} +1 -1
  25. package/web/dist/assets/{it-i1I9NlWC.js → it-CL8gEi58.js} +1 -1
  26. package/web/dist/assets/{main-ImY-JbNz.js → main-Cj3YY0GY.js} +2 -2
  27. package/web/dist/assets/music-CQa4G4bG.js +1 -0
  28. package/web/dist/assets/music-CVLJhYTF.css +1 -0
  29. package/web/dist/assets/{nl-CeUR4KXE.js → nl-BePQfdXl.js} +1 -1
  30. package/web/dist/assets/{ru-BJkAVfoj.js → ru-C2zYQTOO.js} +1 -1
  31. package/web/dist/assets/{sv-F3gM7kjC.js → sv-BIF1MTUK.js} +1 -1
  32. package/web/dist/assets/{uk-Dtj0jb_m.js → uk-B0b55c6z.js} +1 -1
  33. package/web/dist/assets/{vi-CLFmHDR1.js → vi-DBrZrfoI.js} +1 -1
  34. package/web/dist/assets/{zh-C37VYmRU.js → zh-Dghotb2J.js} +1 -1
  35. package/web/dist/index.html +1 -1
  36. package/web/dist/music.html +2 -2
  37. package/web/dist/sw.js +21 -21
  38. package/web/dist/assets/music-B-SPdokI.js +0 -1
  39. 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;
@@ -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 = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean) : [];
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
- file: VeoStudio.fileFor(stateDir(), options.port),
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
- return Boolean(me && base && directory?.list().some((one) => one.ownerId === me.id && peerBase(one.url) === base));
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
  };
@@ -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
- file: string;
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
- private save;
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;
@@ -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
- save() {
43
- mkdirSync(dirname(this.options.file), { recursive: true });
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
- if ((this.spent[month] ?? 0) + cost > this.cap + 1e-9)
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.spent[month] = Math.max(0, (this.spent[month] ?? 0) - cost);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.60.3",
3
+ "version": "0.61.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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;