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.
Files changed (57) hide show
  1. package/dist/ai-label.d.ts +29 -0
  2. package/dist/ai-label.js +135 -2
  3. package/dist/label-routes.d.ts +15 -2
  4. package/dist/label-routes.js +68 -9
  5. package/dist/music-store.d.ts +121 -0
  6. package/dist/music-store.js +289 -0
  7. package/dist/server.d.ts +3 -0
  8. package/dist/server.js +73 -12
  9. package/dist/station-usage.d.ts +1 -1
  10. package/dist/station-usage.js +3 -3
  11. package/dist/store-routes.d.ts +16 -0
  12. package/dist/store-routes.js +189 -0
  13. package/dist/support.d.ts +14 -0
  14. package/dist/support.js +41 -0
  15. package/dist/track-facts.d.ts +8 -0
  16. package/dist/track-facts.js +1 -1
  17. package/dist/zip-stream.d.ts +13 -0
  18. package/dist/zip-stream.js +119 -0
  19. package/package.json +1 -1
  20. package/src/ai-label.ts +133 -2
  21. package/src/label-routes.ts +77 -9
  22. package/src/music-store.ts +333 -0
  23. package/src/server.ts +63 -4
  24. package/src/station-usage.ts +3 -3
  25. package/src/store-routes.ts +204 -0
  26. package/src/support.ts +39 -0
  27. package/src/track-facts.ts +3 -1
  28. package/src/zip-stream.ts +128 -0
  29. package/web/dist/assets/{ar-BEVjqkqM.js → ar--GRGOL7F.js} +1 -1
  30. package/web/dist/assets/{cs-DjazXQ15.js → cs-B2ab-8qO.js} +1 -1
  31. package/web/dist/assets/{da-DnyF6SYK.js → da-Ek2g1uTg.js} +1 -1
  32. package/web/dist/assets/{de--q1ph9Ez.js → de-CM7-9Fbk.js} +1 -1
  33. package/web/dist/assets/{es-Do3DkBwD.js → es-BFSOrr3I.js} +1 -1
  34. package/web/dist/assets/{fi-B7sJTx1N.js → fi-D9Tf69sv.js} +1 -1
  35. package/web/dist/assets/{fr-Dy7f5rGi.js → fr-Gh9yYJGo.js} +1 -1
  36. package/web/dist/assets/{hi-C9vSnTQ4.js → hi-B3ow3HJC.js} +1 -1
  37. package/web/dist/assets/{hu-h-eueQE8.js → hu-paju-6Kr.js} +1 -1
  38. package/web/dist/assets/{id-D8Ffd-Pc.js → id-mubOQFX0.js} +1 -1
  39. package/web/dist/assets/{it-BKM_2rj1.js → it-0EM-qnDT.js} +1 -1
  40. package/web/dist/assets/{main-Dl_uRj22.js → main-CECXge6G.js} +2 -2
  41. package/web/dist/assets/{nl-Cj3ETrcl.js → nl-c6HalphH.js} +1 -1
  42. package/web/dist/assets/{radio-zPgyISLf.js → radio-CDUEoM0y.js} +1 -1
  43. package/web/dist/assets/{ru-B5zmGSnG.js → ru-CUKYVlgf.js} +1 -1
  44. package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
  45. package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
  46. package/web/dist/assets/{sv-BMtjA1uI.js → sv-BUxBsZR5.js} +1 -1
  47. package/web/dist/assets/{uk-B2vuB6Un.js → uk-708lBssa.js} +1 -1
  48. package/web/dist/assets/{vi-CXam3lDh.js → vi-B1ropUvk.js} +1 -1
  49. package/web/dist/assets/{zh-DnYhyzqb.js → zh-C_ndTsNh.js} +1 -1
  50. package/web/dist/index.html +1 -1
  51. package/web/dist/radio.html +12 -1
  52. package/web/dist/station.html +7 -2
  53. package/web/dist/sw.js +25 -25
  54. package/web/dist/vod.html +2 -2
  55. package/web/dist/watch.html +2 -2
  56. /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
  57. /package/web/dist/assets/{watch-hdfnhm3C.js → watch-BbhPO0HS.js} +0 -0
@@ -186,6 +186,35 @@ export declare class AiLabel {
186
186
  path: string;
187
187
  type: string;
188
188
  } | null;
189
+ /** The label song at this path, for the now-playing card's Download buttons. */
190
+ songAt(file: string): {
191
+ artist: string;
192
+ album: string;
193
+ n: number;
194
+ title: string;
195
+ artistName: string;
196
+ albumTitle: string;
197
+ } | null;
198
+ /** A released song's MP3, when this grant's scope covers it. */
199
+ songFile(artistKey: string, albumKey: string, n: number): string;
200
+ /**
201
+ * What a purchase's zip holds: every released song in scope with its words,
202
+ * and each album's cover and album.json, under <artist>/<year>-<album>/.
203
+ * Null when there is nothing (or no such artist).
204
+ */
205
+ zipFor(scope: {
206
+ kind: "song" | "album" | "artist";
207
+ artist: string;
208
+ album: string;
209
+ n: number;
210
+ }): {
211
+ name: string;
212
+ entries: Array<{
213
+ name: string;
214
+ path?: string;
215
+ bytes?: Uint8Array;
216
+ }>;
217
+ } | null;
189
218
  private require;
190
219
  }
191
220
  export interface PublicArtist {
package/dist/ai-label.js CHANGED
@@ -74,6 +74,90 @@ export const ROSTER = {
74
74
  bio: "Maps of places that burned down, drawn at full volume.",
75
75
  themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
76
76
  },
77
+ "iron-lantern-society": {
78
+ name: "Iron Lantern Society", genre: "Rock",
79
+ sound: "bluesy classic hard rock, swaggering riffs, Hammond organ, cowbell grooves, big guitar solos, 100-128 BPM",
80
+ voice: "gravelly male baritone with a wide rasp and a rock-and-roll howl",
81
+ bio: "Five road dogs who think every song should end in a bar fight or a sunrise.",
82
+ themes: "the open road, hard luck, loyalty among misfits, last calls and new starts",
83
+ },
84
+ "paper-satellites": {
85
+ name: "Paper Satellites", genre: "Rock",
86
+ sound: "jangly indie rock, chorus-drenched guitars, driving eighth-note bass, crisp drums, shout-along choruses, 130-160 BPM",
87
+ voice: "earnest male tenor, slightly nasal, with gang vocals in the choruses",
88
+ bio: "Bedroom recordings that grew up and got a van.",
89
+ themes: "growing up, small apartments, friendship, leaving and coming back",
90
+ },
91
+ "mira-solace": {
92
+ name: "Mira Solace", genre: "Hip-Hop",
93
+ sound: "melodic modern trap, airy pads, rolling hi-hats, sliding 808s, sparse piano, 130-145 BPM half-time",
94
+ voice: "confident female rapper with a smoky tone who switches to sung melodic hooks",
95
+ bio: "Writes verses on her phone between shifts and performs them like a closing argument.",
96
+ themes: "self-belief, heartbreak turned into fuel, sisterhood, getting paid",
97
+ },
98
+ "cold-brew-cipher": {
99
+ name: "Cold Brew Cipher", genre: "Hip-Hop",
100
+ sound: "jazzy laid-back hip hop, upright bass, muted trumpet, dusty drum breaks, vinyl warmth, 84-92 BPM",
101
+ voice: "relaxed male rapper with a warm baritone, wordplay-heavy, with a soulful sung chorus",
102
+ bio: "A coffee-shop cipher that never closed.",
103
+ themes: "slow mornings, neighbourhood characters, gratitude, wit and wordplay",
104
+ },
105
+ "vapor-meridian": {
106
+ name: "Vapor Meridian", genre: "Synthwave",
107
+ sound: "dark synthwave, pulsing bass sequences, cold arpeggios, gated snares, cinematic swells, 90-110 BPM",
108
+ voice: "cool, breathy female alto, close-miked and haunting",
109
+ bio: "The soundtrack to a neon noir that only exists after midnight.",
110
+ themes: "rain-soaked cities, secrets, rogue machines, love in the static",
111
+ },
112
+ "static-riviera": {
113
+ name: "Static Riviera", genre: "Synthwave",
114
+ sound: "sunny outrun synthwave, bright saw leads, funky slap bass, sax hooks, sunset pads, 110-124 BPM",
115
+ voice: "smooth male tenor with a touch of vocoder on the hooks",
116
+ bio: "A beach-town cassette left in the sun since 1986.",
117
+ themes: "summer nights, coastal highways, arcades, retro romance",
118
+ },
119
+ "boone-calloway": {
120
+ name: "Boone Calloway", genre: "Country",
121
+ sound: "outlaw country, twangy Telecaster, train-beat drums, honky-tonk piano, dobro, 90-120 BPM",
122
+ voice: "deep, weathered male baritone with a slow drawl",
123
+ bio: "Sings like he has been thrown out of every bar in three counties and misses all of them.",
124
+ themes: "freedom, regret, dusty highways, family, whiskey and redemption",
125
+ },
126
+ "gravel-hollow": {
127
+ name: "The Gravel Hollow Band", genre: "Country",
128
+ sound: "bluegrass and Americana, banjo rolls, mandolin chops, fiddle, upright bass, stacked harmonies, 100-140 BPM",
129
+ voice: "a lead male tenor with tight three-part mountain harmonies",
130
+ bio: "A porch band that never learned to play quietly.",
131
+ themes: "mountains, rivers, hard work, faith, homecoming",
132
+ },
133
+ "hollow-sovereign": {
134
+ name: "Hollow Sovereign", genre: "Metal",
135
+ sound: "symphonic doom metal, crushing slow riffs, choirs, strings, thundering double-kick climaxes, 60-90 BPM",
136
+ voice: "soaring operatic female soprano over deep male growled counterpoints",
137
+ bio: "A cathedral of riffs built in the ruins of a kingdom that never was.",
138
+ themes: "fallen empires, myth, grief, endurance, crowns and chains",
139
+ },
140
+ "rust-cathedral": {
141
+ name: "Rust Cathedral", genre: "Metal",
142
+ sound: "fast thrash metal, palm-muted gallop riffs, shredding solos, pounding double kick, 180-220 BPM",
143
+ voice: "aggressive male shout-singing with high screams",
144
+ bio: "Factory-floor thrash forged from overtime and broken machines.",
145
+ themes: "industry, rebellion, machines, the working class fighting back",
146
+ },
147
+ "theo-marlowe": {
148
+ name: "Theo Marlowe", genre: "Pop",
149
+ sound: "groovy funk-pop, slap bass, tight disco drums, bright brass stabs, falsetto hooks, 112-122 BPM",
150
+ voice: "smooth male pop tenor with a playful falsetto",
151
+ bio: "A charmer with a dance floor in his back pocket.",
152
+ themes: "flirting, confidence, summer romance, good nights out",
153
+ },
154
+ "glass-hearts-club": {
155
+ name: "Glass Hearts Club", genre: "Pop",
156
+ sound: "shimmering synth-pop, gated drums, glassy synths, big layered chorus vocals, 118-128 BPM",
157
+ voice: "a trio of bright female voices trading lines and stacking harmonies",
158
+ bio: "Three best friends turning group chats into choruses.",
159
+ themes: "friendship, crushes, breakups survived together, dancing until the lights come on",
160
+ },
77
161
  "lumi-kestrel": {
78
162
  name: "Lumi Kestrel", genre: "Pop",
79
163
  sound: "bright modern dance pop, punchy four-on-the-floor drums, glossy synths, plucky guitar, big pre-chorus lifts, 110-124 BPM",
@@ -87,7 +171,7 @@ export const DEFAULTS = {
87
171
  enabled: true,
88
172
  monthlySeconds: 3600,
89
173
  everyDays: 30,
90
- seedAlbums: 2,
174
+ seedAlbums: 1,
91
175
  songsPerAlbum: 8,
92
176
  songSeconds: 150,
93
177
  publish: true,
@@ -132,7 +216,7 @@ const PLAN_SYSTEM = [
132
216
  const LYRICS_SYSTEM = [
133
217
  "You write original English song lyrics for an AI singer. Never quote or imitate an existing song and never name a real artist.",
134
218
  "Put each section tag on its own line: [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], [Outro].",
135
- "Use two verses, a chorus sung two or three times and a bridge; vivid, specific, singable lines that rhyme where it sounds natural.",
219
+ "Use two verses of four lines, a four-line chorus sung two or three times, a pre-chorus of two lines and a bridge of two to four lines; vivid, specific, singable lines that rhyme where it sounds natural. Never just one line under a section.",
136
220
  "Only the words that are sung, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
137
221
  ].join(" ");
138
222
  export async function planAlbum(artist, songs, ask = askJson) {
@@ -716,6 +800,55 @@ export class AiLabel {
716
800
  return null;
717
801
  return match[2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
718
802
  }
803
+ /** The label song at this path, for the now-playing card's Download buttons. */
804
+ songAt(file) {
805
+ for (const artist of this.artists.values()) {
806
+ for (const album of artist.albums) {
807
+ const song = album.songs.find((one) => one.file === file);
808
+ if (song)
809
+ return { artist: artist.key, album: album.key, n: song.n, title: song.title, artistName: artist.name, albumTitle: album.title };
810
+ }
811
+ }
812
+ return null;
813
+ }
814
+ /** A released song's MP3, when this grant's scope covers it. */
815
+ songFile(artistKey, albumKey, n) {
816
+ return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
817
+ }
818
+ /**
819
+ * What a purchase's zip holds: every released song in scope with its words,
820
+ * and each album's cover and album.json, under <artist>/<year>-<album>/.
821
+ * Null when there is nothing (or no such artist).
822
+ */
823
+ zipFor(scope) {
824
+ const artist = this.artists.get(scope.artist);
825
+ if (!artist)
826
+ return null;
827
+ const albums = artist.albums.filter((one) => scope.kind === "artist" || one.key === scope.album);
828
+ const entries = [];
829
+ const top = fileName(artist.name);
830
+ for (const album of albums) {
831
+ const songs = album.songs.filter((one) => one.file && (scope.kind !== "song" || one.n === scope.n));
832
+ if (songs.length === 0)
833
+ continue;
834
+ const folder = `${top}/${album.year}-${fileName(album.title)}`;
835
+ for (const song of songs) {
836
+ entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
837
+ entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
838
+ }
839
+ if (album.cover)
840
+ entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
841
+ entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
842
+ artist: artist.name, genre: artist.genre, album: album.title, year: album.year, concept: album.concept,
843
+ label: this.settings.holder, copyright: `℗ ${album.year} ${this.settings.holder}`, madeWithAi: true,
844
+ songs: songs.map((one) => ({ n: one.n, title: one.title, seconds: one.seconds, file: `${songBase(one)}.mp3`, lyrics: `${songBase(one)}.lyrics.txt` })),
845
+ }, null, 2)}\n`) });
846
+ }
847
+ if (entries.length === 0)
848
+ return null;
849
+ const what = scope.kind === "artist" ? "Discography" : scope.kind === "album" ? albums[0].title : this.songFile(scope.artist, scope.album, scope.n) ? albums[0].songs.find((one) => one.n === scope.n).title : "Song";
850
+ return { name: `${fileName(artist.name)} - ${fileName(what)}.zip`, entries };
851
+ }
719
852
  require(key) {
720
853
  const artist = this.artists.get(key);
721
854
  if (!artist)
@@ -1,15 +1,28 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { type AiLabel } from "./ai-label.ts";
3
- /** Whether this path is the label's public face. */
3
+ import { type GrantVerifier } from "./music-store.ts";
4
+ /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
4
5
  export declare function isPublicLabelPath(path: string): boolean;
6
+ /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
7
+ export declare function buyLink(site: string, server: string, kind: "song" | "album" | "artist", artist: string, album?: string, n?: number): string;
5
8
  export interface LabelRoutesContext {
6
9
  label: AiLabel;
7
10
  canControl: boolean;
11
+ /** Checks a store grant with nixamp.com; without it nothing is sold or handed out. */
12
+ grants?: GrantVerifier;
13
+ /** nixamp.com, where the store is, and this server's public address (for Buy links). */
14
+ site?: string;
15
+ publicBase?: string;
8
16
  json: (response: ServerResponse, status: number, body: unknown) => void;
9
17
  readBody: (request: IncomingMessage) => Promise<string>;
10
18
  }
11
19
  /** Answers the paths above and the owner's; false when the path is neither. */
12
20
  export declare function handleLabelRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: LabelRoutesContext): Promise<boolean>;
13
21
  export declare const LABEL_CSP: string;
22
+ type Shop = {
23
+ site: string;
24
+ server: string;
25
+ } | undefined;
14
26
  /** The label's catalogue as a page: no script, plays in any browser. */
15
- export declare function renderLabel(view: ReturnType<AiLabel["publicView"]>): string;
27
+ export declare function renderLabel(view: ReturnType<AiLabel["publicView"]>, shop?: Shop): string;
28
+ export {};
@@ -9,13 +9,21 @@
9
9
  import { createHash } from "node:crypto";
10
10
  import { createReadStream, statSync } from "node:fs";
11
11
  import { ROSTER } from "./ai-label.js";
12
+ import { covers, PRICES } from "./music-store.js";
13
+ import { writeZip, zipSize } from "./zip-stream.js";
12
14
  import { StationError } from "./stations.js";
13
15
  // Listening is free on the live stations (with their breaks); the songs as
14
- // files are sold, so only the words and the cover are public here.
15
- const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.txt|cover\.jpg)$/;
16
- /** Whether this path is the label's public face. */
16
+ // files are sold, so only the words and the cover are public here, and an
17
+ // MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
18
+ const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.(?:txt|mp3)|cover\.jpg)$/;
19
+ /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
17
20
  export function isPublicLabelPath(path) {
18
- return path === "/label" || path === "/api/v1/label" || FILE.test(path);
21
+ return path === "/label" || path === "/api/v1/label" || path === "/api/v1/label/zip" || FILE.test(path);
22
+ }
23
+ /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
24
+ export function buyLink(site, server, kind, artist, album = "", n = 0) {
25
+ const query = new URLSearchParams({ server, kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) });
26
+ return `${site}/buy?${query.toString()}`;
19
27
  }
20
28
  async function jsonBody(ctx, request) {
21
29
  const raw = await ctx.readBody(request);
@@ -79,7 +87,8 @@ export async function handleLabelRoutes(request, response, path, ctx) {
79
87
  return true;
80
88
  }
81
89
  if (path === "/label") {
82
- const page = renderLabel(label.publicView());
90
+ const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase } : undefined;
91
+ const page = renderLabel(label.publicView(), shop);
83
92
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
84
93
  response.end(method === "HEAD" ? undefined : page);
85
94
  return true;
@@ -88,7 +97,45 @@ export async function handleLabelRoutes(request, response, path, ctx) {
88
97
  ctx.json(response, 200, label.publicView());
89
98
  return true;
90
99
  }
100
+ const url = new URL(request.url ?? "/", "http://x");
101
+ const grant = url.searchParams.get("grant") ?? "";
102
+ if (path === "/api/v1/label/zip") {
103
+ const scope = ctx.grants ? await ctx.grants.check(grant) : null;
104
+ if (!scope) {
105
+ 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" });
106
+ return true;
107
+ }
108
+ const zip = label.zipFor(scope);
109
+ if (!zip) {
110
+ ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
111
+ return true;
112
+ }
113
+ response.writeHead(200, {
114
+ "content-type": "application/zip",
115
+ "content-length": String(zipSize(zip.entries)),
116
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(zip.name)}`,
117
+ "cache-control": "private, no-store",
118
+ });
119
+ if (method === "HEAD") {
120
+ response.end();
121
+ return true;
122
+ }
123
+ await writeZip(response, zip.entries).catch(() => response.destroy());
124
+ response.end();
125
+ return true;
126
+ }
91
127
  const [, artist, album, what] = FILE.exec(path);
128
+ if (what.endsWith(".mp3")) {
129
+ const scope = ctx.grants && grant ? await ctx.grants.check(grant) : null;
130
+ const n = Number(what.split(".")[0]);
131
+ const file = scope && covers(scope, artist, album, n) ? label.songFile(artist, album, n) : "";
132
+ if (!file) {
133
+ 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" });
134
+ return true;
135
+ }
136
+ sendFile(request, response, file, "audio/mpeg", file.split("/").pop() ?? "");
137
+ return true;
138
+ }
92
139
  const found = label.publicFile(artist, album, what);
93
140
  if (!found) {
94
141
  ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
@@ -204,6 +251,7 @@ audio{width:100%;max-width:360px;height:36px}
204
251
  details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
205
252
  pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
206
253
  .dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
254
+ a.buy{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:2px 10px}
207
255
  footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
208
256
  `;
209
257
  function hash(text) {
@@ -213,24 +261,35 @@ export const LABEL_CSP = [
213
261
  "default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
214
262
  "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
215
263
  ].join("; ");
216
- function renderAlbum(artist, album) {
264
+ function price(cents) {
265
+ return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
266
+ }
267
+ function buy(shop, kind, artist, album = "", n = 0, what = "") {
268
+ if (!shop)
269
+ return "";
270
+ return `<a class="buy" href="${escape(buyLink(shop.site, shop.server, kind, artist, album, n))}">Download ${what || kind} · ${price(PRICES[kind])}</a>`;
271
+ }
272
+ function renderAlbum(artist, album, shop) {
217
273
  const base = `/api/v1/label/${artist.key}/${album.key}`;
218
274
  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>`;
219
275
  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>`
220
276
  + `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
221
- + `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a></div></li>`).join("");
277
+ + `<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("");
222
278
  return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
223
279
  + `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
224
280
  + `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
281
+ + `${shop ? `<p>${buy(shop, "album", artist.key, album.key, 0, "the album, MP3s with art and lyrics")}</p>` : ""}`
225
282
  + `<ol class="songs">${songs}</ol></div></div>`;
226
283
  }
227
284
  /** The label's catalogue as a page: no script, plays in any browser. */
228
- export function renderLabel(view) {
285
+ export function renderLabel(view, shop) {
229
286
  const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
230
287
  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>` : "";
231
288
  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, "-"))}">`
232
289
  + `<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>`
233
- + genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}${artist.albums.map((album) => renderAlbum(artist, album)).join("")}</article>`).join("")
290
+ + genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}`
291
+ + `${shop ? `<p>${buy(shop, "artist", artist.key, "", 0, "everything by them")}</p>` : ""}`
292
+ + `${artist.albums.map((album) => renderAlbum(artist, album, shop)).join("")}</article>`).join("")
234
293
  + `</section>`).join("");
235
294
  return `<!doctype html>
236
295
  <html lang="en">
@@ -0,0 +1,121 @@
1
+ import type { Db, Support } from "./support.ts";
2
+ export type Kind = "song" | "album" | "artist";
3
+ /** In cents. */
4
+ export declare const PRICES: Record<Kind, number>;
5
+ export interface Scope {
6
+ server: string;
7
+ kind: Kind;
8
+ artist: string;
9
+ album: string;
10
+ n: number;
11
+ }
12
+ export interface Purchase extends Scope {
13
+ id: string;
14
+ token: string;
15
+ email: string;
16
+ userId: string;
17
+ title: string;
18
+ artistName: string;
19
+ amountCents: number;
20
+ coin: string;
21
+ paymentId: string;
22
+ status: "pending" | "paid";
23
+ createdAt: number;
24
+ paidAt: number;
25
+ }
26
+ /** A server's public address as given: https, no path, no trailing slash. */
27
+ export declare function serverBase(value: unknown): string;
28
+ /** `g1.<exp>.<kind>.<artist>.<album|->.<n>.<server, base64url>.<sig>` */
29
+ export declare function makeGrant(secret: string, scope: Scope, exp: number): string;
30
+ export declare function readGrant(secret: string, grant: string, now?: number): (Scope & {
31
+ exp: number;
32
+ }) | null;
33
+ /** Whether a grant for `scope` covers this file of the catalogue. */
34
+ export declare function covers(scope: Pick<Scope, "kind" | "artist" | "album" | "n">, artist: string, album: string, n?: number): boolean;
35
+ /**
36
+ * On a server that holds the music: whether nixamp.com signed this grant,
37
+ * asked at /api/v1/store/grant and remembered (a good one until it expires,
38
+ * a refused one for 5 minutes). nixamp.com itself checks with its secret.
39
+ */
40
+ export declare class GrantVerifier {
41
+ private readonly options;
42
+ private known;
43
+ constructor(options: {
44
+ site: string;
45
+ secret?: string;
46
+ fetcher?: typeof fetch;
47
+ now?: () => number;
48
+ });
49
+ check(grant: string): Promise<(Scope & {
50
+ exp: number;
51
+ }) | null>;
52
+ }
53
+ export interface MusicStoreOptions {
54
+ db: Db;
55
+ support: Support;
56
+ /** Resend; null leaves the receipt unsent (and says so in the log). */
57
+ mail: {
58
+ apiKey: string;
59
+ from: string;
60
+ } | null;
61
+ site: string;
62
+ /** Signs the grants; derived from NIXAMP_JWT_SECRET. */
63
+ secret: string;
64
+ /** The servers whose catalogues may be sold here (the directory and the guide's peers). */
65
+ servers: () => string[];
66
+ fetcher?: typeof fetch;
67
+ now?: () => number;
68
+ log?: (line: string) => void;
69
+ }
70
+ export declare class MusicStore {
71
+ private readonly options;
72
+ private schema;
73
+ constructor(options: MusicStoreOptions);
74
+ private now;
75
+ private ready;
76
+ /** A server's catalogue, as it publishes it at /api/v1/label. */
77
+ private catalogue;
78
+ /** What is for sale at this address, with its name and price; refused when there is no such thing. */
79
+ describe(input: Record<string, unknown>): Promise<Scope & {
80
+ title: string;
81
+ artistName: string;
82
+ amountCents: number;
83
+ songs: number;
84
+ }>;
85
+ /** A purchase waiting for its payment, and where to pay. Signed in, it is the account's too. */
86
+ checkout(input: Record<string, unknown>, account: {
87
+ id: string;
88
+ email: string;
89
+ } | null): Promise<{
90
+ url: string;
91
+ id: string;
92
+ token: string;
93
+ }>;
94
+ /** The purchase's own page, which is also the receipt link. */
95
+ link(id: string, token: string): string;
96
+ /** A purchase by its link; a pending one asks CoinPay whether it has been paid since. */
97
+ get(id: string, token: string): Promise<Purchase | null>;
98
+ /** CoinPay's webhook: a payment of ours that is now paid. False when it is not a music purchase. */
99
+ settle(paymentId: string): Promise<{
100
+ settled: boolean;
101
+ purchase?: string;
102
+ }>;
103
+ /** Whether CoinPay's webhook is about one of ours. */
104
+ owns(paymentId: string): Promise<boolean>;
105
+ /** Marks it paid once (the first to get there sends the receipt). */
106
+ private paid;
107
+ /** Every paid purchase of this account: made signed in, or to its email. */
108
+ forAccount(account: {
109
+ id: string;
110
+ email: string;
111
+ }): Promise<Purchase[]>;
112
+ grant(purchase: Scope): string;
113
+ check(grant: string): (Scope & {
114
+ exp: number;
115
+ }) | null;
116
+ /** Where the zip of a purchase is made: on the server that holds the music. */
117
+ zipUrl(purchase: Purchase): string;
118
+ /** What anybody with the purchase's link (or the account) is shown. */
119
+ view(purchase: Purchase): Record<string, unknown>;
120
+ private mail;
121
+ }