nixamp 0.54.0 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/ai-label.d.ts +26 -0
  2. package/dist/ai-label.js +90 -1
  3. package/dist/label-routes.d.ts +3 -0
  4. package/dist/label-routes.js +3 -2
  5. package/dist/lyrics.d.ts +25 -15
  6. package/dist/lyrics.js +72 -1
  7. package/dist/server.js +17 -12
  8. package/dist/station-guide.d.ts +10 -1
  9. package/dist/station-guide.js +19 -2
  10. package/dist/station-routes.js +1 -1
  11. package/package.json +1 -1
  12. package/src/ai-label.ts +92 -1
  13. package/src/label-routes.ts +5 -2
  14. package/src/lyrics.ts +76 -1
  15. package/src/server.ts +17 -12
  16. package/src/station-guide.ts +20 -2
  17. package/src/station-routes.ts +1 -1
  18. package/web/dist/assets/{ar-CHJqwLbG.js → ar-Huo4sgiY.js} +1 -1
  19. package/web/dist/assets/{cs-CmwqmJKg.js → cs-BBOuUwXU.js} +1 -1
  20. package/web/dist/assets/{da-Xdzrm0AY.js → da-Cev5Z6T9.js} +1 -1
  21. package/web/dist/assets/{de-h2ntHz8Y.js → de-bQVDBh55.js} +1 -1
  22. package/web/dist/assets/{es-DgbBXAxw.js → es-CJuqtDkS.js} +1 -1
  23. package/web/dist/assets/{fi-Bso9G00g.js → fi-7nU502Tj.js} +1 -1
  24. package/web/dist/assets/{fr-6Q3UZKIG.js → fr-BcVNUetg.js} +1 -1
  25. package/web/dist/assets/{hi-T7_WxQ11.js → hi-9TAve2EY.js} +1 -1
  26. package/web/dist/assets/{hu-BjOppQVq.js → hu-BTlBWSxB.js} +1 -1
  27. package/web/dist/assets/{id-BIkZDUv5.js → id-BX3ed0n3.js} +1 -1
  28. package/web/dist/assets/{it-CHOmOHju.js → it-BJUV5qZb.js} +1 -1
  29. package/web/dist/assets/{main-BpsUYSCl.js → main-BWhCWFcj.js} +2 -2
  30. package/web/dist/assets/{nl-CHQR6VFb.js → nl-H4pjCJkd.js} +1 -1
  31. package/web/dist/assets/{ru-Du7NQrxe.js → ru-GzkqrNgF.js} +1 -1
  32. package/web/dist/assets/{station-Bqk5iV8L.css → station-CSnjPeod.css} +1 -1
  33. package/web/dist/assets/{station-xTLbhzsu.js → station-DGgba3hs.js} +2 -2
  34. package/web/dist/assets/{sv-B6AFovY0.js → sv-B5CF3sTx.js} +1 -1
  35. package/web/dist/assets/{uk-LXJz37Kp.js → uk-M0JP5Z7P.js} +1 -1
  36. package/web/dist/assets/{vi-C4taTvjH.js → vi-D4kPYAxo.js} +1 -1
  37. package/web/dist/assets/{zh-dBiC2V5a.js → zh-DsrXJGeN.js} +1 -1
  38. package/web/dist/index.html +1 -1
  39. package/web/dist/station.html +3 -2
  40. package/web/dist/sw.js +24 -24
  41. package/web/dist/vod.html +2 -2
  42. package/web/dist/watch.html +2 -2
  43. /package/web/dist/assets/{vod-BSo-pl8M.js → vod-Br9Ooz6-.js} +0 -0
  44. /package/web/dist/assets/{watch-DBSszFQm.js → watch-Dxsz5gHd.js} +0 -0
@@ -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 video is made beside it, without holding up the next song.
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
  }
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { type AiLabel } from "./ai-label.ts";
3
3
  import { type GrantVerifier } from "./music-store.ts";
4
+ import { type GuideAds } from "./station-guide.ts";
4
5
  /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
5
6
  export declare function isPublicLabelPath(path: string): boolean;
6
7
  /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
@@ -13,6 +14,8 @@ export interface LabelRoutesContext {
13
14
  /** nixamp.com, where the store is, and this server's public address (for Buy links). */
14
15
  site?: string;
15
16
  publicBase?: string;
17
+ /** The one text_link ad /label carries (station-guide.ts withPageAd); none when unset. */
18
+ ads?: GuideAds;
16
19
  json: (response: ServerResponse, status: number, body: unknown) => void;
17
20
  readBody: (request: IncomingMessage) => Promise<string>;
18
21
  }
@@ -12,6 +12,7 @@ import { ROSTER } from "./ai-label.js";
12
12
  import { covers, PRICES } from "./music-store.js";
13
13
  import { writeZip, zipSize } from "./zip-stream.js";
14
14
  import { StationError } from "./stations.js";
15
+ import { adCsp, withPageAd } from "./station-guide.js";
15
16
  // Listening is free on the live stations (with their breaks); the songs as
16
17
  // files are sold, so only the words and the cover are public here, and an
17
18
  // MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
@@ -88,8 +89,8 @@ export async function handleLabelRoutes(request, response, path, ctx) {
88
89
  }
89
90
  if (path === "/label") {
90
91
  const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase } : undefined;
91
- const page = renderLabel(label.publicView(), shop);
92
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
92
+ const page = withPageAd(renderLabel(label.publicView(), shop), ctx.ads ?? {});
93
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": adCsp(LABEL_CSP, ctx.ads ?? {}), "cache-control": "public, max-age=300" });
93
94
  response.end(method === "HEAD" ? undefined : page);
94
95
  return true;
95
96
  }
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);
package/dist/server.js CHANGED
@@ -78,7 +78,7 @@ import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vod
78
78
  import { withPitFooter } from "./pit-footer.js";
79
79
  import { handleFollowPage } from "./follow-page.js";
80
80
  import { asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, radioNav, radioPeers, radioRewriter, radioStations, renderAlbum, renderArtist, renderDirectory, renderMissing, rewriteResponse, } from "./radio-pages.js";
81
- import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
81
+ import { adCsp, guideAds, guideCsp, withPageAd, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
82
82
  import { StationRunner } from "./station-runner.js";
83
83
  import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
84
84
  import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
@@ -2529,7 +2529,7 @@ export function createHandler(engine, options) {
2529
2529
  });
2530
2530
  // Behind a guide name (live.streaming) the page asks its own origin, so no server address shows in it.
2531
2531
  const apiBase = guideHosts().has(requestHost) ? "" : options.stations.publicBase?.() ?? "";
2532
- const page = renderGuide([...rooms, ...entries, ...named], apiBase, ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2532
+ const page = renderGuide([...rooms, ...entries, ...named], apiBase, ads, label && label.publicView().genres.length > 0 ? "/label" : "", path === "/");
2533
2533
  response.writeHead(200, {
2534
2534
  "content-type": "text/html; charset=utf-8",
2535
2535
  "cache-control": "no-cache",
@@ -2546,7 +2546,7 @@ export function createHandler(engine, options) {
2546
2546
  // stream address>. The built page plays it in @profullstack/player, as a
2547
2547
  // film or as radio; nothing about the room passes through here.
2548
2548
  if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
2549
- const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html")) : null;
2549
+ const built = web !== null && isFile(join(web, "watch.html")) ? withPageAd(readFileSync(join(web, "watch.html"), "utf8"), guideAds()) : null;
2550
2550
  if (!built) {
2551
2551
  json(response, 404, { error: "this server was started without its web pages" });
2552
2552
  return;
@@ -2554,7 +2554,7 @@ export function createHandler(engine, options) {
2554
2554
  response.writeHead(200, {
2555
2555
  "content-type": "text/html; charset=utf-8",
2556
2556
  "cache-control": "no-cache",
2557
- "content-security-policy": WATCH_APP_CSP,
2557
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
2558
2558
  "referrer-policy": "no-referrer",
2559
2559
  "x-content-type-options": "nosniff",
2560
2560
  });
@@ -2581,11 +2581,11 @@ export function createHandler(engine, options) {
2581
2581
  response.writeHead(vodPath[1] && !one ? 404 : 200, {
2582
2582
  "content-type": "text/html; charset=utf-8",
2583
2583
  "cache-control": "no-cache",
2584
- "content-security-policy": WATCH_APP_CSP,
2584
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
2585
2585
  "referrer-policy": "no-referrer",
2586
2586
  "x-content-type-options": "nosniff",
2587
2587
  });
2588
- response.end(request.method === "HEAD" ? undefined : built.replace(/<title>[^<]*<\/title>/, head));
2588
+ response.end(request.method === "HEAD" ? undefined : withPageAd(built.replace(/<title>[^<]*<\/title>/, head), guideAds()));
2589
2589
  return;
2590
2590
  }
2591
2591
  // /radio: public, listed stations under readable names, and the artists and
@@ -2598,10 +2598,12 @@ export function createHandler(engine, options) {
2598
2598
  const base = radioBaseFor(request, publicBase);
2599
2599
  const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide");
2600
2600
  const send = (status, page, csp = RADIO_CSP) => {
2601
+ const ads = guideAds();
2602
+ page = withPageAd(page, ads);
2601
2603
  response.writeHead(status, {
2602
2604
  "content-type": "text/html; charset=utf-8",
2603
2605
  "cache-control": status === 200 ? "public, max-age=60" : "no-cache",
2604
- "content-security-policy": csp,
2606
+ "content-security-policy": adCsp(csp, ads),
2605
2607
  "referrer-policy": "strict-origin-when-cross-origin",
2606
2608
  "x-content-type-options": "nosniff",
2607
2609
  });
@@ -2670,12 +2672,14 @@ export function createHandler(engine, options) {
2670
2672
  return send(404, renderMissing("station", base, nav));
2671
2673
  }
2672
2674
  const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html"), "utf8") : null;
2673
- const page = asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav);
2675
+ // The built page carries the one ad; the inline fallback stays as it is.
2676
+ const ads = built ? guideAds() : {};
2677
+ const page = withPageAd(asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav), ads);
2674
2678
  response.writeHead(200, {
2675
2679
  "content-type": "text/html; charset=utf-8",
2676
2680
  "cache-control": "no-cache",
2677
2681
  // The inline page names its styles by hash; the header's is one more.
2678
- "content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP.replace("style-src ", `style-src 'sha256-${sha("sha256").update(NAV_STYLE).digest("base64")}' `),
2682
+ "content-security-policy": built ? adCsp(STATION_APP_CSP, ads) : STATION_PAGE_CSP.replace("style-src ", `style-src 'sha256-${sha("sha256").update(NAV_STYLE).digest("base64")}' `),
2679
2683
  "referrer-policy": "strict-origin-when-cross-origin",
2680
2684
  "x-content-type-options": "nosniff",
2681
2685
  });
@@ -2733,11 +2737,12 @@ export function createHandler(engine, options) {
2733
2737
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
2734
2738
  // The built page plays in @profullstack/player; the inline one is for a
2735
2739
  // server started without the web build.
2736
- const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html")) : null;
2740
+ const ads = guideAds();
2741
+ const built = web !== null && isFile(join(web, "station.html")) ? withPageAd(readFileSync(join(web, "station.html"), "utf8"), ads) : null;
2737
2742
  response.writeHead(200, {
2738
2743
  "content-type": "text/html; charset=utf-8",
2739
2744
  "cache-control": "no-cache",
2740
- "content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP,
2745
+ "content-security-policy": built ? adCsp(STATION_APP_CSP, ads) : STATION_PAGE_CSP,
2741
2746
  "referrer-policy": "no-referrer",
2742
2747
  "x-content-type-options": "nosniff",
2743
2748
  });
@@ -2795,7 +2800,7 @@ export function createHandler(engine, options) {
2795
2800
  const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
2796
2801
  if (await handleLabelRoutes(request, response, path, {
2797
2802
  label: options.stations.label, canControl: control.allowed, json, readBody,
2798
- grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "",
2803
+ grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "", ads: guideAds(),
2799
2804
  }))
2800
2805
  return;
2801
2806
  }
@@ -29,7 +29,7 @@ export interface GuideEntry {
29
29
  * gateway that cannot run its script against this server (the Moshpit's
30
30
  * /n/<name>), and for a crawler. Everything from a station is escaped.
31
31
  */
32
- export declare function renderGuide(entries: GuideEntry[], apiBase?: string, ads?: GuideAds, labelHref?: string): string;
32
+ export declare function renderGuide(entries: GuideEntry[], apiBase?: string, ads?: GuideAds, labelHref?: string, home?: boolean): string;
33
33
  export declare const GUIDE_HTML: string;
34
34
  /**
35
35
  * The guide's policy. With crawlproof configured (guideAds below) its tracker
@@ -38,6 +38,15 @@ export declare const GUIDE_HTML: string;
38
38
  */
39
39
  export declare function guideCsp(ads?: GuideAds): string;
40
40
  export declare const GUIDE_CSP: string;
41
+ /**
42
+ * The one ad an inner page carries -- a station, /radio, an artist, an album,
43
+ * /label, /vod, /watch -- and never a home page: a single text_link line at
44
+ * the top of its content, sized by attributes so a page whose CSP hashes its
45
+ * styles needs no new one. The page as it was without a slot.
46
+ */
47
+ export declare function withPageAd(html: string, ads: GuideAds): string;
48
+ /** A page's CSP with room for that ad's frame. */
49
+ export declare function adCsp(csp: string, ads: GuideAds): string;
41
50
  /** crawlproof analytics and an ad on the guide: a project id (tracker data-site) and an ad slot. */
42
51
  export interface GuideAds {
43
52
  site?: string;
@@ -151,7 +151,7 @@ const GROUPS = ["rooms", "vod", "ai", "shared", "radio", "tv"];
151
151
  * gateway that cannot run its script against this server (the Moshpit's
152
152
  * /n/<name>), and for a crawler. Everything from a station is escaped.
153
153
  */
154
- export function renderGuide(entries, apiBase = "", ads = {}, labelHref = "") {
154
+ export function renderGuide(entries, apiBase = "", ads = {}, labelHref = "", home = false) {
155
155
  const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
156
156
  const cards = (group) => entries
157
157
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
@@ -166,7 +166,8 @@ export function renderGuide(entries, apiBase = "", ads = {}, labelHref = "") {
166
166
  }
167
167
  if (ads.site)
168
168
  page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
169
- if (ads.slot) {
169
+ // A home page carries no ad (live.streaming's / is this guide); nixamp.com/guide is an inner page.
170
+ if (ads.slot && !home) {
170
171
  // One line by default: a 300x250 box above the stations pushed the guide itself off the first screen.
171
172
  const format = ads.format ?? "text_link";
172
173
  const slot = ads.slot;
@@ -249,6 +250,22 @@ export function guideCsp(ads = {}) {
249
250
  ].join("; ");
250
251
  }
251
252
  export const GUIDE_CSP = guideCsp();
253
+ /**
254
+ * The one ad an inner page carries -- a station, /radio, an artist, an album,
255
+ * /label, /vod, /watch -- and never a home page: a single text_link line at
256
+ * the top of its content, sized by attributes so a page whose CSP hashes its
257
+ * styles needs no new one. The page as it was without a slot.
258
+ */
259
+ export function withPageAd(html, ads) {
260
+ if (!ads.slot)
261
+ return html;
262
+ const unit = `<div class="ad-unit ad-text_link"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(ads.slot)}&amp;format=text_link" title="Advertisement" width="100%" height="40" frameborder="0" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>`;
263
+ return html.replace(/<main\b[^>]*>/, (main) => `${main}\n${unit}`);
264
+ }
265
+ /** A page's CSP with room for that ad's frame. */
266
+ export function adCsp(csp, ads) {
267
+ return ads.slot ? `${csp}; frame-src 'self' ${CRAWLPROOF}` : csp;
268
+ }
252
269
  const AD_ID = /^[A-Za-z0-9-]{1,64}$/;
253
270
  /** A setting as the guide may use it: ids that are ids, a format it knows, or nothing. */
254
271
  export function cleanGuideAds(value) {
@@ -240,7 +240,7 @@ export async function handleStations(request, response, path, url, ctx) {
240
240
  ctx.json(response, 404, { error: "no lyrics for what is playing", code: "not_found" });
241
241
  return true;
242
242
  }
243
- ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language } });
243
+ ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language, ...(lyrics.synced ? { synced: lyrics.synced } : {}) } });
244
244
  return true;
245
245
  }
246
246
  if (what === "now") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.54.0",
3
+ "version": "0.55.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",