nixamp 0.49.0 → 0.51.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/ai-label.d.ts +29 -0
- package/dist/ai-label.js +135 -2
- package/dist/label-routes.d.ts +15 -2
- package/dist/label-routes.js +68 -9
- package/dist/music-store.d.ts +121 -0
- package/dist/music-store.js +289 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +73 -12
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +3 -3
- package/dist/store-routes.d.ts +16 -0
- package/dist/store-routes.js +189 -0
- package/dist/support.d.ts +14 -0
- package/dist/support.js +41 -0
- package/dist/track-facts.d.ts +8 -0
- package/dist/track-facts.js +1 -1
- package/dist/zip-stream.d.ts +13 -0
- package/dist/zip-stream.js +119 -0
- package/package.json +1 -1
- package/src/ai-label.ts +133 -2
- package/src/label-routes.ts +77 -9
- package/src/music-store.ts +333 -0
- package/src/server.ts +63 -4
- package/src/station-usage.ts +3 -3
- package/src/store-routes.ts +204 -0
- package/src/support.ts +39 -0
- package/src/track-facts.ts +3 -1
- package/src/zip-stream.ts +128 -0
- package/web/dist/assets/{ar-BEVjqkqM.js → ar--GRGOL7F.js} +1 -1
- package/web/dist/assets/{cs-DjazXQ15.js → cs-B2ab-8qO.js} +1 -1
- package/web/dist/assets/{da-DnyF6SYK.js → da-Ek2g1uTg.js} +1 -1
- package/web/dist/assets/{de--q1ph9Ez.js → de-CM7-9Fbk.js} +1 -1
- package/web/dist/assets/{es-Do3DkBwD.js → es-BFSOrr3I.js} +1 -1
- package/web/dist/assets/{fi-B7sJTx1N.js → fi-D9Tf69sv.js} +1 -1
- package/web/dist/assets/{fr-Dy7f5rGi.js → fr-Gh9yYJGo.js} +1 -1
- package/web/dist/assets/{hi-C9vSnTQ4.js → hi-B3ow3HJC.js} +1 -1
- package/web/dist/assets/{hu-h-eueQE8.js → hu-paju-6Kr.js} +1 -1
- package/web/dist/assets/{id-D8Ffd-Pc.js → id-mubOQFX0.js} +1 -1
- package/web/dist/assets/{it-BKM_2rj1.js → it-0EM-qnDT.js} +1 -1
- package/web/dist/assets/{main-Dl_uRj22.js → main-CECXge6G.js} +2 -2
- package/web/dist/assets/{nl-Cj3ETrcl.js → nl-c6HalphH.js} +1 -1
- package/web/dist/assets/{radio-zPgyISLf.js → radio-CDUEoM0y.js} +1 -1
- package/web/dist/assets/{ru-B5zmGSnG.js → ru-CUKYVlgf.js} +1 -1
- package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
- package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
- package/web/dist/assets/{sv-BMtjA1uI.js → sv-BUxBsZR5.js} +1 -1
- package/web/dist/assets/{uk-B2vuB6Un.js → uk-708lBssa.js} +1 -1
- package/web/dist/assets/{vi-CXam3lDh.js → vi-B1ropUvk.js} +1 -1
- package/web/dist/assets/{zh-DnYhyzqb.js → zh-C_ndTsNh.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +12 -1
- package/web/dist/station.html +7 -2
- package/web/dist/sw.js +25 -25
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
- /package/web/dist/assets/{watch-hdfnhm3C.js → watch-BbhPO0HS.js} +0 -0
package/src/label-routes.ts
CHANGED
|
@@ -10,20 +10,34 @@ import { createHash } from "node:crypto";
|
|
|
10
10
|
import { createReadStream, statSync } from "node:fs";
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
12
|
import { ROSTER, type AiLabel, type PublicArtist } from "./ai-label.ts";
|
|
13
|
+
import { covers, PRICES, type GrantVerifier } from "./music-store.ts";
|
|
14
|
+
import { writeZip, zipSize } from "./zip-stream.ts";
|
|
13
15
|
import { StationError } from "./stations.ts";
|
|
14
16
|
|
|
15
17
|
// Listening is free on the live stations (with their breaks); the songs as
|
|
16
|
-
// files are sold, so only the words and the cover are public here
|
|
17
|
-
|
|
18
|
+
// files are sold, so only the words and the cover are public here, and an
|
|
19
|
+
// MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
|
|
20
|
+
const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.(?:txt|mp3)|cover\.jpg)$/;
|
|
18
21
|
|
|
19
|
-
/** Whether this path is the label's public face. */
|
|
22
|
+
/** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
|
|
20
23
|
export function isPublicLabelPath(path: string): boolean {
|
|
21
|
-
return path === "/label" || path === "/api/v1/label" || FILE.test(path);
|
|
24
|
+
return path === "/label" || path === "/api/v1/label" || path === "/api/v1/label/zip" || FILE.test(path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** nixamp.com's page that sells this: a song, an album or everything by the artist. */
|
|
28
|
+
export function buyLink(site: string, server: string, kind: "song" | "album" | "artist", artist: string, album = "", n = 0): string {
|
|
29
|
+
const query = new URLSearchParams({ server, kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) });
|
|
30
|
+
return `${site}/buy?${query.toString()}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export interface LabelRoutesContext {
|
|
25
34
|
label: AiLabel;
|
|
26
35
|
canControl: boolean;
|
|
36
|
+
/** Checks a store grant with nixamp.com; without it nothing is sold or handed out. */
|
|
37
|
+
grants?: GrantVerifier;
|
|
38
|
+
/** nixamp.com, where the store is, and this server's public address (for Buy links). */
|
|
39
|
+
site?: string;
|
|
40
|
+
publicBase?: string;
|
|
27
41
|
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
28
42
|
readBody: (request: IncomingMessage) => Promise<string>;
|
|
29
43
|
}
|
|
@@ -91,7 +105,8 @@ export async function handleLabelRoutes(
|
|
|
91
105
|
return true;
|
|
92
106
|
}
|
|
93
107
|
if (path === "/label") {
|
|
94
|
-
const
|
|
108
|
+
const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase! } : undefined;
|
|
109
|
+
const page = renderLabel(label.publicView(), shop);
|
|
95
110
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
|
|
96
111
|
response.end(method === "HEAD" ? undefined : page);
|
|
97
112
|
return true;
|
|
@@ -100,7 +115,45 @@ export async function handleLabelRoutes(
|
|
|
100
115
|
ctx.json(response, 200, label.publicView());
|
|
101
116
|
return true;
|
|
102
117
|
}
|
|
118
|
+
const url = new URL(request.url ?? "/", "http://x");
|
|
119
|
+
const grant = url.searchParams.get("grant") ?? "";
|
|
120
|
+
if (path === "/api/v1/label/zip") {
|
|
121
|
+
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
|
|
122
|
+
if (!scope) {
|
|
123
|
+
ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const zip = label.zipFor(scope);
|
|
127
|
+
if (!zip) {
|
|
128
|
+
ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
response.writeHead(200, {
|
|
132
|
+
"content-type": "application/zip",
|
|
133
|
+
"content-length": String(zipSize(zip.entries)),
|
|
134
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(zip.name)}`,
|
|
135
|
+
"cache-control": "private, no-store",
|
|
136
|
+
});
|
|
137
|
+
if (method === "HEAD") {
|
|
138
|
+
response.end();
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
await writeZip(response, zip.entries).catch(() => response.destroy());
|
|
142
|
+
response.end();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
103
145
|
const [, artist, album, what] = FILE.exec(path)!;
|
|
146
|
+
if (what!.endsWith(".mp3")) {
|
|
147
|
+
const scope = ctx.grants && grant ? await ctx.grants.check(grant) : null;
|
|
148
|
+
const n = Number(what!.split(".")[0]);
|
|
149
|
+
const file = scope && covers(scope, artist!, album!, n) ? label.songFile(artist!, album!, n) : "";
|
|
150
|
+
if (!file) {
|
|
151
|
+
ctx.json(response, 403, { error: "songs as files are for the people who bought them; listening on the live stations is free", code: "not_bought" });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
sendFile(request, response, file, "audio/mpeg", file.split("/").pop() ?? "");
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
104
157
|
const found = label.publicFile(artist!, album!, what!);
|
|
105
158
|
if (!found) {
|
|
106
159
|
ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
|
|
@@ -213,6 +266,7 @@ audio{width:100%;max-width:360px;height:36px}
|
|
|
213
266
|
details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
|
|
214
267
|
pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
|
|
215
268
|
.dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
|
|
269
|
+
a.buy{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:2px 10px}
|
|
216
270
|
footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
217
271
|
`;
|
|
218
272
|
|
|
@@ -225,25 +279,39 @@ export const LABEL_CSP = [
|
|
|
225
279
|
"base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
226
280
|
].join("; ");
|
|
227
281
|
|
|
228
|
-
|
|
282
|
+
type Shop = { site: string; server: string } | undefined;
|
|
283
|
+
|
|
284
|
+
function price(cents: number): string {
|
|
285
|
+
return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buy(shop: Shop, kind: "song" | "album" | "artist", artist: string, album = "", n = 0, what = ""): string {
|
|
289
|
+
if (!shop) return "";
|
|
290
|
+
return `<a class="buy" href="${escape(buyLink(shop.site, shop.server, kind, artist, album, n))}">Download ${what || kind} · ${price(PRICES[kind])}</a>`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderAlbum(artist: PublicArtist, album: PublicArtist["albums"][number], shop: Shop): string {
|
|
229
294
|
const base = `/api/v1/label/${artist.key}/${album.key}`;
|
|
230
295
|
const cover = album.cover ? `<img class="cover" src="${base}/cover.jpg" alt="Cover of ${escape(album.title)}" width="160" height="160" loading="lazy">` : `<div class="blank" aria-hidden="true"></div>`;
|
|
231
296
|
const songs = album.songs.map((song) => `<li><div class="song"><span class="n">${song.n}.</span><span class="t">${escape(song.title)}</span><span class="d">${minutes(song.seconds)}</span></div>`
|
|
232
297
|
+ `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
|
|
233
|
-
+ `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a
|
|
298
|
+
+ `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a>${shop ? ` · ${buy(shop, "song", artist.key, album.key, song.n, "MP3")}` : ""}</div></li>`).join("");
|
|
234
299
|
return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
|
|
235
300
|
+ `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
|
|
236
301
|
+ `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
|
|
302
|
+
+ `${shop ? `<p>${buy(shop, "album", artist.key, album.key, 0, "the album, MP3s with art and lyrics")}</p>` : ""}`
|
|
237
303
|
+ `<ol class="songs">${songs}</ol></div></div>`;
|
|
238
304
|
}
|
|
239
305
|
|
|
240
306
|
/** The label's catalogue as a page: no script, plays in any browser. */
|
|
241
|
-
export function renderLabel(view: ReturnType<AiLabel["publicView"]
|
|
307
|
+
export function renderLabel(view: ReturnType<AiLabel["publicView"]>, shop?: Shop): string {
|
|
242
308
|
const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
243
309
|
const nav = view.genres.length > 1 ? `<nav class="genres" aria-label="Genres">${view.genres.map((one) => `<a href="#${escape(one.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(one.genre)}</a>`).join("")}</nav>` : "";
|
|
244
310
|
const sections = view.genres.map((genre) => `<section class="genre" id="${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" aria-labelledby="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">`
|
|
245
311
|
+ `<h2 id="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(genre.genre)}${genre.station ? ` <a href="/watch/${escape(genre.station)}">Listen live, free →</a>` : ""}</h2>`
|
|
246
|
-
+ genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}
|
|
312
|
+
+ genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}`
|
|
313
|
+
+ `${shop ? `<p>${buy(shop, "artist", artist.key, "", 0, "everything by them")}</p>` : ""}`
|
|
314
|
+
+ `${artist.albums.map((album) => renderAlbum(artist, album, shop)).join("")}</article>`).join("")
|
|
247
315
|
+ `</section>`).join("");
|
|
248
316
|
return `<!doctype html>
|
|
249
317
|
<html lang="en">
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The music store on nixamp.com: the AI label's songs as files, bought with
|
|
3
|
+
* crypto through CoinPay.
|
|
4
|
+
*
|
|
5
|
+
* Listening is free on the live stations (with their breaks). What is sold is
|
|
6
|
+
* the files: a song for 10 cents, an album for $1, everything by an artist
|
|
7
|
+
* for $2, as a zip made on the fly by the server that holds them (MP3s with
|
|
8
|
+
* the cover and the words inside, each song's .lyrics.txt, cover.jpg and
|
|
9
|
+
* album.json). A buyer gives an email; once CoinPay says paid (its webhook,
|
|
10
|
+
* or asked when the buyer comes back), the email gets a link to the purchase,
|
|
11
|
+
* and the account with that email sees every purchase under My music on
|
|
12
|
+
* nixamp.com/radio, to play or download again.
|
|
13
|
+
*
|
|
14
|
+
* nixamp.com never holds the music. It signs a grant -- what was bought, from
|
|
15
|
+
* which server, until when -- and the server asks nixamp.com whether a grant
|
|
16
|
+
* is good before it plays or zips anything (/api/v1/store/grant).
|
|
17
|
+
*/
|
|
18
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
19
|
+
import type { Db, Support } from "./support.ts";
|
|
20
|
+
import { SupportError } from "./support.ts";
|
|
21
|
+
|
|
22
|
+
export type Kind = "song" | "album" | "artist";
|
|
23
|
+
|
|
24
|
+
/** In cents. */
|
|
25
|
+
export const PRICES: Record<Kind, number> = { song: 10, album: 100, artist: 200 };
|
|
26
|
+
|
|
27
|
+
const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
|
|
28
|
+
const KEY = /^[a-f0-9]{10}$/;
|
|
29
|
+
const ALBUM = /^[a-f0-9]{8}$/;
|
|
30
|
+
/** A grant is good for a day; pages mint a fresh one each time. */
|
|
31
|
+
const GRANT_MS = 24 * 3600_000;
|
|
32
|
+
|
|
33
|
+
export interface Scope {
|
|
34
|
+
server: string;
|
|
35
|
+
kind: Kind;
|
|
36
|
+
artist: string;
|
|
37
|
+
album: string;
|
|
38
|
+
n: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Purchase extends Scope {
|
|
42
|
+
id: string;
|
|
43
|
+
token: string;
|
|
44
|
+
email: string;
|
|
45
|
+
userId: string;
|
|
46
|
+
title: string;
|
|
47
|
+
artistName: string;
|
|
48
|
+
amountCents: number;
|
|
49
|
+
coin: string;
|
|
50
|
+
paymentId: string;
|
|
51
|
+
status: "pending" | "paid";
|
|
52
|
+
createdAt: number;
|
|
53
|
+
paidAt: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface LabelSong { n: number; title: string }
|
|
57
|
+
interface LabelAlbum { key: string; title: string; year: number; songs: LabelSong[] }
|
|
58
|
+
interface LabelArtist { key: string; name: string; albums: LabelAlbum[] }
|
|
59
|
+
interface LabelView { holder: string; genres: Array<{ genre: string; artists: LabelArtist[] }> }
|
|
60
|
+
|
|
61
|
+
/** A server's public address as given: https, no path, no trailing slash. */
|
|
62
|
+
export function serverBase(value: unknown): string {
|
|
63
|
+
if (typeof value !== "string") return "";
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(value);
|
|
66
|
+
if (url.protocol !== "https:" || url.username || url.password) return "";
|
|
67
|
+
return url.origin;
|
|
68
|
+
} catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sign(secret: string, text: string): string {
|
|
74
|
+
return createHmac("sha256", secret).update(text).digest("base64url");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `g1.<exp>.<kind>.<artist>.<album|->.<n>.<server, base64url>.<sig>` */
|
|
78
|
+
export function makeGrant(secret: string, scope: Scope, exp: number): string {
|
|
79
|
+
const body = ["g1", String(exp), scope.kind, scope.artist, scope.album || "-", String(scope.n), Buffer.from(scope.server).toString("base64url")].join(".");
|
|
80
|
+
return `${body}.${sign(secret, body)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readGrant(secret: string, grant: string, now = Date.now()): (Scope & { exp: number }) | null {
|
|
84
|
+
const parts = grant.split(".");
|
|
85
|
+
if (parts.length !== 8 || parts[0] !== "g1") return null;
|
|
86
|
+
const body = parts.slice(0, 7).join(".");
|
|
87
|
+
const expected = Buffer.from(sign(secret, body));
|
|
88
|
+
const given = Buffer.from(parts[7]!);
|
|
89
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
|
|
90
|
+
const exp = Number(parts[1]);
|
|
91
|
+
if (!Number.isFinite(exp) || exp < now) return null;
|
|
92
|
+
const kind = parts[2] as Kind;
|
|
93
|
+
if (!(kind in PRICES)) return null;
|
|
94
|
+
return { kind, artist: parts[3]!, album: parts[4] === "-" ? "" : parts[4]!, n: Number(parts[5]), server: Buffer.from(parts[6]!, "base64url").toString("utf8"), exp };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether a grant for `scope` covers this file of the catalogue. */
|
|
98
|
+
export function covers(scope: Pick<Scope, "kind" | "artist" | "album" | "n">, artist: string, album: string, n?: number): boolean {
|
|
99
|
+
if (scope.artist !== artist) return false;
|
|
100
|
+
if (scope.kind === "artist") return true;
|
|
101
|
+
if (scope.album !== album) return false;
|
|
102
|
+
if (scope.kind === "album") return true;
|
|
103
|
+
return n === undefined ? false : scope.n === n;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* On a server that holds the music: whether nixamp.com signed this grant,
|
|
108
|
+
* asked at /api/v1/store/grant and remembered (a good one until it expires,
|
|
109
|
+
* a refused one for 5 minutes). nixamp.com itself checks with its secret.
|
|
110
|
+
*/
|
|
111
|
+
export class GrantVerifier {
|
|
112
|
+
private known = new Map<string, { scope: (Scope & { exp: number }) | null; until: number }>();
|
|
113
|
+
|
|
114
|
+
constructor(private readonly options: { site: string; secret?: string; fetcher?: typeof fetch; now?: () => number }) {}
|
|
115
|
+
|
|
116
|
+
async check(grant: string): Promise<(Scope & { exp: number }) | null> {
|
|
117
|
+
const now = this.options.now?.() ?? Date.now();
|
|
118
|
+
if (!/^g1\.[A-Za-z0-9._-]{20,600}$/.test(grant)) return null;
|
|
119
|
+
if (this.options.secret) return readGrant(this.options.secret, grant, now);
|
|
120
|
+
const hit = this.known.get(grant);
|
|
121
|
+
if (hit && hit.until > now) return hit.scope;
|
|
122
|
+
try {
|
|
123
|
+
const response = await (this.options.fetcher ?? fetch)(`${this.options.site}/api/v1/store/grant?g=${encodeURIComponent(grant)}`, { signal: AbortSignal.timeout(5_000) });
|
|
124
|
+
if (response.status >= 500) return null;
|
|
125
|
+
const body = await response.json() as { ok?: boolean; scope?: Scope & { exp: number } };
|
|
126
|
+
const scope = body.ok && body.scope ? body.scope : null;
|
|
127
|
+
if (this.known.size > 5000) this.known.clear();
|
|
128
|
+
this.known.set(grant, { scope, until: scope ? scope.exp : now + 5 * 60_000 });
|
|
129
|
+
return scope;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function row(one: Record<string, unknown>): Purchase {
|
|
137
|
+
return {
|
|
138
|
+
id: String(one["id"]), token: String(one["token"]), email: String(one["email"]), userId: String(one["user_id"] ?? ""),
|
|
139
|
+
server: String(one["server"]), kind: String(one["kind"]) as Kind, artist: String(one["artist"]), album: String(one["album"] ?? ""), n: Number(one["n"] ?? 0),
|
|
140
|
+
title: String(one["title"]), artistName: String(one["artist_name"]), amountCents: Number(one["amount_cents"]), coin: String(one["coin"]),
|
|
141
|
+
paymentId: String(one["payment_id"] ?? ""), status: one["status"] === "paid" ? "paid" : "pending",
|
|
142
|
+
createdAt: new Date(String(one["created_at"])).getTime(), paidAt: one["paid_at"] ? new Date(String(one["paid_at"])).getTime() : 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface MusicStoreOptions {
|
|
147
|
+
db: Db;
|
|
148
|
+
support: Support;
|
|
149
|
+
/** Resend; null leaves the receipt unsent (and says so in the log). */
|
|
150
|
+
mail: { apiKey: string; from: string } | null;
|
|
151
|
+
site: string;
|
|
152
|
+
/** Signs the grants; derived from NIXAMP_JWT_SECRET. */
|
|
153
|
+
secret: string;
|
|
154
|
+
/** The servers whose catalogues may be sold here (the directory and the guide's peers). */
|
|
155
|
+
servers: () => string[];
|
|
156
|
+
fetcher?: typeof fetch;
|
|
157
|
+
now?: () => number;
|
|
158
|
+
log?: (line: string) => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class MusicStore {
|
|
162
|
+
private schema: Promise<void> | null = null;
|
|
163
|
+
|
|
164
|
+
constructor(private readonly options: MusicStoreOptions) {}
|
|
165
|
+
|
|
166
|
+
private now(): number {
|
|
167
|
+
return this.options.now?.() ?? Date.now();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private ready(): Promise<void> {
|
|
171
|
+
this.schema ??= (async () => {
|
|
172
|
+
await this.options.db.query(`CREATE TABLE IF NOT EXISTS music_purchases (
|
|
173
|
+
id TEXT PRIMARY KEY, token TEXT NOT NULL, email TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '',
|
|
174
|
+
server TEXT NOT NULL, kind TEXT NOT NULL, artist TEXT NOT NULL, album TEXT NOT NULL DEFAULT '', n INTEGER NOT NULL DEFAULT 0,
|
|
175
|
+
title TEXT NOT NULL, artist_name TEXT NOT NULL, amount_cents INTEGER NOT NULL, coin TEXT NOT NULL,
|
|
176
|
+
payment_id TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ)`);
|
|
177
|
+
await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_email ON music_purchases (lower(email))");
|
|
178
|
+
await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_user ON music_purchases (user_id)");
|
|
179
|
+
await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_payment ON music_purchases (payment_id)");
|
|
180
|
+
})().catch((error: unknown) => { this.schema = null; throw error; });
|
|
181
|
+
return this.schema;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A server's catalogue, as it publishes it at /api/v1/label. */
|
|
185
|
+
private async catalogue(server: string): Promise<LabelView> {
|
|
186
|
+
const response = await (this.options.fetcher ?? fetch)(`${server}/api/v1/label`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
|
|
187
|
+
if (!response.ok) throw new SupportError("That server's catalogue could not be read. Try again.", 502, "catalogue");
|
|
188
|
+
return await response.json() as LabelView;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What is for sale at this address, with its name and price; refused when there is no such thing. */
|
|
192
|
+
async describe(input: Record<string, unknown>): Promise<Scope & { title: string; artistName: string; amountCents: number; songs: number }> {
|
|
193
|
+
const server = serverBase(input["server"]);
|
|
194
|
+
// A server in the directory or the guide's peers, or one named under nixamp.com (whose names
|
|
195
|
+
// and certificates nixamp.com issues): the directory empties on every restart of nixamp.com.
|
|
196
|
+
const named = server ? new URL(server).hostname.endsWith(".nixamp.com") : false;
|
|
197
|
+
if (!server || !(named || this.options.servers().includes(server))) throw new SupportError("That server does not sell music here.", 404, "not_found");
|
|
198
|
+
const kind = String(input["kind"] ?? "") as Kind;
|
|
199
|
+
if (!(kind in PRICES)) throw new SupportError("Buy a song, an album or an artist.", 400, "invalid_kind");
|
|
200
|
+
const artistKey = String(input["artist"] ?? "");
|
|
201
|
+
const albumKey = kind === "artist" ? "" : String(input["album"] ?? "");
|
|
202
|
+
const n = kind === "song" ? Number(input["n"]) : 0;
|
|
203
|
+
if (!KEY.test(artistKey) || (kind !== "artist" && !ALBUM.test(albumKey)) || (kind === "song" && !(Number.isInteger(n) && n > 0))) throw new SupportError("No such music.", 404, "not_found");
|
|
204
|
+
const view = await this.catalogue(server);
|
|
205
|
+
const artist = view.genres.flatMap((one) => one.artists).find((one) => one.key === artistKey);
|
|
206
|
+
const album = artist?.albums.find((one) => one.key === albumKey);
|
|
207
|
+
const song = album?.songs.find((one) => one.n === n);
|
|
208
|
+
if (!artist || (kind !== "artist" && !album) || (kind === "song" && !song)) throw new SupportError("No such music.", 404, "not_found");
|
|
209
|
+
const title = kind === "artist" ? `Everything by ${artist.name}` : kind === "album" ? `${album!.title} (${album!.year})` : `${song!.title}`;
|
|
210
|
+
const songs = kind === "artist" ? artist.albums.reduce((sum, one) => sum + one.songs.length, 0) : kind === "album" ? album!.songs.length : 1;
|
|
211
|
+
return { server, kind, artist: artistKey, album: albumKey, n, title, artistName: artist.name, amountCents: PRICES[kind], songs };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A purchase waiting for its payment, and where to pay. Signed in, it is the account's too. */
|
|
215
|
+
async checkout(input: Record<string, unknown>, account: { id: string; email: string } | null): Promise<{ url: string; id: string; token: string }> {
|
|
216
|
+
const email = (typeof input["email"] === "string" && input["email"].trim() ? input["email"] : account?.email ?? "").trim().toLowerCase();
|
|
217
|
+
if (!EMAIL.test(email)) throw new SupportError("Give the email your music should go to.", 400, "invalid_email");
|
|
218
|
+
const item = await this.describe(input);
|
|
219
|
+
await this.ready();
|
|
220
|
+
const id = randomUUID();
|
|
221
|
+
const token = randomUUID().replace(/-/g, "");
|
|
222
|
+
const checkout = await this.options.support.pay({
|
|
223
|
+
amountUsd: item.amountCents / 100,
|
|
224
|
+
coin: input["coin"],
|
|
225
|
+
order: `music-${id}`,
|
|
226
|
+
description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
|
|
227
|
+
metadata: { product: "music", purchase: id, kind: item.kind },
|
|
228
|
+
redirect: this.link(id, token),
|
|
229
|
+
});
|
|
230
|
+
await this.options.db.query(`INSERT INTO music_purchases (id, token, email, user_id, server, kind, artist, album, n, title, artist_name, amount_cents, coin, payment_id, created_at)
|
|
231
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
|
|
232
|
+
[id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, new Date(this.now())]);
|
|
233
|
+
return { url: checkout.url, id, token };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The purchase's own page, which is also the receipt link. */
|
|
237
|
+
link(id: string, token: string): string {
|
|
238
|
+
return `${this.options.site}/purchases/${id}?t=${token}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A purchase by its link; a pending one asks CoinPay whether it has been paid since. */
|
|
242
|
+
async get(id: string, token: string): Promise<Purchase | null> {
|
|
243
|
+
await this.ready();
|
|
244
|
+
const found = (await this.options.db.query("SELECT * FROM music_purchases WHERE id = $1 AND token = $2", [id, token])).rows[0];
|
|
245
|
+
if (!found) return null;
|
|
246
|
+
const purchase = row(found);
|
|
247
|
+
if (purchase.status === "pending" && purchase.paymentId && (await this.options.support.isPaid(purchase.paymentId)) === true) return (await this.paid(purchase.id)) ?? purchase;
|
|
248
|
+
return purchase;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** CoinPay's webhook: a payment of ours that is now paid. False when it is not a music purchase. */
|
|
252
|
+
async settle(paymentId: string): Promise<{ settled: boolean; purchase?: string }> {
|
|
253
|
+
await this.ready();
|
|
254
|
+
const found = (await this.options.db.query("SELECT id, status FROM music_purchases WHERE payment_id = $1", [paymentId])).rows[0];
|
|
255
|
+
if (!found) return { settled: false };
|
|
256
|
+
if (found["status"] === "paid") return { settled: true, purchase: String(found["id"]) };
|
|
257
|
+
if ((await this.options.support.isPaid(paymentId)) !== true) return { settled: false, purchase: String(found["id"]) };
|
|
258
|
+
await this.paid(String(found["id"]));
|
|
259
|
+
return { settled: true, purchase: String(found["id"]) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Whether CoinPay's webhook is about one of ours. */
|
|
263
|
+
async owns(paymentId: string): Promise<boolean> {
|
|
264
|
+
await this.ready();
|
|
265
|
+
return (await this.options.db.query("SELECT 1 FROM music_purchases WHERE payment_id = $1", [paymentId])).rows.length > 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Marks it paid once (the first to get there sends the receipt). */
|
|
269
|
+
private async paid(id: string): Promise<Purchase | null> {
|
|
270
|
+
const updated = (await this.options.db.query("UPDATE music_purchases SET status = 'paid', paid_at = $2 WHERE id = $1 AND status = 'pending' RETURNING *", [id, new Date(this.now())])).rows[0];
|
|
271
|
+
if (!updated) {
|
|
272
|
+
const again = (await this.options.db.query("SELECT * FROM music_purchases WHERE id = $1", [id])).rows[0];
|
|
273
|
+
return again ? row(again) : null;
|
|
274
|
+
}
|
|
275
|
+
const purchase = row(updated);
|
|
276
|
+
await this.mail(purchase.email, `Your music: ${purchase.title}`, [
|
|
277
|
+
`Thank you. "${purchase.title}" by ${purchase.artistName} is yours: MP3s with the cover art and the lyrics, in one zip.`,
|
|
278
|
+
`Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
|
|
279
|
+
`Sign in to nixamp.com with ${purchase.email} and everything you have bought is under My music on ${this.options.site}/radio#music, to play or download again.`,
|
|
280
|
+
]);
|
|
281
|
+
return purchase;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Every paid purchase of this account: made signed in, or to its email. */
|
|
285
|
+
async forAccount(account: { id: string; email: string }): Promise<Purchase[]> {
|
|
286
|
+
await this.ready();
|
|
287
|
+
const rows = await this.options.db.query(
|
|
288
|
+
"SELECT * FROM music_purchases WHERE status = 'paid' AND (user_id = $1 OR lower(email) = lower($2)) ORDER BY paid_at DESC LIMIT 500",
|
|
289
|
+
[account.id, account.email],
|
|
290
|
+
);
|
|
291
|
+
return rows.rows.map(row);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
grant(purchase: Scope): string {
|
|
295
|
+
return makeGrant(this.options.secret, purchase, this.now() + GRANT_MS);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
check(grant: string): (Scope & { exp: number }) | null {
|
|
299
|
+
return readGrant(this.options.secret, grant, this.now());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Where the zip of a purchase is made: on the server that holds the music. */
|
|
303
|
+
zipUrl(purchase: Purchase): string {
|
|
304
|
+
return `${purchase.server}/api/v1/label/zip?grant=${encodeURIComponent(this.grant(purchase))}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** What anybody with the purchase's link (or the account) is shown. */
|
|
308
|
+
view(purchase: Purchase): Record<string, unknown> {
|
|
309
|
+
return {
|
|
310
|
+
id: purchase.id, kind: purchase.kind, title: purchase.title, artistName: purchase.artistName, server: purchase.server,
|
|
311
|
+
artist: purchase.artist, album: purchase.album, n: purchase.n, amountUsd: purchase.amountCents / 100, coin: purchase.coin,
|
|
312
|
+
status: purchase.status, createdAt: purchase.createdAt, paidAt: purchase.paidAt,
|
|
313
|
+
...(purchase.status === "paid" ? { grant: this.grant(purchase), zip: this.zipUrl(purchase), link: this.link(purchase.id, purchase.token) } : {}),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async mail(to: string, subject: string, lines: string[]): Promise<void> {
|
|
318
|
+
if (!this.options.mail) {
|
|
319
|
+
this.options.log?.(` store: no RESEND_API_KEY, so "${subject}" to ${to} was not sent`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
await (this.options.fetcher ?? fetch)("https://api.resend.com/emails", {
|
|
324
|
+
method: "POST",
|
|
325
|
+
signal: AbortSignal.timeout(15_000),
|
|
326
|
+
headers: { authorization: `Bearer ${this.options.mail.apiKey}`, "content-type": "application/json" },
|
|
327
|
+
body: JSON.stringify({ from: this.options.mail.from, to: [to], subject, text: `${lines.join("\n\n")}\n\nnixamp.com` }),
|
|
328
|
+
});
|
|
329
|
+
} catch (error) {
|
|
330
|
+
this.options.log?.(` store: mail to ${to} failed: ${(error as Error).message}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -79,7 +79,9 @@ import { Importer } from "./handoff.ts";
|
|
|
79
79
|
import { handleHandoffs } from "./handoff-routes.ts";
|
|
80
80
|
import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
|
|
81
81
|
import { AiLabel } from "./ai-label.ts";
|
|
82
|
-
import { handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
|
|
82
|
+
import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
|
|
83
|
+
import { GrantVerifier, MusicStore } from "./music-store.ts";
|
|
84
|
+
import { handleStoreRoutes, isStorePath } from "./store-routes.ts";
|
|
83
85
|
import { AiRadio } from "./ai-radio.ts";
|
|
84
86
|
import { ListenerShares } from "./listener-shares.ts";
|
|
85
87
|
import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
|
|
@@ -1950,6 +1952,8 @@ export interface HandlerOptions {
|
|
|
1950
1952
|
supportSubscriptions?: SupportSubscriptions;
|
|
1951
1953
|
/** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
|
|
1952
1954
|
coinpayWebhookSecret?: string;
|
|
1955
|
+
/** The AI label's songs for sale, nixamp.com only (music-store.ts). */
|
|
1956
|
+
musicStore?: MusicStore;
|
|
1953
1957
|
sharedTranslations?: SharedTranslations;
|
|
1954
1958
|
/** Translation: texts in another language, by a model here. The same optional library. */
|
|
1955
1959
|
translator?: Translator;
|
|
@@ -2040,6 +2044,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2040
2044
|
site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
|
|
2041
2045
|
...(listenPassKey ? { secret: listenPassKey } : {}),
|
|
2042
2046
|
});
|
|
2047
|
+
// The music store lives on nixamp.com; a server holding the label's music asks it about grants.
|
|
2048
|
+
const storeSite = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
|
|
2049
|
+
const storeGrants = new GrantVerifier({ site: storeSite });
|
|
2043
2050
|
|
|
2044
2051
|
return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
2045
2052
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
@@ -2595,6 +2602,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2595
2602
|
}
|
|
2596
2603
|
let event: ReturnType<typeof readCoinpayWebhook>;
|
|
2597
2604
|
try { event = readCoinpayWebhook(JSON.parse(raw)); } catch { json(response, 400, { error: "invalid JSON" }); return; }
|
|
2605
|
+
if (options.musicStore) {
|
|
2606
|
+
try {
|
|
2607
|
+
if (await options.musicStore.owns(event.paymentId)) { json(response, 200, await options.musicStore.settle(event.paymentId)); return; }
|
|
2608
|
+
} catch (error) {
|
|
2609
|
+
console.error(`nixamp: CoinPay webhook for ${event.paymentId} (music) could not settle: ${error instanceof Error ? error.message : String(error)}`);
|
|
2610
|
+
json(response, 500, { error: "could not settle; retry" });
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2598
2614
|
if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
|
|
2599
2615
|
try {
|
|
2600
2616
|
json(response, 200, await options.translationPasses.settle(event.paymentId));
|
|
@@ -2728,6 +2744,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2728
2744
|
!isPublicSharePath(path) &&
|
|
2729
2745
|
// The AI label's catalogue, its songs and their words.
|
|
2730
2746
|
!(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
|
|
2747
|
+
// The music store: its pages, checkout, receipts and grants (nixamp.com).
|
|
2748
|
+
!(isStorePath(path) && Boolean(options.musicStore)) &&
|
|
2731
2749
|
// What is on, as MCP, for anybody.
|
|
2732
2750
|
path !== "/mcp/public" &&
|
|
2733
2751
|
// Supporting a copyright holder is for anybody listening.
|
|
@@ -3074,9 +3092,24 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3074
3092
|
return;
|
|
3075
3093
|
}
|
|
3076
3094
|
|
|
3095
|
+
if (options.musicStore && options.support && isStorePath(path)) {
|
|
3096
|
+
const support = options.support;
|
|
3097
|
+
const accounts = options.accounts;
|
|
3098
|
+
if (await handleStoreRoutes(request, response, path, url, {
|
|
3099
|
+
store: options.musicStore,
|
|
3100
|
+
coins: async () => (await support.coins()).coins,
|
|
3101
|
+
account: async () => (accounts ? await accounts.whoIs(tokenFrom(request.headers)) : null),
|
|
3102
|
+
json,
|
|
3103
|
+
readBody,
|
|
3104
|
+
})) return;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3077
3107
|
if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
|
|
3078
3108
|
const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
|
|
3079
|
-
if (await handleLabelRoutes(request, response, path, {
|
|
3109
|
+
if (await handleLabelRoutes(request, response, path, {
|
|
3110
|
+
label: options.stations.label, canControl: control.allowed, json, readBody,
|
|
3111
|
+
grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "",
|
|
3112
|
+
})) return;
|
|
3080
3113
|
}
|
|
3081
3114
|
|
|
3082
3115
|
if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
|
|
@@ -7377,7 +7410,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7377
7410
|
try { stamp = String(Math.round(statSync(file).mtimeMs)); } catch { stamp = ""; }
|
|
7378
7411
|
const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
|
|
7379
7412
|
const words = await lyricsFinder.find(file).catch(() => null);
|
|
7380
|
-
|
|
7413
|
+
// A label song is for sale on nixamp.com: the song, its album, everything by the artist.
|
|
7414
|
+
const song = aiLabel.songAt(file);
|
|
7415
|
+
const here = stations.publicBase?.() ?? "";
|
|
7416
|
+
const shop = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
|
|
7417
|
+
const store = song && /^https:\/\//.test(here) ? {
|
|
7418
|
+
song: buyLink(shop, here, "song", song.artist, song.album, song.n),
|
|
7419
|
+
album: buyLink(shop, here, "album", song.artist, song.album),
|
|
7420
|
+
artist: buyLink(shop, here, "artist", song.artist),
|
|
7421
|
+
artistName: song.artistName,
|
|
7422
|
+
albumTitle: song.albumTitle,
|
|
7423
|
+
} : null;
|
|
7424
|
+
return { ...factsFor({
|
|
7381
7425
|
art,
|
|
7382
7426
|
...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
|
|
7383
7427
|
station, file,
|
|
@@ -7386,7 +7430,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7386
7430
|
holders: holderStore.list(),
|
|
7387
7431
|
share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
|
|
7388
7432
|
aiTrack: composed ? { made: composed.made } : null,
|
|
7389
|
-
});
|
|
7433
|
+
}), store };
|
|
7390
7434
|
};
|
|
7391
7435
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
7392
7436
|
// channel's fragments into short files while somebody is asking for them.
|
|
@@ -7531,6 +7575,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7531
7575
|
log: (line) => console.log(line),
|
|
7532
7576
|
}) : undefined;
|
|
7533
7577
|
supportMonthly?.startSchedule();
|
|
7578
|
+
// The AI label's songs for sale: purchases kept here, the music zipped by the server that holds it.
|
|
7579
|
+
const jwtSecret = process.env["NIXAMP_JWT_SECRET"] ?? "";
|
|
7580
|
+
const musicStore = supportPayments && supportPayments.enabled && pool && jwtSecret ? new MusicStore({
|
|
7581
|
+
db: pool as unknown as import("./support.ts").Db,
|
|
7582
|
+
support: supportPayments,
|
|
7583
|
+
mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
|
|
7584
|
+
site: nixampSite,
|
|
7585
|
+
secret: sha("sha256").update(`music-grant:${jwtSecret}`).digest("hex"),
|
|
7586
|
+
servers: () => {
|
|
7587
|
+
const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean) : [];
|
|
7588
|
+
return [...new Set([...guidePeers(), ...listed])];
|
|
7589
|
+
},
|
|
7590
|
+
log: (line) => console.log(line),
|
|
7591
|
+
}) : undefined;
|
|
7534
7592
|
if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
|
|
7535
7593
|
const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
|
|
7536
7594
|
...(pool ? { db: pool } : {}),
|
|
@@ -8085,6 +8143,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8085
8143
|
...(translationPasses ? { translationPasses } : {}),
|
|
8086
8144
|
...(supportPayments ? { support: supportPayments } : {}),
|
|
8087
8145
|
...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
|
|
8146
|
+
...(musicStore ? { musicStore } : {}),
|
|
8088
8147
|
...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
|
|
8089
8148
|
...(sharedTranslations ? { sharedTranslations } : {}),
|
|
8090
8149
|
...(translator ? { translator } : {}),
|
package/src/station-usage.ts
CHANGED
|
@@ -38,13 +38,13 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
38
38
|
|
|
39
39
|
AI label (AI artists who release albums on their own; a station per genre; the catalogue at /label):
|
|
40
40
|
nixamp station label artists, albums, this month's seconds, what it is waiting for
|
|
41
|
-
nixamp station label sign --roster the
|
|
42
|
-
|
|
41
|
+
nixamp station label sign --roster the roster: three artists in each of Rock, Hip-Hop, Synthwave, Country,
|
|
42
|
+
Metal and Pop (nixamp station label lists their keys)
|
|
43
43
|
nixamp station label sign (--preset KEY | --name NAME --genre GENRE --sound TEXT [--voice TEXT] [--bio TEXT] [--themes TEXT])
|
|
44
44
|
nixamp station label album KEY plan the artist's next album now and record it
|
|
45
45
|
nixamp station label set KEY [--sound TEXT] [--voice TEXT] [--bio TEXT] [--themes TEXT] [--off|--on]
|
|
46
46
|
nixamp station label drop KEY [--files]
|
|
47
|
-
nixamp station label config [--holder "Profullstack, Inc."] [--budget SECONDS] [--every-days 30] [--seed-albums
|
|
47
|
+
nixamp station label config [--holder "Profullstack, Inc."] [--budget SECONDS] [--every-days 30] [--seed-albums 1]
|
|
48
48
|
[--songs 8] [--seconds 150] [--on|--off] [--public|--unlisted]
|
|
49
49
|
nixamp station label run record whatever is due now
|
|
50
50
|
nixamp station label resume after the provider ran out of credit
|