nixamp 0.42.0 → 0.43.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 (54) 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 +4 -0
  8. package/dist/server.js +28 -0
  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/track-facts.d.ts +4 -0
  19. package/dist/track-facts.js +8 -4
  20. package/package.json +1 -1
  21. package/src/ai-keys.ts +2 -0
  22. package/src/lyrics-publish.ts +203 -0
  23. package/src/lyrics.ts +161 -0
  24. package/src/server.ts +32 -0
  25. package/src/station-cli.ts +40 -0
  26. package/src/station-page.ts +39 -3
  27. package/src/station-routes.ts +46 -1
  28. package/src/station-usage.ts +5 -0
  29. package/src/stations.ts +4 -0
  30. package/src/track-facts.ts +11 -2
  31. package/web/dist/assets/{ar-S4rqddex.js → ar-CnqhCJJo.js} +1 -1
  32. package/web/dist/assets/{cs-BShF2rip.js → cs-Bsyx7wxR.js} +1 -1
  33. package/web/dist/assets/{da-BO87iZOE.js → da-B4kIRtMO.js} +1 -1
  34. package/web/dist/assets/{de-CUANqdKo.js → de-CUIKrC06.js} +1 -1
  35. package/web/dist/assets/{es-BdUyDUC0.js → es-xgQHSTbS.js} +1 -1
  36. package/web/dist/assets/{fi-BBBq-Bd8.js → fi-BarRr2r9.js} +1 -1
  37. package/web/dist/assets/{fr-DqZ3HEY8.js → fr-CkdhPlV8.js} +1 -1
  38. package/web/dist/assets/{hi-BBMF8sCD.js → hi-Bmz_R4Qe.js} +1 -1
  39. package/web/dist/assets/{hu-D1LEEcho.js → hu-Buques8B.js} +1 -1
  40. package/web/dist/assets/{id-CBC-Hup3.js → id-DI6eGXlp.js} +1 -1
  41. package/web/dist/assets/{it-sk9K0Y5L.js → it-DoPVwxbT.js} +1 -1
  42. package/web/dist/assets/{main-ByEHJWGn.js → main-VepIOCc0.js} +2 -2
  43. package/web/dist/assets/{nl-DioO6nqY.js → nl-Bi2G8eau.js} +1 -1
  44. package/web/dist/assets/{ru-o2SYLX7L.js → ru-gdX1kz2l.js} +1 -1
  45. package/web/dist/assets/station-B9ORrH8G.js +3 -0
  46. package/web/dist/assets/{station--cxhU1wr.css → station-BwDP2cuf.css} +1 -1
  47. package/web/dist/assets/{sv-CCey7ex0.js → sv-DETdNoY-.js} +1 -1
  48. package/web/dist/assets/{uk-C2gHK98V.js → uk-BiRmx495.js} +1 -1
  49. package/web/dist/assets/{vi-B6AG6Gxz.js → vi-C018OY69.js} +1 -1
  50. package/web/dist/assets/{zh-Cnjg8Tp_.js → zh-HZ6x11wW.js} +1 -1
  51. package/web/dist/index.html +1 -1
  52. package/web/dist/station.html +9 -2
  53. package/web/dist/sw.js +21 -21
  54. package/web/dist/assets/station-CdQ9LR6K.js +0 -1
package/dist/ai-keys.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const AI_KEY_NAMES: readonly ["ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL"];
1
+ export declare const AI_KEY_NAMES: readonly ["ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL", "NIXAMP_LYRICS_KEY"];
2
2
  export type AiKeyName = (typeof AI_KEY_NAMES)[number];
3
3
  export declare function aiKeysFile(): string;
4
4
  /** The key, from the environment or ai.json; "" when neither has it. */
package/dist/ai-keys.js CHANGED
@@ -13,6 +13,8 @@ export const AI_KEY_NAMES = [
13
13
  "ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
14
14
  // Any OpenAI-compatible endpoint in OpenAI's place (Cloudflare Workers AI, Ollama, ...) and its model.
15
15
  "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL",
16
+ // nichedb.dev's key for publishing our own songs' words (lyrics-publish.ts).
17
+ "NIXAMP_LYRICS_KEY",
16
18
  ];
17
19
  export function aiKeysFile() {
18
20
  return join(stateDir(), "ai.json");
@@ -0,0 +1,56 @@
1
+ import type { Station } from "./stations.ts";
2
+ type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
3
+ /** Whether a station's lyrics may be published: owned, public and listed. */
4
+ export declare function mayPublishLyrics(station: Pick<Station, "visibility" | "listed" | "rights">): boolean;
5
+ /** Why not, in words, or "" when it may. */
6
+ export declare function whyNotPublish(station: Pick<Station, "visibility" | "listed" | "rights">): string;
7
+ export interface PublishReport {
8
+ station: string;
9
+ published: number;
10
+ unchanged: number;
11
+ /** Files with no words in their tags, or no artist or title to file them under. */
12
+ without: number;
13
+ failed: number;
14
+ stopped: string;
15
+ }
16
+ export interface LyricsPublisherOptions {
17
+ file: string;
18
+ /** The key nichedb takes, read each run (it may be set while the server runs); "" when unset. */
19
+ key: () => string;
20
+ stations: () => Station[];
21
+ tags: (file: string) => Promise<Record<string, string>>;
22
+ known?: (file: string) => {
23
+ title: string;
24
+ artist: string;
25
+ album: string;
26
+ } | null;
27
+ base?: string;
28
+ fetcher?: Fetcher;
29
+ /** Between two posts. */
30
+ gapMs?: number;
31
+ /** After a change, before a run (changes come in bursts). */
32
+ debounceMs?: number;
33
+ log?: (line: string) => void;
34
+ }
35
+ export declare class LyricsPublisher {
36
+ private readonly options;
37
+ private sent;
38
+ private running;
39
+ private timer;
40
+ private last;
41
+ private lastError;
42
+ private readonly base;
43
+ private readonly fetcher;
44
+ constructor(options: LyricsPublisherOptions);
45
+ static fileFor(stateDir: string, port: number): string;
46
+ private save;
47
+ /** Something changed (a station, the library): publish what is new, shortly. Nothing without a key. */
48
+ soon(): void;
49
+ status(): Record<string, unknown>;
50
+ /** Every eligible station, one after another; a run already going is joined, not doubled. */
51
+ publishAll(): Promise<PublishReport[]>;
52
+ /** One station, now. Refused (stopped says why) when it may not be published or there is no key. */
53
+ publish(station: Station): Promise<PublishReport>;
54
+ private publishOne;
55
+ }
56
+ export {};
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Our own songs' words, published back to nichedb.dev.
3
+ *
4
+ * A station whose owner declared the music theirs (basis "owned") and put it
5
+ * on the public guide (public and listed) is music we may publish the words
6
+ * of. For each of its files whose own tags carry lyrics (lyrics.ts), the
7
+ * words go to nichedb's POST /api/v1/lyrics with the key the owner set
8
+ * (NIXAMP_LYRICS_KEY, or `nixamp station lyrics key` into ai.json), once per
9
+ * text: what was sent is remembered by a hash of the words, so a file is sent
10
+ * again only when its words change. One at a time, a second apart, and a
11
+ * refusal of the key (401/403) or a 429 stops the run.
12
+ *
13
+ * Nothing is published without a key, and nothing for a station that is not
14
+ * owned, whatever else is true of it.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { lyricsFromTags, NICHEDB_LYRICS } from "./lyrics.js";
20
+ /** Whether a station's lyrics may be published: owned, public and listed. */
21
+ export function mayPublishLyrics(station) {
22
+ return station.rights?.basis === "owned" && station.visibility === "public" && station.listed === true;
23
+ }
24
+ /** Why not, in words, or "" when it may. */
25
+ export function whyNotPublish(station) {
26
+ if (station.rights?.basis !== "owned")
27
+ return "its music is not declared owned (nixamp station publish ID --basis owned)";
28
+ if (station.visibility !== "public")
29
+ return "it is not public";
30
+ if (!station.listed)
31
+ return "it is not listed on the guide";
32
+ return "";
33
+ }
34
+ export class LyricsPublisher {
35
+ options;
36
+ sent = {};
37
+ running = null;
38
+ timer = null;
39
+ last = null;
40
+ lastError = "";
41
+ base;
42
+ fetcher;
43
+ constructor(options) {
44
+ this.options = options;
45
+ this.base = (options.base ?? process.env["NIXAMP_LYRICS_URL"] ?? NICHEDB_LYRICS).replace(/\/+$/, "");
46
+ this.fetcher = options.fetcher ?? ((url, init) => fetch(url, init));
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(options.file, "utf8"));
49
+ this.sent = parsed.sent ?? {};
50
+ }
51
+ catch {
52
+ this.sent = {};
53
+ }
54
+ }
55
+ static fileFor(stateDir, port) {
56
+ return join(stateDir, `lyrics-published-${port}.json`);
57
+ }
58
+ save() {
59
+ mkdirSync(dirname(this.options.file), { recursive: true });
60
+ const temporary = `${this.options.file}.${process.pid}.tmp`;
61
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, sent: this.sent }, null, 2)}\n`, { mode: 0o600 });
62
+ renameSync(temporary, this.options.file);
63
+ }
64
+ /** Something changed (a station, the library): publish what is new, shortly. Nothing without a key. */
65
+ soon() {
66
+ // A run is already due: changes in a burst ride on it (a busy store saves often).
67
+ if (!this.options.key() || this.timer)
68
+ return;
69
+ this.timer = setTimeout(() => {
70
+ this.timer = null;
71
+ void this.publishAll().catch(() => undefined);
72
+ }, this.options.debounceMs ?? 30_000);
73
+ this.timer.unref?.();
74
+ }
75
+ status() {
76
+ const eligible = this.options.stations().filter(mayPublishLyrics);
77
+ return {
78
+ key: this.options.key() !== "",
79
+ target: this.base,
80
+ published: Object.keys(this.sent).length,
81
+ running: this.running !== null,
82
+ last: this.last,
83
+ lastError: this.lastError,
84
+ stations: this.options.stations().map((one) => ({
85
+ id: one.id,
86
+ name: one.name,
87
+ eligible: mayPublishLyrics(one),
88
+ why: whyNotPublish(one),
89
+ files: one.items.length,
90
+ published: one.items.filter((file) => this.sent[file]).length,
91
+ })),
92
+ eligible: eligible.length,
93
+ };
94
+ }
95
+ /** Every eligible station, one after another; a run already going is joined, not doubled. */
96
+ publishAll() {
97
+ if (this.running)
98
+ return this.running;
99
+ this.running = (async () => {
100
+ const reports = [];
101
+ for (const station of this.options.stations().filter(mayPublishLyrics)) {
102
+ const report = await this.publishOne(station);
103
+ reports.push(report);
104
+ if (report.stopped)
105
+ break;
106
+ }
107
+ this.last = { at: Date.now(), reports };
108
+ const sent = reports.reduce((sum, one) => sum + one.published, 0);
109
+ if (sent > 0)
110
+ this.options.log?.(` lyrics: published ${sent} to ${this.base}.`);
111
+ return reports;
112
+ })().finally(() => { this.running = null; });
113
+ return this.running;
114
+ }
115
+ /** One station, now. Refused (stopped says why) when it may not be published or there is no key. */
116
+ async publish(station) {
117
+ if (this.running)
118
+ await this.running.catch(() => undefined);
119
+ return this.publishOne(station);
120
+ }
121
+ async publishOne(station) {
122
+ const report = { station: station.id, published: 0, unchanged: 0, without: 0, failed: 0, stopped: "" };
123
+ const why = whyNotPublish(station);
124
+ if (why) {
125
+ report.stopped = `not published: ${why}`;
126
+ return report;
127
+ }
128
+ const key = this.options.key();
129
+ if (!key) {
130
+ report.stopped = "no key: set NIXAMP_LYRICS_KEY or `nixamp station lyrics key`";
131
+ return report;
132
+ }
133
+ if (!this.base) {
134
+ report.stopped = "no target: NIXAMP_LYRICS_URL is empty";
135
+ return report;
136
+ }
137
+ let first = true;
138
+ for (const file of station.items) {
139
+ let tags = {};
140
+ try {
141
+ tags = await this.options.tags(file);
142
+ }
143
+ catch {
144
+ tags = {};
145
+ }
146
+ const words = lyricsFromTags(tags);
147
+ const known = this.options.known?.(file) ?? null;
148
+ const title = tags["title"] || known?.title || "";
149
+ const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
150
+ const album = tags["album"] || known?.album || "";
151
+ if (!words || !title || !artist) {
152
+ report.without += 1;
153
+ continue;
154
+ }
155
+ const hash = createHash("sha256").update(`${artist}\n${title}\n${words.text}`).digest("hex");
156
+ if (this.sent[file]?.hash === hash) {
157
+ report.unchanged += 1;
158
+ continue;
159
+ }
160
+ if (!first)
161
+ await new Promise((done) => setTimeout(done, this.options.gapMs ?? 1_000));
162
+ first = false;
163
+ let response;
164
+ try {
165
+ response = await this.fetcher(`${this.base}/api/v1/lyrics`, {
166
+ method: "POST",
167
+ headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${key}` },
168
+ body: JSON.stringify({ artist, title, album, language: words.language, text: words.text, source: "embedded" }),
169
+ signal: AbortSignal.timeout(15_000),
170
+ });
171
+ }
172
+ catch (error) {
173
+ report.failed += 1;
174
+ this.lastError = `could not reach ${this.base}: ${error.message}`;
175
+ report.stopped = this.lastError;
176
+ break;
177
+ }
178
+ if (response.ok) {
179
+ this.sent[file] = { hash, at: Date.now(), artist, title };
180
+ this.save();
181
+ report.published += 1;
182
+ continue;
183
+ }
184
+ report.failed += 1;
185
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
186
+ this.lastError = `nichedb answered ${response.status} for "${title}"${detail ? `: ${detail}` : ""}`;
187
+ if (response.status === 401 || response.status === 403 || response.status === 429 || response.status >= 500) {
188
+ report.stopped = this.lastError;
189
+ break;
190
+ }
191
+ }
192
+ return report;
193
+ }
194
+ }
@@ -0,0 +1,67 @@
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
+ export interface Lyrics {
17
+ title: string;
18
+ artist: string;
19
+ text: string;
20
+ /** "embedded" (the file's own tag) or "nichedb" (or what nichedb said its source was). */
21
+ source: string;
22
+ /** ISO 639-1 where we know the mapping, else what the tag said; "" when unsaid. */
23
+ language: string;
24
+ }
25
+ export declare const NICHEDB_LYRICS = "https://nichedb.dev";
26
+ /** Text as it may be shown: newlines kept, control characters gone, trimmed, capped. */
27
+ export declare function cleanLyrics(text: string): string;
28
+ /** The words a file's own tags carry (lower-cased names, as TagReader gives them), or null. */
29
+ export declare function lyricsFromTags(tags: Record<string, string>): {
30
+ text: string;
31
+ language: string;
32
+ } | null;
33
+ type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
34
+ export interface LyricsFinderOptions {
35
+ /** The file's tags (TagReader.read). */
36
+ tags: (file: string) => Promise<Record<string, string>>;
37
+ /** What the library knows when the file has no tag for it. */
38
+ known?: (file: string) => {
39
+ title: string;
40
+ artist: string;
41
+ } | null;
42
+ /** nichedb's address; NIXAMP_LYRICS_URL, else nichedb.dev. "" turns the lookup off. */
43
+ base?: string;
44
+ fetcher?: Fetcher;
45
+ /** How long nichedb's answer (or its silence) is kept. */
46
+ ttlMs?: number;
47
+ timeoutMs?: number;
48
+ now?: () => number;
49
+ }
50
+ /** Finds a file's words, from its tags, else from nichedb. */
51
+ export declare class LyricsFinder {
52
+ private readonly options;
53
+ private remote;
54
+ private asking;
55
+ private readonly base;
56
+ private readonly fetcher;
57
+ private readonly ttl;
58
+ private readonly timeout;
59
+ private readonly now;
60
+ constructor(options: LyricsFinderOptions);
61
+ /** The file's words, or null. Never throws; never waits on nichedb longer than the timeout. */
62
+ find(file: string): Promise<Lyrics | null>;
63
+ /** What nichedb has for this artist and title, kept for ten minutes. */
64
+ lookup(artist: string, title: string): Promise<Lyrics | null>;
65
+ private ask;
66
+ }
67
+ export {};
package/dist/lyrics.js ADDED
@@ -0,0 +1,145 @@
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
+ export const NICHEDB_LYRICS = "https://nichedb.dev";
17
+ const MAX_TEXT = 20_000;
18
+ /** ID3 / ISO 639-2 codes for the languages we are likely to meet, as 639-1. */
19
+ const LANGS = {
20
+ eng: "en", deu: "de", ger: "de", fra: "fr", fre: "fr", spa: "es", ita: "it", por: "pt", nld: "nl", dut: "nl",
21
+ swe: "sv", nor: "no", nob: "nb", dan: "da", isl: "is", ice: "is", fin: "fi", jpn: "ja", kor: "ko", zho: "zh",
22
+ chi: "zh", rus: "ru", pol: "pl", ukr: "uk", tur: "tr", ara: "ar", heb: "he", hin: "hi", ces: "cs", cze: "cs",
23
+ ell: "el", gre: "el", hun: "hu", ron: "ro", rum: "ro", lat: "la", gle: "ga", cym: "cy", wel: "cy",
24
+ };
25
+ function language(code) {
26
+ const lower = code.toLowerCase();
27
+ if (lower === "" || lower === "xxx" || lower === "und" || lower === "zxx")
28
+ return "";
29
+ return LANGS[lower] ?? lower;
30
+ }
31
+ /** Text as it may be shown: newlines kept, control characters gone, trimmed, capped. */
32
+ export function cleanLyrics(text) {
33
+ return text
34
+ .replace(/\r\n?/g, "\n")
35
+ // eslint-disable-next-line no-control-regex
36
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "")
37
+ .replace(/\n{3,}/g, "\n\n")
38
+ .trim()
39
+ .slice(0, MAX_TEXT);
40
+ }
41
+ /** The words a file's own tags carry (lower-cased names, as TagReader gives them), or null. */
42
+ export function lyricsFromTags(tags) {
43
+ let best = null;
44
+ for (const [name, value] of Object.entries(tags)) {
45
+ let lang = "";
46
+ if (name === "lyrics" || name === "unsyncedlyrics" || name === "unsynced lyrics" || name === "unsynced_lyrics" || name === "uslt") {
47
+ lang = "";
48
+ }
49
+ else if (name.startsWith("lyrics-") || name.startsWith("lyrics_")) {
50
+ // lyrics-eng, or lyrics-<description>-eng: the language is the last part.
51
+ const last = name.slice("lyrics-".length).split(/[-_]/).pop() ?? "";
52
+ lang = /^[a-z]{3}$/.test(last) ? last : "";
53
+ }
54
+ else {
55
+ continue;
56
+ }
57
+ const text = cleanLyrics(value);
58
+ if (text === "")
59
+ continue;
60
+ // The longest wins: a short descriptor-only frame is not the song.
61
+ if (!best || text.length > best.text.length)
62
+ best = { text, language: language(lang) };
63
+ }
64
+ return best;
65
+ }
66
+ /** Finds a file's words, from its tags, else from nichedb. */
67
+ export class LyricsFinder {
68
+ options;
69
+ remote = new Map();
70
+ asking = new Map();
71
+ base;
72
+ fetcher;
73
+ ttl;
74
+ timeout;
75
+ now;
76
+ constructor(options) {
77
+ this.options = options;
78
+ this.base = (options.base ?? process.env["NIXAMP_LYRICS_URL"] ?? NICHEDB_LYRICS).replace(/\/+$/, "");
79
+ this.fetcher = options.fetcher ?? ((url, init) => fetch(url, init));
80
+ this.ttl = options.ttlMs ?? 10 * 60_000;
81
+ this.timeout = options.timeoutMs ?? 4_000;
82
+ this.now = options.now ?? Date.now;
83
+ }
84
+ /** The file's words, or null. Never throws; never waits on nichedb longer than the timeout. */
85
+ async find(file) {
86
+ let tags = {};
87
+ try {
88
+ tags = await this.options.tags(file);
89
+ }
90
+ catch {
91
+ tags = {};
92
+ }
93
+ const known = this.options.known?.(file) ?? null;
94
+ const title = tags["title"] || known?.title || "";
95
+ const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
96
+ const own = lyricsFromTags(tags);
97
+ if (own)
98
+ return { title, artist, text: own.text, source: "embedded", language: own.language };
99
+ if (!title || !artist || !this.base)
100
+ return null;
101
+ return this.lookup(artist, title);
102
+ }
103
+ /** What nichedb has for this artist and title, kept for ten minutes. */
104
+ lookup(artist, title) {
105
+ const key = `${artist.toLowerCase()}\n${title.toLowerCase()}`;
106
+ const hit = this.remote.get(key);
107
+ if (hit && this.now() - hit.at < this.ttl)
108
+ return Promise.resolve(hit.lyrics);
109
+ const pending = this.asking.get(key);
110
+ if (pending)
111
+ return pending;
112
+ const asked = this.ask(artist, title).then((lyrics) => {
113
+ if (this.remote.size > 2000)
114
+ this.remote.clear();
115
+ this.remote.set(key, { at: this.now(), lyrics });
116
+ return lyrics;
117
+ }).finally(() => this.asking.delete(key));
118
+ this.asking.set(key, asked);
119
+ return asked;
120
+ }
121
+ async ask(artist, title) {
122
+ const url = `${this.base}/api/v1/lyrics?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`;
123
+ try {
124
+ const response = await this.fetcher(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(this.timeout) });
125
+ if (!response.ok)
126
+ return null;
127
+ const body = await response.json();
128
+ const found = body?.lyrics;
129
+ const text = typeof found?.["text"] === "string" ? cleanLyrics(found["text"]) : "";
130
+ if (!text)
131
+ return null;
132
+ const string = (name) => (typeof found?.[name] === "string" ? String(found[name]) : "");
133
+ return {
134
+ title: string("title") || title,
135
+ artist: string("artist") || artist,
136
+ text,
137
+ source: "nichedb",
138
+ language: string("language"),
139
+ };
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ }
package/dist/server.d.ts CHANGED
@@ -606,6 +606,10 @@ export interface HandlerOptions {
606
606
  bytes: Buffer;
607
607
  type: string;
608
608
  } | null>;
609
+ /** The words of a file on the air, from its tags or nichedb. */
610
+ lyrics?: (station: Station, file: string) => Promise<import("./lyrics.ts").Lyrics | null>;
611
+ /** Our own songs' words, published back to nichedb. */
612
+ lyricsPublisher?: import("./lyrics-publish.ts").LyricsPublisher;
609
613
  /** Copyright holders, matched to tracks by artist or station. */
610
614
  holders?: import("./track-facts.ts").HolderStore;
611
615
  /** Proxies (by address) whose X-Forwarded-For is believed, for per-sharer limits. */
package/dist/server.js CHANGED
@@ -65,6 +65,9 @@ import { AiRadio } from "./ai-radio.js";
65
65
  import { ListenerShares } from "./listener-shares.js";
66
66
  import { HolderStore, TagReader, factsFor } from "./track-facts.js";
67
67
  import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions, supportHoldersFrom } from "./support.js";
68
+ import { LyricsFinder } from "./lyrics.js";
69
+ import { LyricsPublisher } from "./lyrics-publish.js";
70
+ import { aiKey } from "./ai-keys.js";
68
71
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
69
72
  import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
70
73
  import { StationRunner } from "./station-runner.js";
@@ -2468,6 +2471,8 @@ export function createHandler(engine, options) {
2468
2471
  ...(options.stations.auto ? { auto: options.stations.auto } : {}),
2469
2472
  ...(options.stations.facts ? { facts: options.stations.facts } : {}),
2470
2473
  ...(options.stations.cover ? { cover: options.stations.cover } : {}),
2474
+ ...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
2475
+ ...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
2471
2476
  ...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
2472
2477
  throttle: () => {
2473
2478
  const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
@@ -6624,6 +6629,27 @@ export async function serve(argv, version = "0.1.0") {
6624
6629
  covers.set(file, { key, picture });
6625
6630
  return picture;
6626
6631
  };
6632
+ // The words of what is on: the file's own tag, else nichedb.dev's.
6633
+ const lyricsFinder = new LyricsFinder({ tags: (file) => tagReader.read(file), known: (file) => trackFor(engine, file) });
6634
+ stations.lyrics = (_station, file) => lyricsFinder.find(file);
6635
+ // Our own songs' words, back to nichedb, when a key is set: on any station
6636
+ // change, after a library scan, and once at start.
6637
+ const lyricsPublisher = new LyricsPublisher({
6638
+ file: LyricsPublisher.fileFor(stateDir(), options.port),
6639
+ key: () => aiKey("NIXAMP_LYRICS_KEY"),
6640
+ stations: () => stationStore.list(),
6641
+ tags: (file) => tagReader.read(file),
6642
+ known: (file) => trackFor(engine, file),
6643
+ log: (line) => console.log(line),
6644
+ });
6645
+ stations.lyricsPublisher = lyricsPublisher;
6646
+ stationStore.onSaved = () => lyricsPublisher.soon();
6647
+ const scannedBefore = libraryIndex.onScanned;
6648
+ libraryIndex.onScanned = (report) => {
6649
+ scannedBefore?.(report);
6650
+ lyricsPublisher.soon();
6651
+ };
6652
+ lyricsPublisher.soon();
6627
6653
  stations.facts = async (station, file) => {
6628
6654
  const share = listenerShares.atFile(file);
6629
6655
  const composed = aiRadio.trackAt(file);
@@ -6636,8 +6662,10 @@ export async function serve(argv, version = "0.1.0") {
6636
6662
  stamp = "";
6637
6663
  }
6638
6664
  const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
6665
+ const words = await lyricsFinder.find(file).catch(() => null);
6639
6666
  return factsFor({
6640
6667
  art,
6668
+ ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
6641
6669
  station, file,
6642
6670
  tags: await tagReader.read(file),
6643
6671
  known: trackFor(engine, file),
@@ -265,6 +265,8 @@ export async function station(argv) {
265
265
  return await aiCommand(argv.slice(1), call, show, json);
266
266
  case "shares":
267
267
  return await sharesCommand(argv.slice(1), call, show);
268
+ case "lyrics":
269
+ return await lyricsCommand(argv.slice(1), call, show, json);
268
270
  case "holder":
269
271
  case "holders": {
270
272
  const sub = argv[1] ?? "list";
@@ -417,6 +419,46 @@ async function aiCommand(argv, call, show, json) {
417
419
  return 2;
418
420
  }
419
421
  }
422
+ /** `nixamp station lyrics ...`: our own songs' words, published back to nichedb.dev. */
423
+ async function lyricsCommand(argv, call, show, json) {
424
+ const [sub, id] = argv;
425
+ switch (sub) {
426
+ case undefined:
427
+ case "status":
428
+ return show(await call("GET", "/api/v1/stations/lyrics"), (body) => {
429
+ const stations = body["stations"] ?? [];
430
+ const last = body["last"];
431
+ return [
432
+ `key ${body["key"] ? "set" : "- (nixamp station lyrics key, the value on stdin)"}`,
433
+ `target ${String(body["target"])}`,
434
+ `published ${String(body["published"])} files${body["running"] ? "; publishing now" : ""}`,
435
+ ...(last ? [`last run ${new Date(last.at).toISOString()}: ${last.reports.map((one) => `${String(one["station"])} +${String(one["published"])}`).join(", ") || "nothing eligible"}`] : []),
436
+ ...(body["lastError"] ? [`problem ${String(body["lastError"])}`] : []),
437
+ "",
438
+ ...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"])})`}`),
439
+ ].join("\n");
440
+ });
441
+ case "publish": {
442
+ if (!id || id.startsWith("--")) {
443
+ console.error("nixamp station lyrics publish needs a station id");
444
+ return 2;
445
+ }
446
+ if (!json)
447
+ console.log("Publishing; a second between songs…");
448
+ return show(await call("POST", `/api/v1/stations/lyrics/${id}/publish`, {}), (body) => {
449
+ const report = body["report"];
450
+ 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"])}` : ""}`;
451
+ });
452
+ }
453
+ case "key": {
454
+ const value = argv.includes("--clear") ? "" : await readStdin();
455
+ return show(await call("POST", "/api/v1/stations/ai/keys", { name: "NIXAMP_LYRICS_KEY", value }), (answer) => `nichedb lyrics key ${(answer["keys"] ?? {})["NIXAMP_LYRICS_KEY"] ? "set" : "cleared"}.`);
456
+ }
457
+ default:
458
+ console.error(`nixamp station lyrics: no command "${sub}".\n\n${USAGE}`);
459
+ return 2;
460
+ }
461
+ }
420
462
  /** `nixamp station shares ...` */
421
463
  async function sharesCommand(argv, call, show) {
422
464
  const [sub, id] = argv;