nixamp 0.59.5 → 0.60.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 (47) hide show
  1. package/dist/ai-label.d.ts +37 -0
  2. package/dist/ai-label.js +127 -10
  3. package/dist/music-hub.d.ts +8 -0
  4. package/dist/music-hub.js +30 -6
  5. package/dist/music-seo.d.ts +38 -0
  6. package/dist/music-seo.js +160 -0
  7. package/dist/radio-pages.d.ts +4 -2
  8. package/dist/radio-pages.js +11 -4
  9. package/dist/server.d.ts +5 -0
  10. package/dist/server.js +81 -7
  11. package/dist/station-cli.js +2 -0
  12. package/dist/station-mcp.d.ts +30 -0
  13. package/dist/station-mcp.js +3 -1
  14. package/dist/station-usage.d.ts +1 -1
  15. package/dist/station-usage.js +1 -0
  16. package/dist/veo-studio.d.ts +39 -0
  17. package/dist/veo-studio.js +110 -0
  18. package/package.json +1 -1
  19. package/src/ai-label.ts +128 -12
  20. package/src/music-hub.ts +29 -6
  21. package/src/music-seo.ts +191 -0
  22. package/src/radio-pages.ts +12 -4
  23. package/src/server.ts +62 -8
  24. package/src/station-cli.ts +2 -0
  25. package/src/station-mcp.ts +3 -1
  26. package/src/station-usage.ts +1 -0
  27. package/src/veo-studio.ts +123 -0
  28. package/web/dist/assets/{ar-D7L1Lzg5.js → ar-5jTau3by.js} +1 -1
  29. package/web/dist/assets/{cs-DR-oumcX.js → cs-nlVt79qU.js} +1 -1
  30. package/web/dist/assets/{da-B7ofIVod.js → da-C_mRyuJW.js} +1 -1
  31. package/web/dist/assets/{de-CglXPk_n.js → de-Duqxb30z.js} +1 -1
  32. package/web/dist/assets/{es-CYVNiI20.js → es-CAHgck_F.js} +1 -1
  33. package/web/dist/assets/{fi-Bktga6d_.js → fi-BPXwol2s.js} +1 -1
  34. package/web/dist/assets/{fr-CNxMDHg5.js → fr-C9N4WLtT.js} +1 -1
  35. package/web/dist/assets/{hi-DEyWHgib.js → hi-PWlH7Sya.js} +1 -1
  36. package/web/dist/assets/{hu-mcHUOvhY.js → hu-DHmawwq3.js} +1 -1
  37. package/web/dist/assets/{id-T9wfahs5.js → id-DwMuYDOT.js} +1 -1
  38. package/web/dist/assets/{it-CASLaiyC.js → it-DuVsDcAz.js} +1 -1
  39. package/web/dist/assets/{main-D9-BuE2r.js → main-BV2N47tZ.js} +2 -2
  40. package/web/dist/assets/{nl-DQ-YPUBS.js → nl-BcdvPtpE.js} +1 -1
  41. package/web/dist/assets/{ru-CDSDPqFZ.js → ru-CfMl9LlQ.js} +1 -1
  42. package/web/dist/assets/{sv-liuu51-p.js → sv-CDtolH7I.js} +1 -1
  43. package/web/dist/assets/{uk-B3vreqbA.js → uk-DwjjSnVU.js} +1 -1
  44. package/web/dist/assets/{vi-B0NyoUEc.js → vi-CNYK9Tlb.js} +1 -1
  45. package/web/dist/assets/{zh-Bw5OQASg.js → zh-6nGSjON5.js} +1 -1
  46. package/web/dist/index.html +1 -1
  47. package/web/dist/sw.js +19 -19
package/src/ai-label.ts CHANGED
@@ -125,6 +125,10 @@ export interface LabelSettings {
125
125
  songSeconds: number;
126
126
  /** A music video for every song (the cover over itself, blurred, and the song's waveform), and a TV station of them. */
127
127
  videos: boolean;
128
+ /** Each album's single (song 1) gets filmed Veo shots of the band, while the month's Veo budget lasts. */
129
+ veo: boolean;
130
+ /** How many 8 s Veo shots a single gets. */
131
+ veoShots: number;
128
132
  /** Public, listed and owned, so the genre stations are on the guide. */
129
133
  publish: boolean;
130
134
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -378,6 +382,8 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
378
382
  songSeconds: 150,
379
383
  publish: true,
380
384
  videos: true,
385
+ veo: false,
386
+ veoShots: 4,
381
387
  parallel: 1,
382
388
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
383
389
  };
@@ -617,6 +623,35 @@ export async function writeStoryboard(
617
623
  return prompts;
618
624
  }
619
625
 
626
+ /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
627
+ export class VeoUnavailable extends Error {}
628
+
629
+ const SHOTS = [
630
+ "Wide shot of the whole band performing on a stage, the crowd in the foreground",
631
+ "Close-up of the lead singer singing the chorus into the microphone",
632
+ "The camera moves across the stage between the band members as they play hard",
633
+ "Low-angle shot of the band at the song's climax, lights flaring behind them",
634
+ "Over-the-shoulder shot from behind the drums, looking out at the band and the crowd",
635
+ "Slow push-in on the band in a quieter moment, moody light",
636
+ "Handheld shot from the crowd, fans with hands up, the band lit on stage",
637
+ "The band walking out on stage before the show, backlit silhouettes turning into faces",
638
+ ];
639
+
640
+ /**
641
+ * The prompts for a single's filmed shots. The people are the reference
642
+ * images, described by role and look only: Veo refuses names with faces
643
+ * ("we can't create videos with real people's names or likenesses").
644
+ */
645
+ export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[] {
646
+ const cast = members.slice(0, 3).map((one, i) => `the ${one.role} from reference image ${i + 1} (${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
647
+ return SHOTS.slice(0, count).map((shot) => [
648
+ `Music video for a fictional ${artist.genre} band. ${shot}.`,
649
+ cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
650
+ `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
651
+ "Cinematic, 35mm film look, dynamic camera. No text, no logos.",
652
+ ].filter(Boolean).join(" "));
653
+ }
654
+
620
655
  /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
621
656
  function titleFont(): string {
622
657
  for (const file of ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"]) {
@@ -640,10 +675,21 @@ function runLow(ffmpeg: string[], args: string[], timeout: number, what: string)
640
675
  * the artist and title come up at the start, and the MP3's audio and tags
641
676
  * (title, artist, album, words) are copied in. 720p H.264.
642
677
  */
643
- export function renderStoryVideo(ffmpeg: string[], input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }): Promise<void> {
678
+ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string; clips?: Array<{ path: string; seconds: number }> }): Promise<void> {
644
679
  const fade = 1;
645
- const count = input.images.length;
646
- const each = input.seconds / count + fade;
680
+ const clips = input.clips ?? [];
681
+ // Stills fill what the filmed shots do not; each gets at least 3 s, so on a short song some are left out.
682
+ let images = [...input.images];
683
+ const clipTime = clips.reduce((sum, one) => sum + one.seconds, 0);
684
+ const stillFor = (count: number): number => (input.seconds + (count + clips.length - 1) * fade - clipTime) / Math.max(1, count);
685
+ while (images.length > 0 && stillFor(images.length) < 3) images = images.slice(0, -1);
686
+ const each = images.length ? stillFor(images.length) : 0;
687
+ // A still, then a filmed shot, in turn; whatever is left of either at the end.
688
+ const order: Array<{ kind: "image"; path: string } | { kind: "clip"; path: string; seconds: number }> = [];
689
+ for (let i = 0; i < Math.max(images.length, clips.length); i++) {
690
+ if (images[i]) order.push({ kind: "image", path: images[i]! });
691
+ if (clips[i]) order.push({ kind: "clip", ...clips[i]! });
692
+ }
647
693
  const frames = Math.max(25, Math.round(each * 25));
648
694
  const moves = [
649
695
  `z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
@@ -652,12 +698,25 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
652
698
  `z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
653
699
  ];
654
700
  const parts: string[] = [];
655
- input.images.forEach((_, i) => {
656
- parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[i % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
701
+ const lengths: number[] = [];
702
+ let still = 0;
703
+ order.forEach((item, i) => {
704
+ if (item.kind === "image") {
705
+ parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[still++ % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
706
+ lengths.push(each);
707
+ } else {
708
+ // A filmed shot plays as it was filmed; the last one holds its final frame if the song runs on.
709
+ const hold = i === order.length - 1 ? `,tpad=stop_mode=clone:stop_duration=${Math.max(0, input.seconds).toFixed(2)}` : "";
710
+ parts.push(`[${i}:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,fps=25,setsar=1,trim=duration=${item.seconds},setpts=PTS-STARTPTS${hold}[v${i}]`);
711
+ lengths.push(item.seconds);
712
+ }
657
713
  });
714
+ const count = order.length;
658
715
  let last = "v0";
716
+ let at = 0;
659
717
  for (let i = 1; i < count; i++) {
660
- parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${(i * (each - fade)).toFixed(2)}[x${i}]`);
718
+ at += lengths[i - 1]! - fade;
719
+ parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${at.toFixed(2)}[x${i}]`);
661
720
  last = `x${i}`;
662
721
  }
663
722
  const font = titleFont();
@@ -666,7 +725,7 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
666
725
  ? `[${last}]drawtext=fontfile=${font}:text='${card}':fontcolor=white:fontsize=44:x=60:y=h-110:enable='between(t,0.5,6)':alpha='if(lt(t,1.5),t-0.5,if(gt(t,5),6-t,1))':shadowcolor=black@0.7:shadowx=2:shadowy=2,format=yuv420p[v]`
667
726
  : `[${last}]format=yuv420p[v]`);
668
727
  const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
669
- ...input.images.flatMap((image) => ["-loop", "1", "-t", each.toFixed(2), "-i", image]),
728
+ ...order.flatMap((item) => (item.kind === "image" ? ["-loop", "1", "-t", each.toFixed(2), "-i", item.path] : ["-i", item.path])),
670
729
  "-i", input.audio, "-filter_complex", parts.join(";"),
671
730
  "-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
672
731
  "-movflags", "+faststart+use_metadata_tags", input.out];
@@ -758,8 +817,10 @@ export interface AiLabelDeps {
758
817
  video?: (audio: string, cover: string, out: string) => Promise<void>;
759
818
  /** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
760
819
  align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
820
+ /** A filmed shot (Veo, through nixamp.com): the MP4, or a VeoUnavailable when the month's budget is spent. */
821
+ veo?: ((input: { prompt: string; seconds: number; references: Buffer[] }) => Promise<Buffer>) | null;
761
822
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
762
- storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }) => Promise<void>;
823
+ storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string; clips?: Array<{ path: string; seconds: number }> }) => Promise<void>;
763
824
  /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
764
825
  image?: (prompt: string) => Promise<Buffer | null>;
765
826
  /** Tells the world an album landed (nixamp.com tells the artist's followers); never awaited. */
@@ -789,6 +850,8 @@ export class AiLabel {
789
850
  private videoFailed = new Set<string>();
790
851
  /** Songs whose words were already sent to the aligner since this server started. */
791
852
  private alignTried = new Set<string>();
853
+ /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
854
+ private veoSpentIn = "";
792
855
  /** Old videos already tried as storyboards since this server started. */
793
856
  private upgradeTried = new Set<string>();
794
857
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -937,6 +1000,9 @@ export class AiLabel {
937
1000
  if (input["enabled"] !== undefined) this.settings.enabled = input["enabled"] !== false;
938
1001
  if (input["publish"] !== undefined) this.settings.publish = input["publish"] !== false;
939
1002
  if (input["videos"] !== undefined) this.settings.videos = input["videos"] !== false;
1003
+ if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
1004
+ const shots = whole("veoShots", 1, 8);
1005
+ if (shots !== undefined) this.settings.veoShots = shots;
940
1006
  const perDay = whole("albumsPerDay", 0, 24);
941
1007
  if (perDay !== undefined) this.settings.albumsPerDay = perDay;
942
1008
  if (input["newArtistChance"] !== undefined) {
@@ -1114,7 +1180,7 @@ export class AiLabel {
1114
1180
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
1115
1181
  const missing = songs.filter(({ song }) => !song.video);
1116
1182
  const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
1117
- const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
1183
+ const old = songs.filter(({ song }) => song.video && song.videoTagged && ((song.videoVersion ?? 1) < 2 || (this.wantsVeo(song) && (song.videoVersion ?? 1) < 3)) && !this.upgradeTried.has(song.file));
1118
1184
  let made = 0;
1119
1185
  for (const { album, song } of untagged) {
1120
1186
  if (!this.settings.videos) break;
@@ -1192,15 +1258,65 @@ export class AiLabel {
1192
1258
  this.log(`no storyboard for "${song.title}" (${(error as Error).message}); the cover video instead`);
1193
1259
  }
1194
1260
  }
1195
- if (images.length >= 2) {
1196
- await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out });
1197
- return 2;
1261
+ // An album's single gets filmed shots of the band too, while the month's Veo budget lasts.
1262
+ const clips = this.wantsVeo(song) ? await this.filmShots(artist, song, scenes) : [];
1263
+ if (images.length >= 2 || clips.length > 0) {
1264
+ if (storyOnly && (song.videoVersion ?? 1) >= 2 && clips.length === 0) throw new Error("no filmed shots this time; the video it has stays");
1265
+ await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out, ...(clips.length ? { clips } : {}) });
1266
+ return clips.length ? 3 : 2;
1198
1267
  }
1199
1268
  if (storyOnly) throw new Error("no storyboard could be drawn; the video it has stays");
1200
1269
  await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
1201
1270
  return 1;
1202
1271
  }
1203
1272
 
1273
+ /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1274
+ private wantsVeo(song: Pick<LabelSong, "n">): boolean {
1275
+ return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
1276
+ }
1277
+
1278
+ /**
1279
+ * A single's filmed shots: the band, from their portraits (up to three as
1280
+ * Veo's reference images), on stage, in the song's mood. Stops for the
1281
+ * month when the budget is spent; a shot Veo will not make is skipped.
1282
+ */
1283
+ private async filmShots(artist: LabelArtist, song: LabelSong, dir: string): Promise<Array<{ path: string; seconds: number }>> {
1284
+ const veo = this.deps.veo;
1285
+ if (!veo) return [];
1286
+ mkdirSync(dir, { recursive: true });
1287
+ const members = this.cast(artist);
1288
+ const references: Buffer[] = [];
1289
+ for (const member of members.slice(0, 3)) {
1290
+ const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1291
+ const small = join(dir, `ref-${member.slug}.jpg`);
1292
+ try {
1293
+ // 512 px is plenty for a face and keeps the request small.
1294
+ await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", portrait, "-vf", "scale=512:-2", "-q:v", "4", small], 60_000, "the portrait did not shrink");
1295
+ references.push(readFileSync(small));
1296
+ } catch {
1297
+ // A member without a portrait is described in words only.
1298
+ }
1299
+ }
1300
+ const clips: Array<{ path: string; seconds: number }> = [];
1301
+ for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1302
+ try {
1303
+ const bytes = await veo({ prompt, seconds: 8, references });
1304
+ const file = join(dir, `shot-${i}.mp4`);
1305
+ writeFileSync(file, bytes);
1306
+ clips.push({ path: file, seconds: 8 });
1307
+ } catch (error) {
1308
+ if (error instanceof VeoUnavailable) {
1309
+ this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
1310
+ this.log(`Veo: ${(error as Error).message}; singles get the storyboard until next month`);
1311
+ break;
1312
+ }
1313
+ this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
1314
+ }
1315
+ }
1316
+ if (clips.length) this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1317
+ return clips;
1318
+ }
1319
+
1204
1320
  /** The label's music videos as one TV station: free to watch, with its breaks. */
1205
1321
  private placeVideos(): Promise<void> {
1206
1322
  const next = this.placing.then(async () => {
package/src/music-hub.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { createHash } from "node:crypto";
15
15
  import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import { musicPage, renderMusicPage } from "./music-seo.ts";
16
17
 
17
18
  /** A server's opaque id: the first 10 hex of the SHA-256 of its https origin. Both sides can work it out. */
18
19
  export function serverId(base: string): string {
@@ -149,7 +150,7 @@ export async function musicPageFor(hub: MusicHub, path: string): Promise<string>
149
150
  }
150
151
 
151
152
  export function isMusicPath(path: string): boolean {
152
- return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released";
153
+ return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
153
154
  }
154
155
 
155
156
  export interface MusicRoutesContext {
@@ -158,8 +159,12 @@ export interface MusicRoutesContext {
158
159
  readBody: (request: IncomingMessage) => Promise<string>;
159
160
  /** The page shell (web/music.html), or null when the web build is absent. */
160
161
  page: () => string | null;
162
+ /** This site's public origin, for canonical and og: addresses (https://nixamp.com). */
163
+ site?: string;
161
164
  /** A server says something of its label landed; null when nobody may say so. */
162
165
  released?: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{ status: number; body: unknown }>;
166
+ /** Veo shots for a label server's videos: the month's spend (GET), a clip (POST); answers bytes or JSON. */
167
+ veo?: (request: IncomingMessage) => Promise<{ status: number; body: unknown; video?: Buffer }>;
163
168
  }
164
169
 
165
170
  export async function handleMusicRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: MusicRoutesContext): Promise<boolean> {
@@ -173,6 +178,15 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
173
178
  ctx.json(response, answer.status, answer.body);
174
179
  return true;
175
180
  }
181
+ if (path === "/api/v1/music/veo") {
182
+ if (!ctx.veo) { ctx.json(response, 404, { error: "Veo is not set up here" }); return true; }
183
+ const answer = await ctx.veo(request);
184
+ if (answer.video) {
185
+ response.writeHead(200, { "content-type": "video/mp4", "content-length": String(answer.video.length), "cache-control": "no-store" });
186
+ response.end(answer.video);
187
+ } else ctx.json(response, answer.status, answer.body);
188
+ return true;
189
+ }
176
190
  if (method !== "GET" && method !== "HEAD") { ctx.json(response, 405, { error: "not like that" }); return true; }
177
191
  if (path === "/api/v1/music") {
178
192
  ctx.json(response, 200, await ctx.hub.catalogue());
@@ -204,10 +218,19 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
204
218
  }
205
219
  return true;
206
220
  }
207
- // /music and everything under it is the app; it reads /api/v1/music.
208
- const page = ctx.page();
209
- if (!page) { ctx.json(response, 404, { error: "the music app is not built here" }); return true; }
210
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
211
- response.end(method === "HEAD" ? undefined : page);
221
+ // /music and everything under it is the app; it reads /api/v1/music. Its head and a
222
+ // plain summary are written here, per page (music-seo.ts), for crawlers and link
223
+ // previews; a catalogue that is slow or gone leaves the shell as it is.
224
+ const shell = ctx.page();
225
+ if (!shell) { ctx.json(response, 404, { error: "the music app is not built here" }); return true; }
226
+ const site = (ctx.site ?? "https://nixamp.com").replace(/\/+$/, "");
227
+ const catalogue = await Promise.race([
228
+ ctx.hub.catalogue().catch(() => null),
229
+ new Promise<null>((done) => setTimeout(() => done(null), 2_000).unref?.()),
230
+ ]);
231
+ const view = catalogue ? musicPage(path, catalogue, site) : null;
232
+ const html = view ? renderMusicPage(shell, view, `${site}${path.replace(/\/+$/, "") || "/music"}`) : shell;
233
+ response.writeHead(view?.status ?? 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
234
+ response.end(method === "HEAD" ? undefined : html);
212
235
  return true;
213
236
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * nixamp.com/music for crawlers and link previews: the page shell with its
3
+ * head and a plain summary written on the server, per page. The app reads
4
+ * /api/v1/music and replaces the summary when its script runs, so a person
5
+ * sees the app and a crawler, a chat app or a social card sees the title,
6
+ * the artist, the album and its artwork without running anything.
7
+ *
8
+ * /music the label: genres, artists, stations
9
+ * /music/genre/<slug> a genre's artists
10
+ * /music/artist/<server>/<artist> an artist: bio, members, albums
11
+ * /music/album/<server>/<artist>/<album> an album: cover, songs
12
+ * /music/me the listener's own page: never indexed
13
+ */
14
+ import type { HubArtist, HubCatalogue } from "./music-hub.ts";
15
+
16
+ const slug = (text: string): string => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
17
+ const escape = (text: string): string => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
18
+ const clip = (text: string, max: number): string => {
19
+ const one = text.replace(/\s+/g, " ").trim();
20
+ return one.length <= max ? one : `${one.slice(0, max - 1).replace(/\s+\S*$/, "")}…`;
21
+ };
22
+ const clock = (seconds: number): string => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
23
+
24
+ interface Head {
25
+ title: string;
26
+ description: string;
27
+ type: string;
28
+ image?: { url: string; alt: string };
29
+ noindex?: boolean;
30
+ /** og:music:* and similar, as [property, content]. */
31
+ extra?: Array<[string, string]>;
32
+ ld?: Record<string, unknown>;
33
+ }
34
+
35
+ /** What the server writes for a /music address: its head, and a summary for the page body. */
36
+ export interface MusicPage { head: Head; body: string; status: number }
37
+
38
+ /** The head and summary of a /music address, from the catalogue. `site` is https://nixamp.com. */
39
+ export function musicPage(path: string, catalogue: HubCatalogue, site: string): MusicPage {
40
+ const at = (rest: string): string => `${site}${rest}`;
41
+ const file = (server: string, rest: string): string => at(`/api/v1/music/file/${server}/${rest}`);
42
+ const artists = catalogue.genres.flatMap((genre) => genre.artists);
43
+ const holder = catalogue.holders[0] ?? "Profullstack, Inc.";
44
+ const parts = path.replace(/\/+$/, "").split("/").slice(2);
45
+ const pictureOf = (artist: HubArtist): Head["image"] => {
46
+ const album = [...artist.albums].reverse().find((one) => one.cover);
47
+ if (album) return { url: file(artist.server, `${artist.key}/${album.key}/cover.jpg`), alt: `${album.title} by ${artist.name}, the cover` };
48
+ const member = artist.members?.[0];
49
+ return member ? { url: file(artist.server, `${artist.key}/members/${member.slug}.jpg`), alt: `${member.name} of ${artist.name}` } : undefined;
50
+ };
51
+ const artistLink = (artist: HubArtist): string => `/music/artist/${artist.server}/${artist.key}`;
52
+ const missing = (what: string): MusicPage => ({
53
+ status: 404,
54
+ head: { title: `No such ${what} · nixamp music`, description: `Nothing by that name is on nixamp music.`, type: "website", noindex: true },
55
+ body: `<h1>No such ${escape(what)}</h1><p><a href="/music">Every artist on nixamp music</a></p>`,
56
+ });
57
+
58
+ if (parts[0] === "me") {
59
+ return {
60
+ status: 200,
61
+ head: { title: "My music · nixamp", description: "The songs and albums you bought on nixamp, and the artists you follow.", type: "website", noindex: true },
62
+ body: "<h1>My music</h1><p>Sign in to see what you bought and who you follow.</p>",
63
+ };
64
+ }
65
+
66
+ if (parts[0] === "genre" && parts[1]) {
67
+ const genre = catalogue.genres.find((one) => slug(one.genre) === parts[1]);
68
+ if (!genre) return missing("genre");
69
+ const names = genre.artists.map((one) => one.name);
70
+ const lead = genre.artists[0];
71
+ return {
72
+ status: 200,
73
+ head: {
74
+ title: `${genre.genre} · nixamp music`,
75
+ description: clip(`${genre.genre} by AI artists on nixamp: ${names.join(", ")}. Listen free on the live station; follow the artists; buy the songs.`, 200),
76
+ type: "website",
77
+ ...(lead && pictureOf(lead) ? { image: pictureOf(lead)! } : {}),
78
+ },
79
+ body: `<h1>${escape(genre.genre)}</h1><ul>${genre.artists.map((one) => `<li><a href="${artistLink(one)}">${escape(one.name)}</a>${one.bio ? `: ${escape(clip(one.bio, 160))}` : ""}</li>`).join("")}</ul>`,
80
+ };
81
+ }
82
+
83
+ if (parts[0] === "artist" && parts[1] && parts[2]) {
84
+ const artist = artists.find((one) => one.server === parts[1] && one.key === parts[2]);
85
+ if (!artist) return missing("artist");
86
+ const image = pictureOf(artist);
87
+ const albums = artist.albums.filter((one) => one.songs.length > 0);
88
+ const members = artist.members ?? [];
89
+ return {
90
+ status: 200,
91
+ head: {
92
+ title: `${artist.name} · ${artist.genre} · nixamp music`,
93
+ description: clip(artist.bio || `${artist.name}, ${artist.genre} on nixamp: ${albums.length} album${albums.length === 1 ? "" : "s"}.`, 200),
94
+ type: "profile",
95
+ ...(image ? { image } : {}),
96
+ ld: {
97
+ "@context": "https://schema.org", "@type": "MusicGroup", name: artist.name, genre: artist.genre, url: at(artistLink(artist)),
98
+ ...(artist.bio ? { description: artist.bio } : {}),
99
+ ...(image ? { image: image.url } : {}),
100
+ ...(members.length ? { member: members.map((one) => ({ "@type": "Person", name: one.name, ...(one.role ? { roleName: one.role } : {}) })) } : {}),
101
+ album: albums.map((one) => ({ "@type": "MusicAlbum", name: one.title, url: at(`/music/album/${artist.server}/${artist.key}/${one.key}`), ...(one.year ? { datePublished: String(one.year) } : {}) })),
102
+ recordLabel: { "@type": "Organization", name: holder },
103
+ },
104
+ },
105
+ body: `<p><a href="/music">Music</a> / <a href="/music/genre/${slug(artist.genre)}">${escape(artist.genre)}</a></p><h1>${escape(artist.name)}</h1>`
106
+ + (artist.bio ? `<p>${escape(artist.bio)}</p>` : "")
107
+ + (members.length ? `<h2>Members</h2><ul>${members.map((one) => `<li>${escape(one.name)}${one.role ? `, ${escape(one.role)}` : ""}</li>`).join("")}</ul>` : "")
108
+ + (albums.length ? `<h2>Albums</h2><ul>${albums.map((one) => `<li><a href="/music/album/${artist.server}/${artist.key}/${one.key}">${escape(one.title)}</a>${one.year ? ` (${one.year})` : ""}</li>`).join("")}</ul>` : ""),
109
+ };
110
+ }
111
+
112
+ if (parts[0] === "album" && parts[1] && parts[2] && parts[3]) {
113
+ const artist = artists.find((one) => one.server === parts[1] && one.key === parts[2]);
114
+ const album = artist?.albums.find((one) => one.key === parts[3]);
115
+ if (!artist || !album) return missing("album");
116
+ const image = album.cover ? { url: file(artist.server, `${artist.key}/${album.key}/cover.jpg`), alt: `${album.title} by ${artist.name}, the cover` } : pictureOf(artist);
117
+ const url = at(`/music/album/${artist.server}/${artist.key}/${album.key}`);
118
+ return {
119
+ status: 200,
120
+ head: {
121
+ title: `${album.title} by ${artist.name} · nixamp music`,
122
+ description: clip(album.concept || `${album.title}, ${album.songs.length} songs by ${artist.name} (${artist.genre})${album.year ? `, ${album.year}` : ""}. Listen free on nixamp.`, 200),
123
+ type: "music.album",
124
+ ...(image ? { image } : {}),
125
+ extra: [
126
+ ["music:musician", at(artistLink(artist))],
127
+ ...(album.year ? [["music:release_date", String(album.year)] as [string, string]] : []),
128
+ ...album.songs.slice(0, 30).map((song) => ["music:song", `${url}#${song.n}`] as [string, string]),
129
+ ],
130
+ ld: {
131
+ "@context": "https://schema.org", "@type": "MusicAlbum", name: album.title, url,
132
+ byArtist: { "@type": "MusicGroup", name: artist.name, url: at(artistLink(artist)) },
133
+ genre: artist.genre, numTracks: album.songs.length,
134
+ ...(album.year ? { datePublished: String(album.year) } : {}),
135
+ ...(image ? { image: image.url } : {}),
136
+ ...(album.concept ? { description: album.concept } : {}),
137
+ recordLabel: { "@type": "Organization", name: holder },
138
+ track: album.songs.map((song) => ({ "@type": "MusicRecording", name: song.title, position: song.n, ...(song.seconds ? { duration: `PT${Math.floor(song.seconds / 60)}M${Math.round(song.seconds % 60)}S` } : {}) })),
139
+ },
140
+ },
141
+ body: `<p><a href="/music">Music</a> / <a href="${artistLink(artist)}">${escape(artist.name)}</a></p><h1>${escape(album.title)}</h1>`
142
+ + `<p>by <a href="${artistLink(artist)}">${escape(artist.name)}</a>${album.year ? ` · ${album.year}` : ""} · ${album.songs.length} songs</p>`
143
+ + (album.concept ? `<p>${escape(album.concept)}</p>` : "")
144
+ + `<ol>${album.songs.map((song) => `<li>${escape(song.title)}${song.seconds ? ` <span>${clock(song.seconds)}</span>` : ""}</li>`).join("")}</ol>`,
145
+ };
146
+ }
147
+
148
+ if (parts.length > 0) return missing("page");
149
+ const lead = artists.find((one) => pictureOf(one));
150
+ return {
151
+ status: 200,
152
+ head: {
153
+ title: "AI music, live and on demand · nixamp",
154
+ description: clip(`Original bands and artists, written and sung by AI and released by ${holder}: ${catalogue.genres.map((one) => one.genre).join(", ")}. Listen free; follow the artists; buy the songs.`, 200),
155
+ type: "website",
156
+ ...(lead ? { image: pictureOf(lead)! } : {}),
157
+ },
158
+ body: `<h1>AI music, live and on demand</h1>${catalogue.genres.map((genre) => `<h2><a href="/music/genre/${slug(genre.genre)}">${escape(genre.genre)}</a></h2><ul>${genre.artists.map((one) => `<li><a href="${artistLink(one)}">${escape(one.name)}</a></li>`).join("")}</ul>`).join("")}`,
159
+ };
160
+ }
161
+
162
+ /** The shell with a page's head and summary written in. */
163
+ export function renderMusicPage(shell: string, page: MusicPage, url: string): string {
164
+ const { head } = page;
165
+ const meta: string[] = [
166
+ `<title>${escape(head.title)}</title>`,
167
+ `<meta name="description" content="${escape(head.description)}" />`,
168
+ `<link rel="canonical" href="${escape(url)}" />`,
169
+ ...(head.noindex ? [`<meta name="robots" content="noindex" />`] : []),
170
+ `<meta property="og:site_name" content="nixamp" />`,
171
+ `<meta property="og:title" content="${escape(head.title.replace(/ · nixamp( music)?$/, ""))}" />`,
172
+ `<meta property="og:description" content="${escape(head.description)}" />`,
173
+ `<meta property="og:type" content="${escape(head.type)}" />`,
174
+ `<meta property="og:url" content="${escape(url)}" />`,
175
+ ...(head.image ? [
176
+ `<meta property="og:image" content="${escape(head.image.url)}" />`,
177
+ `<meta property="og:image:alt" content="${escape(head.image.alt)}" />`,
178
+ `<meta name="twitter:card" content="summary_large_image" />`,
179
+ `<meta name="twitter:image" content="${escape(head.image.url)}" />`,
180
+ ] : [`<meta name="twitter:card" content="summary" />`]),
181
+ `<meta name="twitter:title" content="${escape(head.title)}" />`,
182
+ `<meta name="twitter:description" content="${escape(head.description)}" />`,
183
+ ...(head.extra ?? []).map(([property, content]) => `<meta property="${escape(property)}" content="${escape(content)}" />`),
184
+ ...(head.ld ? [`<script type="application/ld+json">${JSON.stringify(head.ld).replace(/</g, "\\u003c")}</script>`] : []),
185
+ ];
186
+ return shell
187
+ .replace(/<title>[^<]*<\/title>/, "")
188
+ .replace(/<meta name="description"[^>]*>/, "")
189
+ .replace("</head>", ` ${meta.join("\n ")}\n </head>`)
190
+ .replace(/(<main id="view"[^>]*>)(<\/main>)/, `$1${page.body}$2`);
191
+ }
@@ -476,7 +476,7 @@ export function renderMissing(what: string, base: string, nav: string): string {
476
476
  /** What a station page's head says: a station of ours or a peer's. */
477
477
  export interface StationFace { name: string; description?: string; video?: boolean }
478
478
 
479
- export function stationHead(station: StationFace, slug: string, base: string, path = `/radio/${slug}`): string {
479
+ export function stationHead(station: StationFace, slug: string, base: string, path = `/radio/${slug}`, image = ""): string {
480
480
  const url = `${base}${path}`;
481
481
  const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
482
482
  return `<title>${escape(station.name)} · live.streaming</title>
@@ -487,14 +487,22 @@ export function stationHead(station: StationFace, slug: string, base: string, pa
487
487
  <meta property="og:url" content="${escape(url)}" />
488
488
  <meta property="og:type" content="${station.video ? "video.tv_show" : "music.radio_station"}" />
489
489
  <meta property="og:site_name" content="live.streaming" />
490
- <meta name="twitter:card" content="summary" />
490
+ ${image ? `<meta property="og:image" content="${escape(image)}" />
491
+ <meta name="twitter:card" content="summary_large_image" />
492
+ <meta name="twitter:image" content="${escape(image)}" />` : `<meta name="twitter:card" content="summary" />`}
491
493
  <style>${NAV_STYLE}</style>`;
492
494
  }
493
495
 
496
+ /** A station's picture for link previews: the cover of an album it plays, from the catalogue. */
497
+ export function stationImage(catalogue: RadioCatalogue, slug: string, base: string): string {
498
+ const album = [...catalogue.albums.values()].find((one) => one.stations.includes(slug) && (one.coverFile || one.label?.cover));
499
+ return album ? `${base}/radio/album/${album.slug}/cover.jpg` : "";
500
+ }
501
+
494
502
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
495
- export function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string {
503
+ export function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string, image = ""): string {
496
504
  return html
497
- .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
505
+ .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path, image))
498
506
  .replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
499
507
  .replace(/<body>/, `<body>\n${nav}`);
500
508
  }