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/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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
|
|
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 } : {}),
|
package/src/station-cli.ts
CHANGED
|
@@ -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;
|
package/src/station-page.ts
CHANGED
|
@@ -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>
|
package/src/station-routes.ts
CHANGED
|
@@ -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" });
|
package/src/station-usage.ts
CHANGED
|
@@ -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[] {
|