nixamp 0.49.0 → 0.50.1

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 (53) hide show
  1. package/dist/ai-label.d.ts +29 -0
  2. package/dist/ai-label.js +50 -1
  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/store-routes.d.ts +16 -0
  10. package/dist/store-routes.js +189 -0
  11. package/dist/support.d.ts +14 -0
  12. package/dist/support.js +41 -0
  13. package/dist/track-facts.d.ts +8 -0
  14. package/dist/zip-stream.d.ts +13 -0
  15. package/dist/zip-stream.js +119 -0
  16. package/package.json +1 -1
  17. package/src/ai-label.ts +48 -1
  18. package/src/label-routes.ts +77 -9
  19. package/src/music-store.ts +333 -0
  20. package/src/server.ts +63 -4
  21. package/src/store-routes.ts +204 -0
  22. package/src/support.ts +39 -0
  23. package/src/track-facts.ts +2 -0
  24. package/src/zip-stream.ts +128 -0
  25. package/web/dist/assets/{ar-BEVjqkqM.js → ar-CC_uYRlC.js} +1 -1
  26. package/web/dist/assets/{cs-DjazXQ15.js → cs-k5C-jvfX.js} +1 -1
  27. package/web/dist/assets/{da-DnyF6SYK.js → da-CdBzU_fF.js} +1 -1
  28. package/web/dist/assets/{de--q1ph9Ez.js → de-B-UWa0W7.js} +1 -1
  29. package/web/dist/assets/{es-Do3DkBwD.js → es-Dod8aCau.js} +1 -1
  30. package/web/dist/assets/{fi-B7sJTx1N.js → fi-C36bBPFW.js} +1 -1
  31. package/web/dist/assets/{fr-Dy7f5rGi.js → fr-DWUxs2qv.js} +1 -1
  32. package/web/dist/assets/{hi-C9vSnTQ4.js → hi-DjLqhfs8.js} +1 -1
  33. package/web/dist/assets/{hu-h-eueQE8.js → hu-CgsOlcq8.js} +1 -1
  34. package/web/dist/assets/{id-D8Ffd-Pc.js → id-ZA8yTWX9.js} +1 -1
  35. package/web/dist/assets/{it-BKM_2rj1.js → it-DfFhS34g.js} +1 -1
  36. package/web/dist/assets/{main-Dl_uRj22.js → main-CjgQp9um.js} +2 -2
  37. package/web/dist/assets/{nl-Cj3ETrcl.js → nl-C257i24U.js} +1 -1
  38. package/web/dist/assets/{radio-zPgyISLf.js → radio-CDUEoM0y.js} +1 -1
  39. package/web/dist/assets/{ru-B5zmGSnG.js → ru--30I7BNC.js} +1 -1
  40. package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
  41. package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
  42. package/web/dist/assets/{sv-BMtjA1uI.js → sv-CxzBeXfk.js} +1 -1
  43. package/web/dist/assets/{uk-B2vuB6Un.js → uk-DXHpMk4i.js} +1 -1
  44. package/web/dist/assets/{vi-CXam3lDh.js → vi-Dg2LT4x3.js} +1 -1
  45. package/web/dist/assets/{zh-DnYhyzqb.js → zh-D3pvejo6.js} +1 -1
  46. package/web/dist/index.html +1 -1
  47. package/web/dist/radio.html +12 -1
  48. package/web/dist/station.html +7 -2
  49. package/web/dist/sw.js +25 -25
  50. package/web/dist/vod.html +2 -2
  51. package/web/dist/watch.html +2 -2
  52. /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
  53. /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
@@ -132,7 +132,7 @@ const PLAN_SYSTEM = [
132
132
  const LYRICS_SYSTEM = [
133
133
  "You write original English song lyrics for an AI singer. Never quote or imitate an existing song and never name a real artist.",
134
134
  "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.",
135
+ "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
136
  "Only the words that are sung, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
137
137
  ].join(" ");
138
138
  export async function planAlbum(artist, songs, ask = askJson) {
@@ -716,6 +716,55 @@ export class AiLabel {
716
716
  return null;
717
717
  return match[2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
718
718
  }
719
+ /** The label song at this path, for the now-playing card's Download buttons. */
720
+ songAt(file) {
721
+ for (const artist of this.artists.values()) {
722
+ for (const album of artist.albums) {
723
+ const song = album.songs.find((one) => one.file === file);
724
+ if (song)
725
+ return { artist: artist.key, album: album.key, n: song.n, title: song.title, artistName: artist.name, albumTitle: album.title };
726
+ }
727
+ }
728
+ return null;
729
+ }
730
+ /** A released song's MP3, when this grant's scope covers it. */
731
+ songFile(artistKey, albumKey, n) {
732
+ return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
733
+ }
734
+ /**
735
+ * What a purchase's zip holds: every released song in scope with its words,
736
+ * and each album's cover and album.json, under <artist>/<year>-<album>/.
737
+ * Null when there is nothing (or no such artist).
738
+ */
739
+ zipFor(scope) {
740
+ const artist = this.artists.get(scope.artist);
741
+ if (!artist)
742
+ return null;
743
+ const albums = artist.albums.filter((one) => scope.kind === "artist" || one.key === scope.album);
744
+ const entries = [];
745
+ const top = fileName(artist.name);
746
+ for (const album of albums) {
747
+ const songs = album.songs.filter((one) => one.file && (scope.kind !== "song" || one.n === scope.n));
748
+ if (songs.length === 0)
749
+ continue;
750
+ const folder = `${top}/${album.year}-${fileName(album.title)}`;
751
+ for (const song of songs) {
752
+ entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
753
+ entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
754
+ }
755
+ if (album.cover)
756
+ entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
757
+ entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
758
+ artist: artist.name, genre: artist.genre, album: album.title, year: album.year, concept: album.concept,
759
+ label: this.settings.holder, copyright: `℗ ${album.year} ${this.settings.holder}`, madeWithAi: true,
760
+ songs: songs.map((one) => ({ n: one.n, title: one.title, seconds: one.seconds, file: `${songBase(one)}.mp3`, lyrics: `${songBase(one)}.lyrics.txt` })),
761
+ }, null, 2)}\n`) });
762
+ }
763
+ if (entries.length === 0)
764
+ return null;
765
+ 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";
766
+ return { name: `${fileName(artist.name)} - ${fileName(what)}.zip`, entries };
767
+ }
719
768
  require(key) {
720
769
  const artist = this.artists.get(key);
721
770
  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
+ }