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.
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +2 -0
- package/dist/lyrics-publish.d.ts +56 -0
- package/dist/lyrics-publish.js +194 -0
- package/dist/lyrics.d.ts +67 -0
- package/dist/lyrics.js +145 -0
- package/dist/server.d.ts +7 -1
- package/dist/server.js +64 -3
- package/dist/station-cli.js +42 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +39 -3
- package/dist/station-routes.d.ts +9 -0
- package/dist/station-routes.js +39 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +5 -0
- package/dist/stations.d.ts +2 -0
- package/dist/stations.js +6 -0
- package/dist/support.d.ts +65 -0
- package/dist/support.js +187 -5
- package/dist/track-facts.d.ts +4 -0
- package/dist/track-facts.js +8 -4
- package/package.json +1 -1
- package/src/ai-keys.ts +2 -0
- package/src/lyrics-publish.ts +203 -0
- package/src/lyrics.ts +161 -0
- package/src/server.ts +68 -3
- package/src/station-cli.ts +40 -0
- package/src/station-page.ts +39 -3
- package/src/station-routes.ts +46 -1
- package/src/station-usage.ts +5 -0
- package/src/stations.ts +4 -0
- package/src/support.ts +201 -6
- package/src/track-facts.ts +11 -2
- package/web/dist/assets/{ar-DJ2gQPOo.js → ar-DJAQCP6v.js} +1 -1
- package/web/dist/assets/{cs-HEbqVNx5.js → cs-BYeHBx_F.js} +1 -1
- package/web/dist/assets/{da-C87QYmEx.js → da-CR89gWR7.js} +1 -1
- package/web/dist/assets/{de-BqR-HScZ.js → de-DPFsA0HC.js} +1 -1
- package/web/dist/assets/{es-DjzlzxAQ.js → es-Bg5Vx2bB.js} +1 -1
- package/web/dist/assets/{fi-2Is-qqeQ.js → fi-BVpY7Uww.js} +1 -1
- package/web/dist/assets/{fr-CTEDRgPI.js → fr-DsSofhGt.js} +1 -1
- package/web/dist/assets/{hi-CU5X7c5G.js → hi-DkSvHtix.js} +1 -1
- package/web/dist/assets/{hu-CQAs9Ye7.js → hu-D4Z2GpU0.js} +1 -1
- package/web/dist/assets/{id-5dd2TXs7.js → id-BzOPbgSu.js} +1 -1
- package/web/dist/assets/{it-B69_ME4E.js → it-BAT4YCiY.js} +1 -1
- package/web/dist/assets/{main-DRvUTBup.js → main-Dpj3oiHi.js} +2 -2
- package/web/dist/assets/{nl-Drb6oxnO.js → nl-CiTdg-si.js} +1 -1
- package/web/dist/assets/{ru-C4u8G5A0.js → ru-CJBuL5JA.js} +1 -1
- package/web/dist/assets/{station--cxhU1wr.css → station-BwDP2cuf.css} +1 -1
- package/web/dist/assets/station-DXRmjrqv.js +3 -0
- package/web/dist/assets/{sv-Bi-av_7u.js → sv-Dptz7Qud.js} +1 -1
- package/web/dist/assets/{uk-DZVF8mrT.js → uk-yL3tD6Kt.js} +1 -1
- package/web/dist/assets/{vi-B2UJxM5O.js → vi-CuVvOIut.js} +1 -1
- package/web/dist/assets/{zh-XRXZxCqU.js → zh-kHhOVHwJ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +9 -2
- package/web/dist/sw.js +21 -21
- 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
|
+
}
|
package/dist/lyrics.d.ts
ADDED
|
@@ -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
|
@@ -26,7 +26,7 @@ import { ListenerStore } from "./library-user.ts";
|
|
|
26
26
|
import { Importer } from "./handoff.ts";
|
|
27
27
|
import { AiRadio } from "./ai-radio.ts";
|
|
28
28
|
import { ListenerShares } from "./listener-shares.ts";
|
|
29
|
-
import { Support } from "./support.ts";
|
|
29
|
+
import { Support, SupportSubscriptions } from "./support.ts";
|
|
30
30
|
import { PeerGuides } from "./station-guide.ts";
|
|
31
31
|
import { StationRunner } from "./station-runner.ts";
|
|
32
32
|
import { StationStore, type Station } from "./stations.ts";
|
|
@@ -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. */
|
|
@@ -807,6 +811,8 @@ export interface HandlerOptions {
|
|
|
807
811
|
translationPasses?: TranslationPasses;
|
|
808
812
|
/** Payments to copyright holders nixamp.com collects for (support.ts). */
|
|
809
813
|
support?: Support;
|
|
814
|
+
/** Monthly support, run by nixamp.com (support.ts). */
|
|
815
|
+
supportSubscriptions?: SupportSubscriptions;
|
|
810
816
|
/** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
|
|
811
817
|
coinpayWebhookSecret?: string;
|
|
812
818
|
sharedTranslations?: SharedTranslations;
|
package/dist/server.js
CHANGED
|
@@ -64,7 +64,10 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
|
|
|
64
64
|
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
|
-
import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.js";
|
|
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";
|
|
@@ -2164,7 +2167,7 @@ export function createHandler(engine, options) {
|
|
|
2164
2167
|
// What is on, as MCP, for anybody.
|
|
2165
2168
|
path !== "/mcp/public" &&
|
|
2166
2169
|
// Supporting a copyright holder is for anybody listening.
|
|
2167
|
-
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2170
|
+
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2168
2171
|
// A station's short link, which sends the browser to wherever it plays.
|
|
2169
2172
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2170
2173
|
!STATION_PAGE.test(path) &&
|
|
@@ -2231,6 +2234,22 @@ export function createHandler(engine, options) {
|
|
|
2231
2234
|
* in the directory). It sends the browser to the station's own page, on
|
|
2232
2235
|
* the server that plays it. Nothing private or unlisted is ever found.
|
|
2233
2236
|
*/
|
|
2237
|
+
/* A monthly support's cancel link, from any of its emails. */
|
|
2238
|
+
if (path === "/support/cancel" && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
|
|
2239
|
+
let words;
|
|
2240
|
+
try {
|
|
2241
|
+
const done = await options.supportSubscriptions.cancel(url.searchParams.get("s") ?? "", url.searchParams.get("t") ?? "");
|
|
2242
|
+
const name = options.support?.holder(done.holder)?.name ?? done.holder;
|
|
2243
|
+
words = `Your monthly support for ${name} is cancelled. No more payment links will be sent.`;
|
|
2244
|
+
}
|
|
2245
|
+
catch (error) {
|
|
2246
|
+
words = error instanceof SupportError ? error.message : "That did not work; try the link again.";
|
|
2247
|
+
}
|
|
2248
|
+
const safe = words.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
2249
|
+
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" });
|
|
2250
|
+
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>`);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2234
2253
|
/* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
|
|
2235
2254
|
const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
|
|
2236
2255
|
if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
|
|
@@ -2269,7 +2288,14 @@ export function createHandler(engine, options) {
|
|
|
2269
2288
|
catch {
|
|
2270
2289
|
body = {};
|
|
2271
2290
|
}
|
|
2272
|
-
|
|
2291
|
+
if (body["monthly"] === true) {
|
|
2292
|
+
if (!options.supportSubscriptions)
|
|
2293
|
+
throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
|
|
2294
|
+
json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
json(response, 200, await support.checkout({ ...body, holder: holder.id }));
|
|
2298
|
+
}
|
|
2273
2299
|
return;
|
|
2274
2300
|
}
|
|
2275
2301
|
json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
@@ -2445,6 +2471,8 @@ export function createHandler(engine, options) {
|
|
|
2445
2471
|
...(options.stations.auto ? { auto: options.stations.auto } : {}),
|
|
2446
2472
|
...(options.stations.facts ? { facts: options.stations.facts } : {}),
|
|
2447
2473
|
...(options.stations.cover ? { cover: options.stations.cover } : {}),
|
|
2474
|
+
...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
|
|
2475
|
+
...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
|
|
2448
2476
|
...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
|
|
2449
2477
|
throttle: () => {
|
|
2450
2478
|
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
@@ -6601,6 +6629,27 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6601
6629
|
covers.set(file, { key, picture });
|
|
6602
6630
|
return picture;
|
|
6603
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();
|
|
6604
6653
|
stations.facts = async (station, file) => {
|
|
6605
6654
|
const share = listenerShares.atFile(file);
|
|
6606
6655
|
const composed = aiRadio.trackAt(file);
|
|
@@ -6613,8 +6662,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6613
6662
|
stamp = "";
|
|
6614
6663
|
}
|
|
6615
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);
|
|
6616
6666
|
return factsFor({
|
|
6617
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)}` } : {}),
|
|
6618
6669
|
station, file,
|
|
6619
6670
|
tags: await tagReader.read(file),
|
|
6620
6671
|
known: trackFor(engine, file),
|
|
@@ -6752,6 +6803,15 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6752
6803
|
? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
|
|
6753
6804
|
// Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
|
|
6754
6805
|
const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
|
|
6806
|
+
// Monthly support: each month a new CoinPay payment and its link by email.
|
|
6807
|
+
const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
|
|
6808
|
+
db: pool,
|
|
6809
|
+
support: supportPayments,
|
|
6810
|
+
mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
|
|
6811
|
+
site: nixampSite,
|
|
6812
|
+
log: (line) => console.log(line),
|
|
6813
|
+
}) : undefined;
|
|
6814
|
+
supportMonthly?.startSchedule();
|
|
6755
6815
|
if (translationPasses)
|
|
6756
6816
|
void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
|
|
6757
6817
|
const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
|
|
@@ -7301,6 +7361,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7301
7361
|
...(liveVoice ? { liveVoice } : {}),
|
|
7302
7362
|
...(translationPasses ? { translationPasses } : {}),
|
|
7303
7363
|
...(supportPayments ? { support: supportPayments } : {}),
|
|
7364
|
+
...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
|
|
7304
7365
|
...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
|
|
7305
7366
|
...(sharedTranslations ? { sharedTranslations } : {}),
|
|
7306
7367
|
...(translator ? { translator } : {}),
|