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.
- package/dist/ai-label.d.ts +29 -0
- package/dist/ai-label.js +50 -1
- 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/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/zip-stream.d.ts +13 -0
- package/dist/zip-stream.js +119 -0
- package/package.json +1 -1
- package/src/ai-label.ts +48 -1
- package/src/label-routes.ts +77 -9
- package/src/music-store.ts +333 -0
- package/src/server.ts +63 -4
- package/src/store-routes.ts +204 -0
- package/src/support.ts +39 -0
- package/src/track-facts.ts +2 -0
- package/src/zip-stream.ts +128 -0
- package/web/dist/assets/{ar-BEVjqkqM.js → ar-CC_uYRlC.js} +1 -1
- package/web/dist/assets/{cs-DjazXQ15.js → cs-k5C-jvfX.js} +1 -1
- package/web/dist/assets/{da-DnyF6SYK.js → da-CdBzU_fF.js} +1 -1
- package/web/dist/assets/{de--q1ph9Ez.js → de-B-UWa0W7.js} +1 -1
- package/web/dist/assets/{es-Do3DkBwD.js → es-Dod8aCau.js} +1 -1
- package/web/dist/assets/{fi-B7sJTx1N.js → fi-C36bBPFW.js} +1 -1
- package/web/dist/assets/{fr-Dy7f5rGi.js → fr-DWUxs2qv.js} +1 -1
- package/web/dist/assets/{hi-C9vSnTQ4.js → hi-DjLqhfs8.js} +1 -1
- package/web/dist/assets/{hu-h-eueQE8.js → hu-CgsOlcq8.js} +1 -1
- package/web/dist/assets/{id-D8Ffd-Pc.js → id-ZA8yTWX9.js} +1 -1
- package/web/dist/assets/{it-BKM_2rj1.js → it-DfFhS34g.js} +1 -1
- package/web/dist/assets/{main-Dl_uRj22.js → main-CjgQp9um.js} +2 -2
- package/web/dist/assets/{nl-Cj3ETrcl.js → nl-C257i24U.js} +1 -1
- package/web/dist/assets/{radio-zPgyISLf.js → radio-CDUEoM0y.js} +1 -1
- package/web/dist/assets/{ru-B5zmGSnG.js → ru--30I7BNC.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-CxzBeXfk.js} +1 -1
- package/web/dist/assets/{uk-B2vuB6Un.js → uk-DXHpMk4i.js} +1 -1
- package/web/dist/assets/{vi-CXam3lDh.js → vi-Dg2LT4x3.js} +1 -1
- package/web/dist/assets/{zh-DnYhyzqb.js → zh-D3pvejo6.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/dist/ai-label.d.ts
CHANGED
|
@@ -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)
|
package/dist/label-routes.d.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { type AiLabel } from "./ai-label.ts";
|
|
3
|
-
|
|
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"]
|
|
27
|
+
export declare function renderLabel(view: ReturnType<AiLabel["publicView"]>, shop?: Shop): string;
|
|
28
|
+
export {};
|
package/dist/label-routes.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
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>` : ""}
|
|
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
|
+
}
|