nixamp 0.53.5 → 0.55.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-label.d.ts +26 -0
- package/dist/ai-label.js +90 -1
- package/dist/follow-page.d.ts +23 -0
- package/dist/follow-page.js +111 -0
- package/dist/lyrics.d.ts +25 -15
- package/dist/lyrics.js +72 -1
- package/dist/radio-pages.d.ts +211 -0
- package/dist/radio-pages.js +415 -0
- package/dist/radio-slug.d.ts +18 -0
- package/dist/radio-slug.js +43 -0
- package/dist/server.js +242 -3
- package/dist/station-guide.d.ts +2 -0
- package/dist/station-guide.js +5 -3
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +4 -2
- package/dist/station-routes.d.ts +1 -0
- package/dist/station-routes.js +29 -4
- package/package.json +1 -1
- package/src/ai-label.ts +92 -1
- package/src/follow-page.ts +127 -0
- package/src/lyrics.ts +76 -1
- package/src/radio-pages.ts +513 -0
- package/src/radio-slug.ts +45 -0
- package/src/server.ts +232 -3
- package/src/station-guide.ts +7 -3
- package/src/station-page.ts +4 -2
- package/src/station-routes.ts +29 -5
- package/web/dist/assets/{ar-DqeYPqVe.js → ar-CCVdTxXd.js} +1 -1
- package/web/dist/assets/{cs-D4zQN1oV.js → cs-DazinpO5.js} +1 -1
- package/web/dist/assets/{da-DKIaXNAT.js → da-dANXD44J.js} +1 -1
- package/web/dist/assets/{de-D1lMnV7x.js → de-BH5N_2Zt.js} +1 -1
- package/web/dist/assets/{es-CjjnXT7v.js → es-lGWlpKlv.js} +1 -1
- package/web/dist/assets/{fi-Dp56MD45.js → fi-CIIF4ItN.js} +1 -1
- package/web/dist/assets/{fr-CeifYZP1.js → fr-s0WjlDkZ.js} +1 -1
- package/web/dist/assets/{hi-DsBa_glA.js → hi-Dwrx_fdX.js} +1 -1
- package/web/dist/assets/{hu-SYFZGXA2.js → hu-B7yw75er.js} +1 -1
- package/web/dist/assets/{id-91576EGD.js → id-DNdkH2kw.js} +1 -1
- package/web/dist/assets/{it-Cjmfw8XJ.js → it-C9858eN5.js} +1 -1
- package/web/dist/assets/{main-CBsLIRa4.js → main-Coq6MRdf.js} +2 -2
- package/web/dist/assets/{nl-XqBXZghJ.js → nl-BeH2Fgue.js} +1 -1
- package/web/dist/assets/{ru-CYT8xDxB.js → ru-DZ2nMkFG.js} +1 -1
- package/web/dist/assets/{station-Bqk5iV8L.css → station-CSnjPeod.css} +1 -1
- package/web/dist/assets/station-DGgba3hs.js +3 -0
- package/web/dist/assets/{sv-BlpGbShs.js → sv-CuTZodhi.js} +1 -1
- package/web/dist/assets/{uk-g-QbP-e_.js → uk-Bt534Jo1.js} +1 -1
- package/web/dist/assets/{vi-CF206cCy.js → vi-Ci0kYdXP.js} +1 -1
- package/web/dist/assets/{zh-D4QH6alE.js → zh-C8tiLpKW.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +3 -2
- package/web/dist/sw.js +24 -24
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- package/web/dist/assets/station-DUt85zQk.js +0 -3
- /package/web/dist/assets/{vod-BSo-pl8M.js → vod-Br9Ooz6-.js} +0 -0
- /package/web/dist/assets/{watch-DBSszFQm.js → watch-Dxsz5gHd.js} +0 -0
package/dist/ai-label.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Composer } from "./ai-radio.ts";
|
|
2
2
|
import { askJson } from "./ai-text.ts";
|
|
3
|
+
import { type SyncedLine } from "./lyrics.ts";
|
|
3
4
|
import type { LibraryIndex, Probed } from "./library-index.ts";
|
|
4
5
|
import type { StationRunner } from "./station-runner.ts";
|
|
5
6
|
import { type StationStore } from "./stations.ts";
|
|
@@ -15,6 +16,8 @@ export interface LabelSong {
|
|
|
15
16
|
/** The MP3, "" until composed. */
|
|
16
17
|
file: string;
|
|
17
18
|
lyricsFile: string;
|
|
19
|
+
/** Its words timed to the recording (001. <title>.lrc, enhanced LRC), for karaoke on the station page; absent until aligned. */
|
|
20
|
+
lrc?: string;
|
|
18
21
|
/** Its music video (001. <title>.mp4), "" until made; absent on songs from before videos. */
|
|
19
22
|
video?: string;
|
|
20
23
|
/** Whether the video carries its song's tags (videos from 0.52.1 and before do not, until retagged). */
|
|
@@ -127,6 +130,21 @@ export declare function writeLyrics(artist: Pick<LabelArtist, "name" | "genre" |
|
|
|
127
130
|
* OPENAI_API_KEY its token); null when there is no image model to ask.
|
|
128
131
|
*/
|
|
129
132
|
export declare function workersAiCover(prompt: string, env?: Record<string, string | undefined>, fetcher?: typeof fetch): Promise<Buffer | null>;
|
|
133
|
+
/** A timed word from an aligner: seconds from the start of the song. */
|
|
134
|
+
export interface TimedWord {
|
|
135
|
+
text: string;
|
|
136
|
+
start: number;
|
|
137
|
+
end: number;
|
|
138
|
+
}
|
|
139
|
+
/** ElevenLabs forced alignment: the song and its words in, each word's time out. */
|
|
140
|
+
export declare function elevenLabsAligner(key: string, fetcher?: typeof fetch): (audio: string, text: string) => Promise<TimedWord[]>;
|
|
141
|
+
/** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
|
|
142
|
+
export declare function sungLines(lyrics: string): string[];
|
|
143
|
+
/**
|
|
144
|
+
* Timed words laid back onto the lines they were written in, in order, so
|
|
145
|
+
* each line and each word knows when it is sung.
|
|
146
|
+
*/
|
|
147
|
+
export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLine[];
|
|
130
148
|
/** Image prompts for a song's video, one a scene, from its words and mood. */
|
|
131
149
|
export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound">, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
|
|
132
150
|
/**
|
|
@@ -169,6 +187,8 @@ export interface AiLabelDeps {
|
|
|
169
187
|
tag?: (from: string, to: string, tags: Record<string, string>, cover: string) => Promise<void>;
|
|
170
188
|
/** Renders a song's music video to `out`; ffmpeg by default (renderVideo). */
|
|
171
189
|
video?: (audio: string, cover: string, out: string) => Promise<void>;
|
|
190
|
+
/** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
|
|
191
|
+
align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
|
|
172
192
|
/** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
|
|
173
193
|
storyVideo?: (input: {
|
|
174
194
|
audio: string;
|
|
@@ -200,6 +220,8 @@ export declare class AiLabel {
|
|
|
200
220
|
private filming;
|
|
201
221
|
/** Songs whose video failed since this server started. */
|
|
202
222
|
private videoFailed;
|
|
223
|
+
/** Songs whose words were already sent to the aligner since this server started. */
|
|
224
|
+
private alignTried;
|
|
203
225
|
/** Old videos already tried as storyboards since this server started. */
|
|
204
226
|
private upgradeTried;
|
|
205
227
|
/** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
|
|
@@ -246,6 +268,10 @@ export declare class AiLabel {
|
|
|
246
268
|
* song's tags (seconds each), then old cover-and-waveform videos made again
|
|
247
269
|
* as storyboards.
|
|
248
270
|
*/
|
|
271
|
+
/** The aligner this server can use: the dependency, else ElevenLabs with its key, else none. */
|
|
272
|
+
private aligner;
|
|
273
|
+
/** A song's words timed to its recording, written as 001. <title>.lrc beside it. Once per song (a failure is not retried this run). */
|
|
274
|
+
private sync;
|
|
249
275
|
private filmPass;
|
|
250
276
|
/**
|
|
251
277
|
* One song's video: a storyboard of scenes when a model can write it and an
|
package/dist/ai-label.js
CHANGED
|
@@ -33,6 +33,7 @@ import { dirname, join, resolve, sep } from "node:path";
|
|
|
33
33
|
import { aiKey } from "./ai-keys.js";
|
|
34
34
|
import { composerFromEnv, OutOfCredit } from "./ai-radio.js";
|
|
35
35
|
import { askJson, hasModel } from "./ai-text.js";
|
|
36
|
+
import { formatLrc } from "./lyrics.js";
|
|
36
37
|
import { StationError } from "./stations.js";
|
|
37
38
|
/**
|
|
38
39
|
* The label's first roster: invented names, one genre each, sung in English.
|
|
@@ -408,6 +409,53 @@ export async function workersAiCover(prompt, env = process.env, fetcher = fetch)
|
|
|
408
409
|
throw new Error("the cover model answered with no picture");
|
|
409
410
|
return image;
|
|
410
411
|
}
|
|
412
|
+
/** ElevenLabs forced alignment: the song and its words in, each word's time out. */
|
|
413
|
+
export function elevenLabsAligner(key, fetcher = fetch) {
|
|
414
|
+
return async (audio, text) => {
|
|
415
|
+
const form = new FormData();
|
|
416
|
+
form.append("file", new Blob([readFileSync(audio)], { type: "audio/mpeg" }), "song.mp3");
|
|
417
|
+
form.append("text", text);
|
|
418
|
+
const response = await fetcher("https://api.elevenlabs.io/v1/forced-alignment", {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: { "xi-api-key": key },
|
|
421
|
+
body: form,
|
|
422
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
423
|
+
});
|
|
424
|
+
if (response.status === 401 || response.status === 402)
|
|
425
|
+
throw new OutOfCredit(`elevenlabs answered HTTP ${response.status} to the alignment`);
|
|
426
|
+
if (!response.ok)
|
|
427
|
+
throw new Error(`the alignment answered HTTP ${response.status}`);
|
|
428
|
+
const body = await response.json();
|
|
429
|
+
return (body.words ?? [])
|
|
430
|
+
.map((one) => ({ text: String(one.text ?? "").trim(), start: Number(one.start), end: Number(one.end) }))
|
|
431
|
+
.filter((one) => one.text && Number.isFinite(one.start) && Number.isFinite(one.end));
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
|
|
435
|
+
export function sungLines(lyrics) {
|
|
436
|
+
return lyrics.split("\n").map((line) => line.trim()).filter((line) => line && !/^\[[^\]]*\]$/.test(line));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Timed words laid back onto the lines they were written in, in order, so
|
|
440
|
+
* each line and each word knows when it is sung.
|
|
441
|
+
*/
|
|
442
|
+
export function syncLines(lyrics, timed) {
|
|
443
|
+
const out = [];
|
|
444
|
+
let at = 0;
|
|
445
|
+
for (const line of sungLines(lyrics)) {
|
|
446
|
+
const words = line.split(/\s+/).filter(Boolean);
|
|
447
|
+
const placed = words.map((word) => {
|
|
448
|
+
const hit = timed[at];
|
|
449
|
+
if (hit)
|
|
450
|
+
at += 1;
|
|
451
|
+
return hit ? { text: word, start: hit.start, end: hit.end } : null;
|
|
452
|
+
}).filter((one) => one !== null);
|
|
453
|
+
if (placed.length === 0)
|
|
454
|
+
break;
|
|
455
|
+
out.push({ start: placed[0].start, end: placed[placed.length - 1].end, text: line, words: placed });
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
411
459
|
const STORYBOARD_SYSTEM = [
|
|
412
460
|
"You storyboard music videos for original songs. Write one image prompt per scene, in order, following the song's story and mood.",
|
|
413
461
|
"Keep the same main character(s), setting and color palette across scenes so it reads as one video; vary the shots (wide, close-up, action, detail).",
|
|
@@ -555,6 +603,8 @@ export class AiLabel {
|
|
|
555
603
|
filming = null;
|
|
556
604
|
/** Songs whose video failed since this server started. */
|
|
557
605
|
videoFailed = new Set();
|
|
606
|
+
/** Songs whose words were already sent to the aligner since this server started. */
|
|
607
|
+
alignTried = new Set();
|
|
558
608
|
/** Old videos already tried as storyboards since this server started. */
|
|
559
609
|
upgradeTried = new Set();
|
|
560
610
|
/** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
|
|
@@ -844,7 +894,43 @@ export class AiLabel {
|
|
|
844
894
|
* song's tags (seconds each), then old cover-and-waveform videos made again
|
|
845
895
|
* as storyboards.
|
|
846
896
|
*/
|
|
897
|
+
/** The aligner this server can use: the dependency, else ElevenLabs with its key, else none. */
|
|
898
|
+
aligner() {
|
|
899
|
+
if (this.deps.align !== undefined)
|
|
900
|
+
return this.deps.align;
|
|
901
|
+
const key = aiKey("ELEVENLABS_API_KEY");
|
|
902
|
+
return key ? elevenLabsAligner(key) : null;
|
|
903
|
+
}
|
|
904
|
+
/** A song's words timed to its recording, written as 001. <title>.lrc beside it. Once per song (a failure is not retried this run). */
|
|
905
|
+
async sync(artist, album, song) {
|
|
906
|
+
const align = this.aligner();
|
|
907
|
+
if (!align || !song.file || !song.lyrics || song.lrc || this.alignTried.has(song.file))
|
|
908
|
+
return false;
|
|
909
|
+
this.alignTried.add(song.file);
|
|
910
|
+
try {
|
|
911
|
+
const timed = await align(song.file, sungLines(song.lyrics).join("\n"));
|
|
912
|
+
const lines = syncLines(song.lyrics, timed);
|
|
913
|
+
if (lines.length === 0)
|
|
914
|
+
throw new Error("the aligner placed no words");
|
|
915
|
+
const file = join(album.folder, `${songBase(song)}.lrc`);
|
|
916
|
+
writeFileSync(file, formatLrc(lines, { title: song.title, artist: artist.name, album: album.title }));
|
|
917
|
+
song.lrc = file;
|
|
918
|
+
this.save();
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
this.log(`could not time the words of "${song.title}": ${error.message}`);
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
847
926
|
async filmPass() {
|
|
927
|
+
// Words timed first: quick, and what the station page's karaoke needs.
|
|
928
|
+
for (const artist of this.list()) {
|
|
929
|
+
for (const album of artist.albums) {
|
|
930
|
+
for (const song of album.songs)
|
|
931
|
+
await this.sync(artist, album, song);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
848
934
|
const songs = this.list().flatMap((artist) => artist.albums
|
|
849
935
|
.filter((album) => album.cover || album.coverTried)
|
|
850
936
|
.flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
|
|
@@ -1184,7 +1270,8 @@ export class AiLabel {
|
|
|
1184
1270
|
this.save();
|
|
1185
1271
|
this.writeAlbumJson(artist, album);
|
|
1186
1272
|
await this.place(artist.genre);
|
|
1187
|
-
// Its
|
|
1273
|
+
// Its words timed to the recording, then its video, without holding up the next song.
|
|
1274
|
+
await this.sync(artist, album, song).catch(() => undefined);
|
|
1188
1275
|
void this.videos();
|
|
1189
1276
|
}
|
|
1190
1277
|
catch (error) {
|
|
@@ -1364,6 +1451,8 @@ export class AiLabel {
|
|
|
1364
1451
|
for (const song of songs) {
|
|
1365
1452
|
entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
|
|
1366
1453
|
entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
|
|
1454
|
+
if (song.lrc)
|
|
1455
|
+
entries.push({ name: `${folder}/${songBase(song)}.lrc`, path: song.lrc });
|
|
1367
1456
|
if (song.video)
|
|
1368
1457
|
entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
|
|
1369
1458
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export interface FollowPageContext {
|
|
3
|
+
/** The signed-in account, or null. */
|
|
4
|
+
account: () => Promise<{
|
|
5
|
+
id: string;
|
|
6
|
+
} | null>;
|
|
7
|
+
follows: {
|
|
8
|
+
follow: (follower: string, streamer: string) => Promise<boolean>;
|
|
9
|
+
unfollow: (follower: string, streamer: string) => Promise<void>;
|
|
10
|
+
isFollowing: (follower: string, streamer: string) => Promise<boolean>;
|
|
11
|
+
followerCount: (streamer: string) => Promise<number>;
|
|
12
|
+
};
|
|
13
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare const FOLLOW_CSP: string;
|
|
16
|
+
/** What the link says, cleaned: an artist id, a name to show, and an https page to go back to. */
|
|
17
|
+
export declare function followTarget(input: URLSearchParams): {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
back: string;
|
|
21
|
+
} | null;
|
|
22
|
+
/** Answers /follow; false for any other path. */
|
|
23
|
+
export declare function handleFollowPage(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: FollowPageContext): Promise<boolean>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /follow on nixamp.com: follow an artist from a /radio page (radio-pages.ts).
|
|
3
|
+
*
|
|
4
|
+
* The /radio pages are on another site (live.streaming.pit.world), where a
|
|
5
|
+
* nixamp.com session cookie is a third-party cookie and no browser sends it.
|
|
6
|
+
* So Follow there is a link here, where the session is first-party: this page
|
|
7
|
+
* says who you are following, and a same-site form (SameSite=Lax keeps a
|
|
8
|
+
* cross-site POST from carrying the session) follows or unfollows. The follow
|
|
9
|
+
* is the same follows row as following a streamer, keyed "artist:<name>".
|
|
10
|
+
*
|
|
11
|
+
* GET /follow?id=artist:<name>&name=<shown>&back=<https page>
|
|
12
|
+
* POST /follow id, name, back, act=follow|unfollow -> 303 back to the GET
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
const ID = /^artist:[a-z0-9-]{1,90}$/;
|
|
16
|
+
function escape(text) {
|
|
17
|
+
return String(text).replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
18
|
+
}
|
|
19
|
+
const STYLE = `
|
|
20
|
+
:root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
|
|
21
|
+
*{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}
|
|
22
|
+
main{max-width:520px;margin:0 auto;padding:40px 16px}
|
|
23
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
|
24
|
+
h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}.by{color:var(--muted);margin:0 0 16px}
|
|
25
|
+
button,a.go{display:block;text-align:center;text-decoration:none;margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
|
|
26
|
+
button.quiet{background:transparent;color:var(--fg);border:1px solid var(--line)}
|
|
27
|
+
a{color:var(--accent)}a:focus-visible,button:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
|
28
|
+
small{display:block;margin-top:18px;color:var(--muted)}
|
|
29
|
+
`;
|
|
30
|
+
const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
31
|
+
export const FOLLOW_CSP = ["default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'", "form-action 'self'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
|
|
32
|
+
function page(title, body) {
|
|
33
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
34
|
+
<title>${escape(title)} · nixamp</title><meta name="robots" content="noindex"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
|
|
35
|
+
<body><main><div class="card">${body}</div></main></body></html>`;
|
|
36
|
+
}
|
|
37
|
+
function send(response, status, html, method) {
|
|
38
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": FOLLOW_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
|
|
39
|
+
response.end(method === "HEAD" ? undefined : html);
|
|
40
|
+
}
|
|
41
|
+
/** What the link says, cleaned: an artist id, a name to show, and an https page to go back to. */
|
|
42
|
+
export function followTarget(input) {
|
|
43
|
+
const id = (input.get("id") ?? "").trim();
|
|
44
|
+
if (!ID.test(id))
|
|
45
|
+
return null;
|
|
46
|
+
const name = (input.get("name") ?? "").replace(/[\u0000-\u001f]/g, " ").trim().slice(0, 120) || id.slice("artist:".length);
|
|
47
|
+
let back = "";
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(input.get("back") ?? "");
|
|
50
|
+
if (url.protocol === "https:" && !url.username && !url.password)
|
|
51
|
+
back = url.href;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
back = "";
|
|
55
|
+
}
|
|
56
|
+
return { id, name, back };
|
|
57
|
+
}
|
|
58
|
+
/** Answers /follow; false for any other path. */
|
|
59
|
+
export async function handleFollowPage(request, response, path, url, ctx) {
|
|
60
|
+
if (path !== "/follow")
|
|
61
|
+
return false;
|
|
62
|
+
const method = request.method ?? "GET";
|
|
63
|
+
if (method === "POST") {
|
|
64
|
+
// Same site only: a form on another site gets no session (SameSite=Lax), and is told so here too.
|
|
65
|
+
const origin = String(request.headers.origin ?? "");
|
|
66
|
+
const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0].trim();
|
|
67
|
+
if (origin && origin !== "null" && new URL(origin).host !== host) {
|
|
68
|
+
send(response, 403, page("Not from here", `<h1>Not from here</h1><p class="by">Follow from nixamp.com's own page.</p>`), method);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
const form = new URLSearchParams(await ctx.readBody(request));
|
|
72
|
+
const target = followTarget(form);
|
|
73
|
+
const me = await ctx.account().catch(() => null);
|
|
74
|
+
if (target && me) {
|
|
75
|
+
if (form.get("act") === "unfollow")
|
|
76
|
+
await ctx.follows.unfollow(me.id, target.id);
|
|
77
|
+
else
|
|
78
|
+
await ctx.follows.follow(me.id, target.id);
|
|
79
|
+
}
|
|
80
|
+
const again = target ? `/follow?${new URLSearchParams({ id: target.id, name: target.name, ...(target.back ? { back: target.back } : {}) }).toString()}` : "/follow";
|
|
81
|
+
response.writeHead(303, { location: again, "cache-control": "no-store" });
|
|
82
|
+
response.end();
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
86
|
+
response.writeHead(405, { allow: "GET, POST" }).end();
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const target = followTarget(url.searchParams);
|
|
90
|
+
if (!target) {
|
|
91
|
+
send(response, 404, page("Nobody to follow", `<h1>Nobody to follow</h1><p class="by">That link does not name an artist.</p>`), method);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const back = target.back ? `<small><a href="${escape(target.back)}">← Back to ${escape(target.name)}</a></small>` : "";
|
|
95
|
+
const me = await ctx.account().catch(() => null);
|
|
96
|
+
if (!me) {
|
|
97
|
+
send(response, 200, page(`Follow ${target.name}`, `<h1>Follow ${escape(target.name)}</h1>
|
|
98
|
+
<p class="by">Sign in to nixamp.com to follow the artists you hear on the radio.</p>
|
|
99
|
+
<a class="go" href="/radio#music">Sign in to nixamp.com</a>
|
|
100
|
+
<small>Signed in, open this page again (or press Follow on the artist's page) to follow.</small>${back}`), method);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const [following, count] = await Promise.all([ctx.follows.isFollowing(me.id, target.id), ctx.follows.followerCount(target.id).catch(() => 0)]);
|
|
104
|
+
const hidden = `<input type="hidden" name="id" value="${escape(target.id)}"><input type="hidden" name="name" value="${escape(target.name)}">${target.back ? `<input type="hidden" name="back" value="${escape(target.back)}">` : ""}`;
|
|
105
|
+
send(response, 200, page(`Follow ${target.name}`, `<h1>${escape(target.name)}</h1>
|
|
106
|
+
<p class="by">${following ? "You follow them." : "Follow them to keep them with your follows on nixamp.com."} ${count} follower${count === 1 ? "" : "s"}.</p>
|
|
107
|
+
<form method="post" action="/follow">${hidden}<input type="hidden" name="act" value="${following ? "unfollow" : "follow"}">
|
|
108
|
+
<button type="submit"${following ? ' class="quiet"' : ""}>${following ? "Unfollow" : `Follow ${escape(target.name)}`}</button></form>
|
|
109
|
+
${back}`), method);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
package/dist/lyrics.d.ts
CHANGED
|
@@ -1,18 +1,3 @@
|
|
|
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
1
|
export interface Lyrics {
|
|
17
2
|
title: string;
|
|
18
3
|
artist: string;
|
|
@@ -21,7 +6,32 @@ export interface Lyrics {
|
|
|
21
6
|
source: string;
|
|
22
7
|
/** ISO 639-1 where we know the mapping, else what the tag said; "" when unsaid. */
|
|
23
8
|
language: string;
|
|
9
|
+
/** When a .lrc beside the file says when each line (and word) is sung: seconds from the file's start. */
|
|
10
|
+
synced?: SyncedLine[];
|
|
11
|
+
}
|
|
12
|
+
export interface SyncedLine {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
text: string;
|
|
16
|
+
words: Array<{
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
text: string;
|
|
20
|
+
}>;
|
|
24
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Enhanced LRC: `[mm:ss.xx]` before each sung line, and `<mm:ss.xx>` before
|
|
24
|
+
* each word when the words are timed. Tags like `[ar:...]` and lines without a
|
|
25
|
+
* time are skipped. Ends are the next start (the last line runs 4 s).
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseLrc(text: string): SyncedLine[];
|
|
28
|
+
export declare function formatLrc(lines: SyncedLine[], head?: {
|
|
29
|
+
title?: string;
|
|
30
|
+
artist?: string;
|
|
31
|
+
album?: string;
|
|
32
|
+
}): string;
|
|
33
|
+
/** The .lrc that sits beside a file (same name, .lrc), or "". */
|
|
34
|
+
export declare function lrcBeside(file: string): string;
|
|
25
35
|
export declare const NICHEDB_LYRICS = "https://nichedb.dev";
|
|
26
36
|
/** Text as it may be shown: newlines kept, control characters gone, trimmed, capped. */
|
|
27
37
|
export declare function cleanLyrics(text: string): string;
|
package/dist/lyrics.js
CHANGED
|
@@ -13,6 +13,67 @@
|
|
|
13
13
|
* nichedb's for ten minutes, misses included, so a station of tracks nobody
|
|
14
14
|
* has words for asks once per track per ten minutes and never waits long.
|
|
15
15
|
*/
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
const STAMP = /^(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?$/;
|
|
18
|
+
function seconds(stamp) {
|
|
19
|
+
const match = STAMP.exec(stamp);
|
|
20
|
+
if (!match)
|
|
21
|
+
return Number.NaN;
|
|
22
|
+
const fraction = match[3] ? Number(`0.${match[3]}`) : 0;
|
|
23
|
+
return Number(match[1]) * 60 + Number(match[2]) + fraction;
|
|
24
|
+
}
|
|
25
|
+
function stamp(at) {
|
|
26
|
+
const whole = Math.max(0, at);
|
|
27
|
+
const minutes = Math.floor(whole / 60);
|
|
28
|
+
const rest = whole - minutes * 60;
|
|
29
|
+
return `${String(minutes).padStart(2, "0")}:${rest.toFixed(2).padStart(5, "0")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Enhanced LRC: `[mm:ss.xx]` before each sung line, and `<mm:ss.xx>` before
|
|
33
|
+
* each word when the words are timed. Tags like `[ar:...]` and lines without a
|
|
34
|
+
* time are skipped. Ends are the next start (the last line runs 4 s).
|
|
35
|
+
*/
|
|
36
|
+
export function parseLrc(text) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
39
|
+
const head = /^\[([^\]]+)\](.*)$/.exec(raw.trim());
|
|
40
|
+
if (!head)
|
|
41
|
+
continue;
|
|
42
|
+
const start = seconds(head[1]);
|
|
43
|
+
if (!Number.isFinite(start))
|
|
44
|
+
continue;
|
|
45
|
+
const body = head[2];
|
|
46
|
+
const words = [];
|
|
47
|
+
const parts = body.split(/<([^>]+)>/);
|
|
48
|
+
if (parts.length > 1) {
|
|
49
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
50
|
+
const at = seconds(parts[i]);
|
|
51
|
+
const word = (parts[i + 1] ?? "").trim();
|
|
52
|
+
if (Number.isFinite(at) && word)
|
|
53
|
+
words.push({ start: at, end: at, text: word });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const plain = (parts.length > 1 ? words.map((one) => one.text).join(" ") : body).trim();
|
|
57
|
+
if (!plain)
|
|
58
|
+
continue;
|
|
59
|
+
lines.push({ start, end: start, text: plain, words });
|
|
60
|
+
}
|
|
61
|
+
lines.sort((a, b) => a.start - b.start);
|
|
62
|
+
lines.forEach((line, i) => {
|
|
63
|
+
line.end = lines[i + 1]?.start ?? line.start + 4;
|
|
64
|
+
line.words.forEach((word, j) => { word.end = line.words[j + 1]?.start ?? line.end; });
|
|
65
|
+
});
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
export function formatLrc(lines, head = {}) {
|
|
69
|
+
const tags = [head.artist ? `[ar:${head.artist}]` : "", head.title ? `[ti:${head.title}]` : "", head.album ? `[al:${head.album}]` : ""].filter(Boolean);
|
|
70
|
+
const body = lines.map((line) => `[${stamp(line.start)}]${line.words.length ? line.words.map((word) => ` <${stamp(word.start)}>${word.text}`).join("") : line.text}`);
|
|
71
|
+
return `${[...tags, ...body].join("\n")}\n`;
|
|
72
|
+
}
|
|
73
|
+
/** The .lrc that sits beside a file (same name, .lrc), or "". */
|
|
74
|
+
export function lrcBeside(file) {
|
|
75
|
+
return file.replace(/\.[^./]+$/, "") + ".lrc";
|
|
76
|
+
}
|
|
16
77
|
export const NICHEDB_LYRICS = "https://nichedb.dev";
|
|
17
78
|
const MAX_TEXT = 20_000;
|
|
18
79
|
/** ID3 / ISO 639-2 codes for the languages we are likely to meet, as 639-1. */
|
|
@@ -94,8 +155,18 @@ export class LyricsFinder {
|
|
|
94
155
|
const title = tags["title"] || known?.title || "";
|
|
95
156
|
const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
|
|
96
157
|
const own = lyricsFromTags(tags);
|
|
158
|
+
// Timed words beside the file (karaoke): the page follows along with them.
|
|
159
|
+
let synced = [];
|
|
160
|
+
try {
|
|
161
|
+
synced = parseLrc(readFileSync(lrcBeside(file), "utf8"));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
synced = [];
|
|
165
|
+
}
|
|
97
166
|
if (own)
|
|
98
|
-
return { title, artist, text: own.text, source: "embedded", language: own.language };
|
|
167
|
+
return { title, artist, text: own.text, source: "embedded", language: own.language, ...(synced.length ? { synced } : {}) };
|
|
168
|
+
if (synced.length)
|
|
169
|
+
return { title, artist, text: synced.map((line) => line.text).join("\n"), source: "embedded", language: "", synced };
|
|
99
170
|
if (!title || !artist || !this.base)
|
|
100
171
|
return null;
|
|
101
172
|
return this.lookup(artist, title);
|