nixamp 0.41.2 → 0.43.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-keys.d.ts +1 -1
  2. package/dist/ai-keys.js +2 -0
  3. package/dist/lyrics-publish.d.ts +56 -0
  4. package/dist/lyrics-publish.js +194 -0
  5. package/dist/lyrics.d.ts +67 -0
  6. package/dist/lyrics.js +145 -0
  7. package/dist/server.d.ts +7 -1
  8. package/dist/server.js +64 -3
  9. package/dist/station-cli.js +42 -0
  10. package/dist/station-page.d.ts +1 -1
  11. package/dist/station-page.js +39 -3
  12. package/dist/station-routes.d.ts +9 -0
  13. package/dist/station-routes.js +39 -1
  14. package/dist/station-usage.d.ts +1 -1
  15. package/dist/station-usage.js +5 -0
  16. package/dist/stations.d.ts +2 -0
  17. package/dist/stations.js +6 -0
  18. package/dist/support.d.ts +65 -0
  19. package/dist/support.js +187 -5
  20. package/dist/track-facts.d.ts +4 -0
  21. package/dist/track-facts.js +8 -4
  22. package/package.json +1 -1
  23. package/src/ai-keys.ts +2 -0
  24. package/src/lyrics-publish.ts +203 -0
  25. package/src/lyrics.ts +161 -0
  26. package/src/server.ts +68 -3
  27. package/src/station-cli.ts +40 -0
  28. package/src/station-page.ts +39 -3
  29. package/src/station-routes.ts +46 -1
  30. package/src/station-usage.ts +5 -0
  31. package/src/stations.ts +4 -0
  32. package/src/support.ts +201 -6
  33. package/src/track-facts.ts +11 -2
  34. package/web/dist/assets/{ar-DJ2gQPOo.js → ar-DJAQCP6v.js} +1 -1
  35. package/web/dist/assets/{cs-HEbqVNx5.js → cs-BYeHBx_F.js} +1 -1
  36. package/web/dist/assets/{da-C87QYmEx.js → da-CR89gWR7.js} +1 -1
  37. package/web/dist/assets/{de-BqR-HScZ.js → de-DPFsA0HC.js} +1 -1
  38. package/web/dist/assets/{es-DjzlzxAQ.js → es-Bg5Vx2bB.js} +1 -1
  39. package/web/dist/assets/{fi-2Is-qqeQ.js → fi-BVpY7Uww.js} +1 -1
  40. package/web/dist/assets/{fr-CTEDRgPI.js → fr-DsSofhGt.js} +1 -1
  41. package/web/dist/assets/{hi-CU5X7c5G.js → hi-DkSvHtix.js} +1 -1
  42. package/web/dist/assets/{hu-CQAs9Ye7.js → hu-D4Z2GpU0.js} +1 -1
  43. package/web/dist/assets/{id-5dd2TXs7.js → id-BzOPbgSu.js} +1 -1
  44. package/web/dist/assets/{it-B69_ME4E.js → it-BAT4YCiY.js} +1 -1
  45. package/web/dist/assets/{main-DRvUTBup.js → main-Dpj3oiHi.js} +2 -2
  46. package/web/dist/assets/{nl-Drb6oxnO.js → nl-CiTdg-si.js} +1 -1
  47. package/web/dist/assets/{ru-C4u8G5A0.js → ru-CJBuL5JA.js} +1 -1
  48. package/web/dist/assets/{station--cxhU1wr.css → station-BwDP2cuf.css} +1 -1
  49. package/web/dist/assets/station-DXRmjrqv.js +3 -0
  50. package/web/dist/assets/{sv-Bi-av_7u.js → sv-Dptz7Qud.js} +1 -1
  51. package/web/dist/assets/{uk-DZVF8mrT.js → uk-yL3tD6Kt.js} +1 -1
  52. package/web/dist/assets/{vi-B2UJxM5O.js → vi-CuVvOIut.js} +1 -1
  53. package/web/dist/assets/{zh-XRXZxCqU.js → zh-kHhOVHwJ.js} +1 -1
  54. package/web/dist/index.html +1 -1
  55. package/web/dist/station.html +9 -2
  56. package/web/dist/sw.js +21 -21
  57. package/web/dist/assets/station-CdQ9LR6K.js +0 -1
package/src/lyrics.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The words of the song on the air, for a station's page.
3
+ *
4
+ * Two places they come from, in this order:
5
+ * 1. the file itself: an ID3 USLT frame (ffprobe calls it `lyrics-eng`, or
6
+ * `lyrics-<description>-<lang>`), a Vorbis/APE `LYRICS` or
7
+ * `UNSYNCEDLYRICS` tag. Suno writes the words it sang into USLT, so our
8
+ * own catalog carries them already;
9
+ * 2. nichedb.dev's lyrics, asked by artist and title, which is where a file
10
+ * without the tag gets them (and where ours are published back to:
11
+ * lyrics-publish.ts).
12
+ * Each answer is kept: the tags with the file (by size and time, TagReader),
13
+ * nichedb's for ten minutes, misses included, so a station of tracks nobody
14
+ * has words for asks once per track per ten minutes and never waits long.
15
+ */
16
+
17
+ export interface Lyrics {
18
+ title: string;
19
+ artist: string;
20
+ text: string;
21
+ /** "embedded" (the file's own tag) or "nichedb" (or what nichedb said its source was). */
22
+ source: string;
23
+ /** ISO 639-1 where we know the mapping, else what the tag said; "" when unsaid. */
24
+ language: string;
25
+ }
26
+
27
+ export const NICHEDB_LYRICS = "https://nichedb.dev";
28
+ const MAX_TEXT = 20_000;
29
+
30
+ /** ID3 / ISO 639-2 codes for the languages we are likely to meet, as 639-1. */
31
+ const LANGS: Record<string, string> = {
32
+ eng: "en", deu: "de", ger: "de", fra: "fr", fre: "fr", spa: "es", ita: "it", por: "pt", nld: "nl", dut: "nl",
33
+ swe: "sv", nor: "no", nob: "nb", dan: "da", isl: "is", ice: "is", fin: "fi", jpn: "ja", kor: "ko", zho: "zh",
34
+ chi: "zh", rus: "ru", pol: "pl", ukr: "uk", tur: "tr", ara: "ar", heb: "he", hin: "hi", ces: "cs", cze: "cs",
35
+ ell: "el", gre: "el", hun: "hu", ron: "ro", rum: "ro", lat: "la", gle: "ga", cym: "cy", wel: "cy",
36
+ };
37
+
38
+ function language(code: string): string {
39
+ const lower = code.toLowerCase();
40
+ if (lower === "" || lower === "xxx" || lower === "und" || lower === "zxx") return "";
41
+ return LANGS[lower] ?? lower;
42
+ }
43
+
44
+ /** Text as it may be shown: newlines kept, control characters gone, trimmed, capped. */
45
+ export function cleanLyrics(text: string): string {
46
+ return text
47
+ .replace(/\r\n?/g, "\n")
48
+ // eslint-disable-next-line no-control-regex
49
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "")
50
+ .replace(/\n{3,}/g, "\n\n")
51
+ .trim()
52
+ .slice(0, MAX_TEXT);
53
+ }
54
+
55
+ /** The words a file's own tags carry (lower-cased names, as TagReader gives them), or null. */
56
+ export function lyricsFromTags(tags: Record<string, string>): { text: string; language: string } | null {
57
+ let best: { text: string; language: string } | null = null;
58
+ for (const [name, value] of Object.entries(tags)) {
59
+ let lang = "";
60
+ if (name === "lyrics" || name === "unsyncedlyrics" || name === "unsynced lyrics" || name === "unsynced_lyrics" || name === "uslt") {
61
+ lang = "";
62
+ } else if (name.startsWith("lyrics-") || name.startsWith("lyrics_")) {
63
+ // lyrics-eng, or lyrics-<description>-eng: the language is the last part.
64
+ const last = name.slice("lyrics-".length).split(/[-_]/).pop() ?? "";
65
+ lang = /^[a-z]{3}$/.test(last) ? last : "";
66
+ } else {
67
+ continue;
68
+ }
69
+ const text = cleanLyrics(value);
70
+ if (text === "") continue;
71
+ // The longest wins: a short descriptor-only frame is not the song.
72
+ if (!best || text.length > best.text.length) best = { text, language: language(lang) };
73
+ }
74
+ return best;
75
+ }
76
+
77
+ type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
78
+
79
+ export interface LyricsFinderOptions {
80
+ /** The file's tags (TagReader.read). */
81
+ tags: (file: string) => Promise<Record<string, string>>;
82
+ /** What the library knows when the file has no tag for it. */
83
+ known?: (file: string) => { title: string; artist: string } | null;
84
+ /** nichedb's address; NIXAMP_LYRICS_URL, else nichedb.dev. "" turns the lookup off. */
85
+ base?: string;
86
+ fetcher?: Fetcher;
87
+ /** How long nichedb's answer (or its silence) is kept. */
88
+ ttlMs?: number;
89
+ timeoutMs?: number;
90
+ now?: () => number;
91
+ }
92
+
93
+ /** Finds a file's words, from its tags, else from nichedb. */
94
+ export class LyricsFinder {
95
+ private remote = new Map<string, { at: number; lyrics: Lyrics | null }>();
96
+ private asking = new Map<string, Promise<Lyrics | null>>();
97
+ private readonly base: string;
98
+ private readonly fetcher: Fetcher;
99
+ private readonly ttl: number;
100
+ private readonly timeout: number;
101
+ private readonly now: () => number;
102
+
103
+ constructor(private readonly options: LyricsFinderOptions) {
104
+ this.base = (options.base ?? process.env["NIXAMP_LYRICS_URL"] ?? NICHEDB_LYRICS).replace(/\/+$/, "");
105
+ this.fetcher = options.fetcher ?? ((url, init) => fetch(url, init));
106
+ this.ttl = options.ttlMs ?? 10 * 60_000;
107
+ this.timeout = options.timeoutMs ?? 4_000;
108
+ this.now = options.now ?? Date.now;
109
+ }
110
+
111
+ /** The file's words, or null. Never throws; never waits on nichedb longer than the timeout. */
112
+ async find(file: string): Promise<Lyrics | null> {
113
+ let tags: Record<string, string> = {};
114
+ try { tags = await this.options.tags(file); } catch { tags = {}; }
115
+ const known = this.options.known?.(file) ?? null;
116
+ const title = tags["title"] || known?.title || "";
117
+ const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
118
+ const own = lyricsFromTags(tags);
119
+ if (own) return { title, artist, text: own.text, source: "embedded", language: own.language };
120
+ if (!title || !artist || !this.base) return null;
121
+ return this.lookup(artist, title);
122
+ }
123
+
124
+ /** What nichedb has for this artist and title, kept for ten minutes. */
125
+ lookup(artist: string, title: string): Promise<Lyrics | null> {
126
+ const key = `${artist.toLowerCase()}\n${title.toLowerCase()}`;
127
+ const hit = this.remote.get(key);
128
+ if (hit && this.now() - hit.at < this.ttl) return Promise.resolve(hit.lyrics);
129
+ const pending = this.asking.get(key);
130
+ if (pending) return pending;
131
+ const asked = this.ask(artist, title).then((lyrics) => {
132
+ if (this.remote.size > 2000) this.remote.clear();
133
+ this.remote.set(key, { at: this.now(), lyrics });
134
+ return lyrics;
135
+ }).finally(() => this.asking.delete(key));
136
+ this.asking.set(key, asked);
137
+ return asked;
138
+ }
139
+
140
+ private async ask(artist: string, title: string): Promise<Lyrics | null> {
141
+ const url = `${this.base}/api/v1/lyrics?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`;
142
+ try {
143
+ const response = await this.fetcher(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(this.timeout) });
144
+ if (!response.ok) return null;
145
+ const body = await response.json() as { lyrics?: Record<string, unknown> };
146
+ const found = body?.lyrics;
147
+ const text = typeof found?.["text"] === "string" ? cleanLyrics(found["text"]) : "";
148
+ if (!text) return null;
149
+ const string = (name: string): string => (typeof found?.[name] === "string" ? String(found[name]) : "");
150
+ return {
151
+ title: string("title") || title,
152
+ artist: string("artist") || artist,
153
+ text,
154
+ source: "nichedb",
155
+ language: string("language"),
156
+ };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ }
package/src/server.ts CHANGED
@@ -81,7 +81,10 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
81
81
  import { AiRadio } from "./ai-radio.ts";
82
82
  import { ListenerShares } from "./listener-shares.ts";
83
83
  import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
84
- import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.ts";
84
+ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions, supportHoldersFrom } from "./support.ts";
85
+ import { LyricsFinder } from "./lyrics.ts";
86
+ import { LyricsPublisher } from "./lyrics-publish.ts";
87
+ import { aiKey } from "./ai-keys.ts";
85
88
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
86
89
  import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
87
90
  import { StationRunner } from "./station-runner.ts";
@@ -1742,6 +1745,10 @@ export interface HandlerOptions {
1742
1745
  facts?: (station: Station, file: string) => Promise<import("./track-facts.ts").TrackFacts>;
1743
1746
  /** The cover picture inside a file, for the now-playing card. */
1744
1747
  cover?: (file: string) => Promise<{ bytes: Buffer; type: string } | null>;
1748
+ /** The words of a file on the air, from its tags or nichedb. */
1749
+ lyrics?: (station: Station, file: string) => Promise<import("./lyrics.ts").Lyrics | null>;
1750
+ /** Our own songs' words, published back to nichedb. */
1751
+ lyricsPublisher?: import("./lyrics-publish.ts").LyricsPublisher;
1745
1752
  /** Copyright holders, matched to tracks by artist or station. */
1746
1753
  holders?: import("./track-facts.ts").HolderStore;
1747
1754
  /** Proxies (by address) whose X-Forwarded-For is believed, for per-sharer limits. */
@@ -1924,6 +1931,8 @@ export interface HandlerOptions {
1924
1931
  translationPasses?: TranslationPasses;
1925
1932
  /** Payments to copyright holders nixamp.com collects for (support.ts). */
1926
1933
  support?: Support;
1934
+ /** Monthly support, run by nixamp.com (support.ts). */
1935
+ supportSubscriptions?: SupportSubscriptions;
1927
1936
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
1928
1937
  coinpayWebhookSecret?: string;
1929
1938
  sharedTranslations?: SharedTranslations;
@@ -2698,7 +2707,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2698
2707
  // What is on, as MCP, for anybody.
2699
2708
  path !== "/mcp/public" &&
2700
2709
  // Supporting a copyright holder is for anybody listening.
2701
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2710
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2702
2711
  // A station's short link, which sends the browser to wherever it plays.
2703
2712
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2704
2713
  !STATION_PAGE.test(path) &&
@@ -2765,6 +2774,22 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2765
2774
  * in the directory). It sends the browser to the station's own page, on
2766
2775
  * the server that plays it. Nothing private or unlisted is ever found.
2767
2776
  */
2777
+ /* A monthly support's cancel link, from any of its emails. */
2778
+ if (path === "/support/cancel" && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2779
+ let words: string;
2780
+ try {
2781
+ const done = await options.supportSubscriptions.cancel(url.searchParams.get("s") ?? "", url.searchParams.get("t") ?? "");
2782
+ const name = options.support?.holder(done.holder)?.name ?? done.holder;
2783
+ words = `Your monthly support for ${name} is cancelled. No more payment links will be sent.`;
2784
+ } catch (error) {
2785
+ words = error instanceof SupportError ? error.message : "That did not work; try the link again.";
2786
+ }
2787
+ const safe = words.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
2788
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer" });
2789
+ response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe}</p><p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2790
+ return;
2791
+ }
2792
+
2768
2793
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2769
2794
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2770
2795
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -2798,7 +2823,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2798
2823
  }
2799
2824
  let body: Record<string, unknown> = {};
2800
2825
  try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
2801
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2826
+ if (body["monthly"] === true) {
2827
+ if (!options.supportSubscriptions) throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
2828
+ json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
2829
+ } else {
2830
+ json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2831
+ }
2802
2832
  return;
2803
2833
  }
2804
2834
  json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
@@ -2975,6 +3005,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2975
3005
  ...(options.stations.auto ? { auto: options.stations.auto } : {}),
2976
3006
  ...(options.stations.facts ? { facts: options.stations.facts } : {}),
2977
3007
  ...(options.stations.cover ? { cover: options.stations.cover } : {}),
3008
+ ...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
3009
+ ...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
2978
3010
  ...(options.stations.peers ? { peers: () => options.stations!.peers!.entries() } : {}),
2979
3011
  throttle: () => {
2980
3012
  const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
@@ -7083,6 +7115,27 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7083
7115
  covers.set(file, { key, picture });
7084
7116
  return picture;
7085
7117
  };
7118
+ // The words of what is on: the file's own tag, else nichedb.dev's.
7119
+ const lyricsFinder = new LyricsFinder({ tags: (file) => tagReader.read(file), known: (file) => trackFor(engine, file) });
7120
+ stations.lyrics = (_station, file) => lyricsFinder.find(file);
7121
+ // Our own songs' words, back to nichedb, when a key is set: on any station
7122
+ // change, after a library scan, and once at start.
7123
+ const lyricsPublisher = new LyricsPublisher({
7124
+ file: LyricsPublisher.fileFor(stateDir(), options.port),
7125
+ key: () => aiKey("NIXAMP_LYRICS_KEY"),
7126
+ stations: () => stationStore.list(),
7127
+ tags: (file) => tagReader.read(file),
7128
+ known: (file) => trackFor(engine, file),
7129
+ log: (line) => console.log(line),
7130
+ });
7131
+ stations.lyricsPublisher = lyricsPublisher;
7132
+ stationStore.onSaved = () => lyricsPublisher.soon();
7133
+ const scannedBefore = libraryIndex.onScanned;
7134
+ libraryIndex.onScanned = (report) => {
7135
+ scannedBefore?.(report);
7136
+ lyricsPublisher.soon();
7137
+ };
7138
+ lyricsPublisher.soon();
7086
7139
  stations.facts = async (station, file) => {
7087
7140
  const share = listenerShares.atFile(file);
7088
7141
  const composed = aiRadio.trackAt(file);
@@ -7090,8 +7143,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7090
7143
  let stamp = "";
7091
7144
  try { stamp = String(Math.round(statSync(file).mtimeMs)); } catch { stamp = ""; }
7092
7145
  const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
7146
+ const words = await lyricsFinder.find(file).catch(() => null);
7093
7147
  return factsFor({
7094
7148
  art,
7149
+ ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
7095
7150
  station, file,
7096
7151
  tags: await tagReader.read(file),
7097
7152
  known: trackFor(engine, file),
@@ -7234,6 +7289,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7234
7289
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
7235
7290
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
7236
7291
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
7292
+ // Monthly support: each month a new CoinPay payment and its link by email.
7293
+ const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
7294
+ db: pool as unknown as import("./support.ts").Db,
7295
+ support: supportPayments,
7296
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
7297
+ site: nixampSite,
7298
+ log: (line) => console.log(line),
7299
+ }) : undefined;
7300
+ supportMonthly?.startSchedule();
7237
7301
  if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7238
7302
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
7239
7303
  ...(pool ? { db: pool } : {}),
@@ -7787,6 +7851,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7787
7851
  ...(liveVoice ? { liveVoice } : {}),
7788
7852
  ...(translationPasses ? { translationPasses } : {}),
7789
7853
  ...(supportPayments ? { support: supportPayments } : {}),
7854
+ ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
7790
7855
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7791
7856
  ...(sharedTranslations ? { sharedTranslations } : {}),
7792
7857
  ...(translator ? { translator } : {}),
@@ -253,6 +253,8 @@ export async function station(argv: string[]): Promise<number> {
253
253
  return await aiCommand(argv.slice(1), call, show, json);
254
254
  case "shares":
255
255
  return await sharesCommand(argv.slice(1), call, show);
256
+ case "lyrics":
257
+ return await lyricsCommand(argv.slice(1), call, show, json);
256
258
  case "holder":
257
259
  case "holders": {
258
260
  const sub = argv[1] ?? "list";
@@ -389,6 +391,44 @@ async function aiCommand(argv: string[], call: Call, show: Show, json: boolean):
389
391
  }
390
392
  }
391
393
 
394
+ /** `nixamp station lyrics ...`: our own songs' words, published back to nichedb.dev. */
395
+ async function lyricsCommand(argv: string[], call: Call, show: Show, json: boolean): Promise<number> {
396
+ const [sub, id] = argv;
397
+ switch (sub) {
398
+ case undefined:
399
+ case "status":
400
+ return show(await call("GET", "/api/v1/stations/lyrics"), (body) => {
401
+ const stations = (body["stations"] as Record<string, unknown>[] | undefined) ?? [];
402
+ const last = body["last"] as { at: number; reports: Array<Record<string, unknown>> } | null;
403
+ return [
404
+ `key ${body["key"] ? "set" : "- (nixamp station lyrics key, the value on stdin)"}`,
405
+ `target ${String(body["target"])}`,
406
+ `published ${String(body["published"])} files${body["running"] ? "; publishing now" : ""}`,
407
+ ...(last ? [`last run ${new Date(last.at).toISOString()}: ${last.reports.map((one) => `${String(one["station"])} +${String(one["published"])}`).join(", ") || "nothing eligible"}`] : []),
408
+ ...(body["lastError"] ? [`problem ${String(body["lastError"])}`] : []),
409
+ "",
410
+ ...stations.map((one) => ` ${String(one["id"])} ${one["eligible"] ? "publishes" : "no "} ${String(one["published"]).padStart(4)}/${String(one["files"]).padEnd(5)} ${String(one["name"])}${one["eligible"] ? "" : ` (${String(one["why"])})`}`),
411
+ ].join("\n");
412
+ });
413
+ case "publish": {
414
+ if (!id || id.startsWith("--")) { console.error("nixamp station lyrics publish needs a station id"); return 2; }
415
+ if (!json) console.log("Publishing; a second between songs…");
416
+ return show(await call("POST", `/api/v1/stations/lyrics/${id}/publish`, {}), (body) => {
417
+ const report = body["report"] as Record<string, unknown>;
418
+ return `${String(report["published"])} published, ${String(report["unchanged"])} already there, ${String(report["without"])} without words, ${String(report["failed"])} failed${report["stopped"] ? `\nstopped: ${String(report["stopped"])}` : ""}`;
419
+ });
420
+ }
421
+ case "key": {
422
+ const value = argv.includes("--clear") ? "" : await readStdin();
423
+ return show(await call("POST", "/api/v1/stations/ai/keys", { name: "NIXAMP_LYRICS_KEY", value }), (answer) =>
424
+ `nichedb lyrics key ${((answer["keys"] ?? {}) as Record<string, boolean>)["NIXAMP_LYRICS_KEY"] ? "set" : "cleared"}.`);
425
+ }
426
+ default:
427
+ console.error(`nixamp station lyrics: no command "${sub}".\n\n${USAGE}`);
428
+ return 2;
429
+ }
430
+ }
431
+
392
432
  /** `nixamp station shares ...` */
393
433
  async function sharesCommand(argv: string[], call: Call, show: Show): Promise<number> {
394
434
  const [sub, id] = argv;
@@ -14,7 +14,7 @@ import { createHash } from "node:crypto";
14
14
  const STYLE = `
15
15
  :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}
16
16
  @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}
17
- *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
17
+ *{box-sizing:border-box}[hidden]{display:none!important}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
18
18
  main{max-width:560px;margin:0 auto;padding:48px 16px}
19
19
  .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
20
20
  h1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}
@@ -30,6 +30,13 @@ button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(-
30
30
  label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
31
31
  input[type=range]{width:120px;accent-color:var(--accent)}
32
32
  .small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
33
+ .lyrics{margin:20px 0 0;border-top:1px solid var(--line);padding-top:12px}
34
+ .lyrics summary{cursor:pointer;font-weight:600;min-height:24px}
35
+ .lyrics .for{font-weight:400;color:var(--muted);font-size:.9rem}
36
+ .words{margin-top:10px;line-height:1.6}
37
+ .words p{margin:0 0 1em;white-space:pre-wrap;overflow-wrap:anywhere}
38
+ .words p.sec{margin:1.2em 0 .3em;font-size:.75rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
39
+ .words p.sec:first-child{margin-top:0}
33
40
  .screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}
34
41
  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
35
42
  @media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
@@ -90,13 +97,37 @@ const SCRIPT = `
90
97
  if (!wanted || audio.paused || audio.buffered.length === 0) return 0;
91
98
  return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));
92
99
  };
100
+ // The words of what is on, line by line; "[Verse 1]" as a small header. Text only, never HTML.
101
+ let words = null;
102
+ const lyrics = async () => {
103
+ try {
104
+ const r = await fetch(api + "/lyrics" + q, { cache: "no-store" });
105
+ const got = r.ok ? (await r.json()).lyrics : null;
106
+ const key = got ? got.title + "\\n" + got.text : "";
107
+ if (key === words) return;
108
+ words = key;
109
+ $("#lyrics").hidden = !got;
110
+ if (!got) return;
111
+ const out = [];
112
+ let stanza = [];
113
+ const flush = () => { if (stanza.length) { const p = document.createElement("p"); p.textContent = stanza.join("\\n"); out.push(p); stanza = []; } };
114
+ for (const line of got.text.split("\\n")) {
115
+ if (/^\\s*\\[[^\\]]{1,160}\\]\\s*$/.test(line)) { flush(); const h = document.createElement("p"); h.className = "sec"; h.textContent = line.trim().slice(1, -1); out.push(h); }
116
+ else if (line.trim() === "") flush();
117
+ else stanza.push(line);
118
+ }
119
+ flush();
120
+ $("#words").replaceChildren(...out);
121
+ $("#lyrics-for").textContent = got.title ? " · " + got.title : "";
122
+ } catch {}
123
+ };
93
124
  const listen = () => {
94
125
  if (!window.EventSource) return;
95
126
  const events = new EventSource(api + "/events" + q);
96
- events.addEventListener("hello", (e) => { live = true; title(JSON.parse(e.data)); });
127
+ events.addEventListener("hello", (e) => { live = true; title(JSON.parse(e.data)); lyrics(); });
97
128
  events.addEventListener("now", (e) => {
98
129
  const s = JSON.parse(e.data);
99
- setTimeout(() => { title(s); say(s.nowPlaying ? "Now playing " + (s.nowPlaying.title || "") : ""); }, ahead() * 1000);
130
+ setTimeout(() => { title(s); lyrics(); say(s.nowPlaying ? "Now playing " + (s.nowPlaying.title || "") : ""); }, ahead() * 1000);
100
131
  });
101
132
  events.addEventListener("state", () => poll());
102
133
  events.onerror = () => { live = false; };
@@ -122,7 +153,9 @@ const SCRIPT = `
122
153
  wire();
123
154
  $("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
124
155
  $("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
156
+ if (window.matchMedia && matchMedia("(max-width: 640px)").matches) $("#lyrics-box").open = false;
125
157
  poll();
158
+ lyrics();
126
159
  listen();
127
160
  setInterval(poll, 5000);
128
161
  })();
@@ -152,6 +185,9 @@ const PAGE = `<!doctype html>
152
185
  <button id="play" type="button" aria-pressed="false" disabled>Play</button>
153
186
  <label>Volume <input id="volume" type="range" min="0" max="1" step="0.05" value="1"></label>
154
187
  </div>
188
+ <section id="lyrics" class="lyrics" aria-label="Lyrics" hidden>
189
+ <details id="lyrics-box" open><summary>Lyrics<span id="lyrics-for" class="for"></span></summary><div id="words" class="words"></div></details>
190
+ </section>
155
191
  <p class="small"><a id="m3u" href="#">Open in another player (.m3u)</a> · Pausing here does not pause the station.</p>
156
192
  </div>
157
193
  <p class="small">Played by <a href="https://nixamp.com">nixamp</a>.</p>
@@ -17,12 +17,14 @@ import type { Channels } from "./channels.ts";
17
17
  import type { StationRunner } from "./station-runner.ts";
18
18
  import { alertsFor, type Limits, type StationUsage } from "./station-metering.ts";
19
19
  import { aiFromTags, type TrackFacts } from "./track-facts.ts";
20
+ import type { Lyrics } from "./lyrics.ts";
21
+ import type { PublishReport } from "./lyrics-publish.ts";
20
22
  import {
21
23
  StationError, channelOf, tokensMatch, type OwnerStation, type PublicStation, type Station, type StationStore,
22
24
  } from "./stations.ts";
23
25
 
24
26
  /** The three things a station's link opens. */
25
- const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art)$/;
27
+ const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
26
28
 
27
29
  /** Whether this path is one a station's own link may open, without the server's key. */
28
30
  export function isStationListenPath(path: string): boolean {
@@ -64,6 +66,10 @@ export interface StationContext {
64
66
  facts?: (station: Station, file: string) => Promise<TrackFacts>;
65
67
  /** The cover picture inside a file (JPEG/PNG bytes), or null. */
66
68
  cover?: (file: string) => Promise<{ bytes: Buffer; type: string } | null>;
69
+ /** The words of a file, from its tags or nichedb; null when none are known. */
70
+ lyrics?: (station: Station, file: string) => Promise<Lyrics | null>;
71
+ /** Our own songs' words, published back to nichedb (lyrics-publish.ts). */
72
+ lyricsPublisher?: { status: () => unknown; publish: (station: Station) => Promise<PublishReport> };
67
73
  /** Auto stations: run a sync with these settings (and keep them), or read them. */
68
74
  auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
69
75
  /** The public stations other servers list (guide peers); absent when there are none. */
@@ -272,6 +278,16 @@ export async function handleStations(
272
278
  response.end(method === "HEAD" ? undefined : picture.bytes);
273
279
  return true;
274
280
  }
281
+ if (what === "lyrics") {
282
+ const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
283
+ const lyrics = file && ctx.lyrics ? await ctx.lyrics(station, file).catch(() => null) : null;
284
+ if (!lyrics) {
285
+ ctx.json(response, 404, { error: "no lyrics for what is playing", code: "not_found" });
286
+ return true;
287
+ }
288
+ ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language } });
289
+ return true;
290
+ }
275
291
  if (what === "now") {
276
292
  const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
277
293
  ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
@@ -419,6 +435,35 @@ export async function handleStations(
419
435
  return true;
420
436
  }
421
437
 
438
+ if (path === "/api/v1/stations/lyrics") {
439
+ if (!ctx.lyricsPublisher) {
440
+ ctx.json(response, 404, { error: "lyrics publishing is not available here", code: "not_found" });
441
+ return true;
442
+ }
443
+ if (method !== "GET") {
444
+ ctx.json(response, 405, { error: "GET", code: "method_not_allowed" });
445
+ return true;
446
+ }
447
+ ctx.json(response, 200, ctx.lyricsPublisher.status());
448
+ return true;
449
+ }
450
+ const publishLyrics = /^\/api\/v1\/stations\/lyrics\/([a-z0-9]{10})\/publish$/.exec(path);
451
+ if (publishLyrics) {
452
+ const target = ctx.store.get(publishLyrics[1]!);
453
+ if (!ctx.lyricsPublisher || !target) {
454
+ ctx.json(response, 404, { error: "no such station", code: "not_found" });
455
+ return true;
456
+ }
457
+ if (method !== "POST") {
458
+ ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
459
+ return true;
460
+ }
461
+ const report = await ctx.lyricsPublisher.publish(target);
462
+ const refused = report.stopped.startsWith("not published") ? "not_eligible" : report.stopped.startsWith("no key") ? "no_key" : "";
463
+ ctx.json(response, refused ? 409 : 200, refused ? { error: report.stopped, code: refused, report } : { report });
464
+ return true;
465
+ }
466
+
422
467
  const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
423
468
  if (!match) {
424
469
  ctx.json(response, 404, { error: "no such station", code: "not_found" });
@@ -41,6 +41,11 @@ export const USAGE = `nixamp station — make a collection into a station
41
41
  nixamp station shares approve|reject|delete SHARE
42
42
  nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For
43
43
 
44
+ Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):
45
+ nixamp station lyrics the key, what was published, which stations may publish
46
+ nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)
47
+ nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)
48
+
44
49
  Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):
45
50
  nixamp station holder list
46
51
  nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]
package/src/stations.ts CHANGED
@@ -192,8 +192,12 @@ export class StationStore {
192
192
  return join(stateDir, `stations-${port}.json`);
193
193
  }
194
194
 
195
+ /** Told after every change is written (lyrics publishing listens). */
196
+ onSaved: (() => void) | null = null;
197
+
195
198
  private save(): void {
196
199
  this.io.write(this.file, `${JSON.stringify({ version: 1, stations: [...this.stations.values()] }, null, 2)}\n`);
200
+ try { this.onSaved?.(); } catch { /* a listener's trouble is not the store's */ }
197
201
  }
198
202
 
199
203
  list(): Station[] {