nixamp 0.60.0 → 0.60.2

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 (32) hide show
  1. package/dist/music-hub.d.ts +2 -0
  2. package/dist/music-hub.js +21 -5
  3. package/dist/music-seo.d.ts +38 -0
  4. package/dist/music-seo.js +160 -0
  5. package/dist/radio-pages.d.ts +4 -2
  6. package/dist/radio-pages.js +11 -4
  7. package/dist/server.js +17 -5
  8. package/package.json +1 -1
  9. package/src/music-hub.ts +23 -5
  10. package/src/music-seo.ts +191 -0
  11. package/src/radio-pages.ts +12 -4
  12. package/src/server.ts +12 -5
  13. package/web/dist/assets/{ar-jc2nfbwU.js → ar-9DPCOmaG.js} +1 -1
  14. package/web/dist/assets/{cs-CsQm5s-8.js → cs-Cav9Gs8C.js} +1 -1
  15. package/web/dist/assets/{da-DHCYXrSL.js → da-CiS3aLLY.js} +1 -1
  16. package/web/dist/assets/{de-IO9rajjn.js → de-Be0gdWMG.js} +1 -1
  17. package/web/dist/assets/{es-CIkJTUJE.js → es-CdRfYe8i.js} +1 -1
  18. package/web/dist/assets/{fi-JklvgzxQ.js → fi-C5UAFZtS.js} +1 -1
  19. package/web/dist/assets/{fr-CUk3yUOe.js → fr-B4qOxKuF.js} +1 -1
  20. package/web/dist/assets/{hi-CEdvI1Y5.js → hi-CSJ2RXel.js} +1 -1
  21. package/web/dist/assets/{hu-B_oRzY-F.js → hu-CQ5YyA1R.js} +1 -1
  22. package/web/dist/assets/{id-D5Wx0nsW.js → id-CjwGIPsf.js} +1 -1
  23. package/web/dist/assets/{it-DP4ncIUW.js → it-CLkcLBD0.js} +1 -1
  24. package/web/dist/assets/{main-D1zLWorp.js → main-CUpgrT90.js} +2 -2
  25. package/web/dist/assets/{nl-BSvol-ht.js → nl-OmbQRQTb.js} +1 -1
  26. package/web/dist/assets/{ru-35Ln-kD-.js → ru-DKzgZr9o.js} +1 -1
  27. package/web/dist/assets/{sv-D1DNLvXv.js → sv-ht1o-0P8.js} +1 -1
  28. package/web/dist/assets/{uk-B0fTytVz.js → uk-Kz3DHKMD.js} +1 -1
  29. package/web/dist/assets/{vi-6o60v8Th.js → vi-Cq8jzF8f.js} +1 -1
  30. package/web/dist/assets/{zh-CbbDoNoL.js → zh-D3FicqxI.js} +1 -1
  31. package/web/dist/index.html +1 -1
  32. package/web/dist/sw.js +19 -19
@@ -87,6 +87,8 @@ export interface MusicRoutesContext {
87
87
  readBody: (request: IncomingMessage) => Promise<string>;
88
88
  /** The page shell (web/music.html), or null when the web build is absent. */
89
89
  page: () => string | null;
90
+ /** This site's public origin, for canonical and og: addresses (https://nixamp.com). */
91
+ site?: string;
90
92
  /** A server says something of its label landed; null when nobody may say so. */
91
93
  released?: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{
92
94
  status: number;
package/dist/music-hub.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * POST /api/v1/music/released a server says an album or song landed: its followers hear
13
13
  */
14
14
  import { createHash } from "node:crypto";
15
+ import { musicPage, renderMusicPage } from "./music-seo.js";
15
16
  /** A server's opaque id: the first 10 hex of the SHA-256 of its https origin. Both sides can work it out. */
16
17
  export function serverId(base) {
17
18
  return createHash("sha256").update(base.replace(/\/+$/, "")).digest("hex").slice(0, 10);
@@ -221,13 +222,28 @@ export async function handleMusicRoutes(request, response, path, ctx) {
221
222
  }
222
223
  return true;
223
224
  }
224
- // /music and everything under it is the app; it reads /api/v1/music.
225
- const page = ctx.page();
226
- if (!page) {
225
+ // /music and everything under it is the app; it reads /api/v1/music. Its head and a
226
+ // plain summary are written here, per page (music-seo.ts), for crawlers and link
227
+ // previews; a catalogue that is slow or gone leaves the shell as it is.
228
+ const shell = ctx.page();
229
+ if (!shell) {
227
230
  ctx.json(response, 404, { error: "the music app is not built here" });
228
231
  return true;
229
232
  }
230
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
231
- response.end(method === "HEAD" ? undefined : page);
233
+ const site = (ctx.site ?? "https://nixamp.com").replace(/\/+$/, "");
234
+ const catalogue = await Promise.race([
235
+ ctx.hub.catalogue().catch(() => null),
236
+ new Promise((done) => setTimeout(() => done(null), 2_000).unref?.()),
237
+ ]);
238
+ // An empty catalogue (nixamp.com just restarted, no server has said hello yet) or
239
+ // one without the server a page names is not knowing, not "no such page": the
240
+ // shell as it is, never a 404 a crawler would drop the page for.
241
+ const servers = new Set(catalogue?.genres.flatMap((one) => one.artists.map((artist) => artist.server)) ?? []);
242
+ const named = /^\/music\/(?:artist|album)\/([a-f0-9]{10})\//.exec(path)?.[1];
243
+ const known = servers.size > 0 && (!named || servers.has(named));
244
+ const view = catalogue && known ? musicPage(path, catalogue, site) : null;
245
+ const html = view ? renderMusicPage(shell, view, `${site}${path.replace(/\/+$/, "") || "/music"}`) : shell;
246
+ response.writeHead(view?.status ?? 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
247
+ response.end(method === "HEAD" ? undefined : html);
232
248
  return true;
233
249
  }
@@ -0,0 +1,38 @@
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 { HubCatalogue } from "./music-hub.ts";
15
+ interface Head {
16
+ title: string;
17
+ description: string;
18
+ type: string;
19
+ image?: {
20
+ url: string;
21
+ alt: string;
22
+ };
23
+ noindex?: boolean;
24
+ /** og:music:* and similar, as [property, content]. */
25
+ extra?: Array<[string, string]>;
26
+ ld?: Record<string, unknown>;
27
+ }
28
+ /** What the server writes for a /music address: its head, and a summary for the page body. */
29
+ export interface MusicPage {
30
+ head: Head;
31
+ body: string;
32
+ status: number;
33
+ }
34
+ /** The head and summary of a /music address, from the catalogue. `site` is https://nixamp.com. */
35
+ export declare function musicPage(path: string, catalogue: HubCatalogue, site: string): MusicPage;
36
+ /** The shell with a page's head and summary written in. */
37
+ export declare function renderMusicPage(shell: string, page: MusicPage, url: string): string;
38
+ export {};
@@ -0,0 +1,160 @@
1
+ const slug = (text) => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2
+ const escape = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
+ const clip = (text, max) => {
4
+ const one = text.replace(/\s+/g, " ").trim();
5
+ return one.length <= max ? one : `${one.slice(0, max - 1).replace(/\s+\S*$/, "")}…`;
6
+ };
7
+ const clock = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
8
+ /** The head and summary of a /music address, from the catalogue. `site` is https://nixamp.com. */
9
+ export function musicPage(path, catalogue, site) {
10
+ const at = (rest) => `${site}${rest}`;
11
+ const file = (server, rest) => at(`/api/v1/music/file/${server}/${rest}`);
12
+ const artists = catalogue.genres.flatMap((genre) => genre.artists);
13
+ const holder = catalogue.holders[0] ?? "Profullstack, Inc.";
14
+ const parts = path.replace(/\/+$/, "").split("/").slice(2);
15
+ const pictureOf = (artist) => {
16
+ const album = [...artist.albums].reverse().find((one) => one.cover);
17
+ if (album)
18
+ return { url: file(artist.server, `${artist.key}/${album.key}/cover.jpg`), alt: `${album.title} by ${artist.name}, the cover` };
19
+ const member = artist.members?.[0];
20
+ return member ? { url: file(artist.server, `${artist.key}/members/${member.slug}.jpg`), alt: `${member.name} of ${artist.name}` } : undefined;
21
+ };
22
+ const artistLink = (artist) => `/music/artist/${artist.server}/${artist.key}`;
23
+ const missing = (what) => ({
24
+ status: 404,
25
+ head: { title: `No such ${what} · nixamp music`, description: `Nothing by that name is on nixamp music.`, type: "website", noindex: true },
26
+ body: `<h1>No such ${escape(what)}</h1><p><a href="/music">Every artist on nixamp music</a></p>`,
27
+ });
28
+ if (parts[0] === "me") {
29
+ return {
30
+ status: 200,
31
+ head: { title: "My music · nixamp", description: "The songs and albums you bought on nixamp, and the artists you follow.", type: "website", noindex: true },
32
+ body: "<h1>My music</h1><p>Sign in to see what you bought and who you follow.</p>",
33
+ };
34
+ }
35
+ if (parts[0] === "genre" && parts[1]) {
36
+ const genre = catalogue.genres.find((one) => slug(one.genre) === parts[1]);
37
+ if (!genre)
38
+ return missing("genre");
39
+ const names = genre.artists.map((one) => one.name);
40
+ const lead = genre.artists[0];
41
+ return {
42
+ status: 200,
43
+ head: {
44
+ title: `${genre.genre} · nixamp music`,
45
+ description: clip(`${genre.genre} by AI artists on nixamp: ${names.join(", ")}. Listen free on the live station; follow the artists; buy the songs.`, 200),
46
+ type: "website",
47
+ ...(lead && pictureOf(lead) ? { image: pictureOf(lead) } : {}),
48
+ },
49
+ 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>`,
50
+ };
51
+ }
52
+ if (parts[0] === "artist" && parts[1] && parts[2]) {
53
+ const artist = artists.find((one) => one.server === parts[1] && one.key === parts[2]);
54
+ if (!artist)
55
+ return missing("artist");
56
+ const image = pictureOf(artist);
57
+ const albums = artist.albums.filter((one) => one.songs.length > 0);
58
+ const members = artist.members ?? [];
59
+ return {
60
+ status: 200,
61
+ head: {
62
+ title: `${artist.name} · ${artist.genre} · nixamp music`,
63
+ description: clip(artist.bio || `${artist.name}, ${artist.genre} on nixamp: ${albums.length} album${albums.length === 1 ? "" : "s"}.`, 200),
64
+ type: "profile",
65
+ ...(image ? { image } : {}),
66
+ ld: {
67
+ "@context": "https://schema.org", "@type": "MusicGroup", name: artist.name, genre: artist.genre, url: at(artistLink(artist)),
68
+ ...(artist.bio ? { description: artist.bio } : {}),
69
+ ...(image ? { image: image.url } : {}),
70
+ ...(members.length ? { member: members.map((one) => ({ "@type": "Person", name: one.name, ...(one.role ? { roleName: one.role } : {}) })) } : {}),
71
+ 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) } : {}) })),
72
+ recordLabel: { "@type": "Organization", name: holder },
73
+ },
74
+ },
75
+ body: `<p><a href="/music">Music</a> / <a href="/music/genre/${slug(artist.genre)}">${escape(artist.genre)}</a></p><h1>${escape(artist.name)}</h1>`
76
+ + (artist.bio ? `<p>${escape(artist.bio)}</p>` : "")
77
+ + (members.length ? `<h2>Members</h2><ul>${members.map((one) => `<li>${escape(one.name)}${one.role ? `, ${escape(one.role)}` : ""}</li>`).join("")}</ul>` : "")
78
+ + (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>` : ""),
79
+ };
80
+ }
81
+ if (parts[0] === "album" && parts[1] && parts[2] && parts[3]) {
82
+ const artist = artists.find((one) => one.server === parts[1] && one.key === parts[2]);
83
+ const album = artist?.albums.find((one) => one.key === parts[3]);
84
+ if (!artist || !album)
85
+ return missing("album");
86
+ const image = album.cover ? { url: file(artist.server, `${artist.key}/${album.key}/cover.jpg`), alt: `${album.title} by ${artist.name}, the cover` } : pictureOf(artist);
87
+ const url = at(`/music/album/${artist.server}/${artist.key}/${album.key}`);
88
+ return {
89
+ status: 200,
90
+ head: {
91
+ title: `${album.title} by ${artist.name} · nixamp music`,
92
+ description: clip(album.concept || `${album.title}, ${album.songs.length} songs by ${artist.name} (${artist.genre})${album.year ? `, ${album.year}` : ""}. Listen free on nixamp.`, 200),
93
+ type: "music.album",
94
+ ...(image ? { image } : {}),
95
+ extra: [
96
+ ["music:musician", at(artistLink(artist))],
97
+ ...(album.year ? [["music:release_date", String(album.year)]] : []),
98
+ ...album.songs.slice(0, 30).map((song) => ["music:song", `${url}#${song.n}`]),
99
+ ],
100
+ ld: {
101
+ "@context": "https://schema.org", "@type": "MusicAlbum", name: album.title, url,
102
+ byArtist: { "@type": "MusicGroup", name: artist.name, url: at(artistLink(artist)) },
103
+ genre: artist.genre, numTracks: album.songs.length,
104
+ ...(album.year ? { datePublished: String(album.year) } : {}),
105
+ ...(image ? { image: image.url } : {}),
106
+ ...(album.concept ? { description: album.concept } : {}),
107
+ recordLabel: { "@type": "Organization", name: holder },
108
+ 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` } : {}) })),
109
+ },
110
+ },
111
+ body: `<p><a href="/music">Music</a> / <a href="${artistLink(artist)}">${escape(artist.name)}</a></p><h1>${escape(album.title)}</h1>`
112
+ + `<p>by <a href="${artistLink(artist)}">${escape(artist.name)}</a>${album.year ? ` · ${album.year}` : ""} · ${album.songs.length} songs</p>`
113
+ + (album.concept ? `<p>${escape(album.concept)}</p>` : "")
114
+ + `<ol>${album.songs.map((song) => `<li>${escape(song.title)}${song.seconds ? ` <span>${clock(song.seconds)}</span>` : ""}</li>`).join("")}</ol>`,
115
+ };
116
+ }
117
+ if (parts.length > 0)
118
+ return missing("page");
119
+ const lead = artists.find((one) => pictureOf(one));
120
+ return {
121
+ status: 200,
122
+ head: {
123
+ title: "AI music, live and on demand · nixamp",
124
+ 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),
125
+ type: "website",
126
+ ...(lead ? { image: pictureOf(lead) } : {}),
127
+ },
128
+ 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("")}`,
129
+ };
130
+ }
131
+ /** The shell with a page's head and summary written in. */
132
+ export function renderMusicPage(shell, page, url) {
133
+ const { head } = page;
134
+ const meta = [
135
+ `<title>${escape(head.title)}</title>`,
136
+ `<meta name="description" content="${escape(head.description)}" />`,
137
+ `<link rel="canonical" href="${escape(url)}" />`,
138
+ ...(head.noindex ? [`<meta name="robots" content="noindex" />`] : []),
139
+ `<meta property="og:site_name" content="nixamp" />`,
140
+ `<meta property="og:title" content="${escape(head.title.replace(/ · nixamp( music)?$/, ""))}" />`,
141
+ `<meta property="og:description" content="${escape(head.description)}" />`,
142
+ `<meta property="og:type" content="${escape(head.type)}" />`,
143
+ `<meta property="og:url" content="${escape(url)}" />`,
144
+ ...(head.image ? [
145
+ `<meta property="og:image" content="${escape(head.image.url)}" />`,
146
+ `<meta property="og:image:alt" content="${escape(head.image.alt)}" />`,
147
+ `<meta name="twitter:card" content="summary_large_image" />`,
148
+ `<meta name="twitter:image" content="${escape(head.image.url)}" />`,
149
+ ] : [`<meta name="twitter:card" content="summary" />`]),
150
+ `<meta name="twitter:title" content="${escape(head.title)}" />`,
151
+ `<meta name="twitter:description" content="${escape(head.description)}" />`,
152
+ ...(head.extra ?? []).map(([property, content]) => `<meta property="${escape(property)}" content="${escape(content)}" />`),
153
+ ...(head.ld ? [`<script type="application/ld+json">${JSON.stringify(head.ld).replace(/</g, "\\u003c")}</script>`] : []),
154
+ ];
155
+ return shell
156
+ .replace(/<title>[^<]*<\/title>/, "")
157
+ .replace(/<meta name="description"[^>]*>/, "")
158
+ .replace("</head>", ` ${meta.join("\n ")}\n </head>`)
159
+ .replace(/(<main id="view"[^>]*>)(<\/main>)/, `$1${page.body}$2`);
160
+ }
@@ -188,9 +188,11 @@ export interface StationFace {
188
188
  description?: string;
189
189
  video?: boolean;
190
190
  }
191
- export declare function stationHead(station: StationFace, slug: string, base: string, path?: string): string;
191
+ export declare function stationHead(station: StationFace, slug: string, base: string, path?: string, image?: string): string;
192
+ /** A station's picture for link previews: the cover of an album it plays, from the catalogue. */
193
+ export declare function stationImage(catalogue: RadioCatalogue, slug: string, base: string): string;
192
194
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
193
- export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string;
195
+ export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string, image?: string): string;
194
196
  /** A station another server lists on this guide (a guide peer), as /radio reaches it. */
195
197
  export interface PeerRadio {
196
198
  origin: string;
@@ -388,7 +388,7 @@ export function renderAlbum(catalogue, album, ctx) {
388
388
  export function renderMissing(what, base, nav) {
389
389
  return document({ title: `Not here · live.streaming`, description: "Nothing by that name is on the air here.", url: `${base}/radio` }, nav, `<h1>No ${escape(what)} by that name</h1><p class="lead">Nothing by that name is on the air here now. <a href="/radio">See every station</a>.</p>`);
390
390
  }
391
- export function stationHead(station, slug, base, path = `/radio/${slug}`) {
391
+ export function stationHead(station, slug, base, path = `/radio/${slug}`, image = "") {
392
392
  const url = `${base}${path}`;
393
393
  const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
394
394
  return `<title>${escape(station.name)} · live.streaming</title>
@@ -399,13 +399,20 @@ export function stationHead(station, slug, base, path = `/radio/${slug}`) {
399
399
  <meta property="og:url" content="${escape(url)}" />
400
400
  <meta property="og:type" content="${station.video ? "video.tv_show" : "music.radio_station"}" />
401
401
  <meta property="og:site_name" content="live.streaming" />
402
- <meta name="twitter:card" content="summary" />
402
+ ${image ? `<meta property="og:image" content="${escape(image)}" />
403
+ <meta name="twitter:card" content="summary_large_image" />
404
+ <meta name="twitter:image" content="${escape(image)}" />` : `<meta name="twitter:card" content="summary" />`}
403
405
  <style>${NAV_STYLE}</style>`;
404
406
  }
407
+ /** A station's picture for link previews: the cover of an album it plays, from the catalogue. */
408
+ export function stationImage(catalogue, slug, base) {
409
+ const album = [...catalogue.albums.values()].find((one) => one.stations.includes(slug) && (one.coverFile || one.label?.cover));
410
+ return album ? `${base}/radio/album/${album.slug}/cover.jpg` : "";
411
+ }
405
412
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
406
- export function asRadioPage(html, station, slug, base, nav, path) {
413
+ export function asRadioPage(html, station, slug, base, nav, path, image = "") {
407
414
  return html
408
- .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
415
+ .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path, image))
409
416
  .replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
410
417
  .replace(/<body>/, `<body>\n${nav}`);
411
418
  }
package/dist/server.js CHANGED
@@ -79,7 +79,7 @@ import { handleRoomProxy, handleRooms, isRoomPath, ROOM_PAGE, roomAddress, RoomP
79
79
  import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.js";
80
80
  import { withPitFooter } from "./pit-footer.js";
81
81
  import { handleFollowPage } from "./follow-page.js";
82
- import { asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, RADIO_BUY, radioBuyLink, radioBuyQuery, radioNav, radioPeers, radioRewriter, radioStations, renderAlbum, renderArtist, renderDirectory, renderMissing, rewriteResponse, } from "./radio-pages.js";
82
+ import { asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, RADIO_BUY, radioBuyLink, radioBuyQuery, radioNav, radioPeers, radioRewriter, radioStations, renderAlbum, stationImage, renderArtist, renderDirectory, renderMissing, rewriteResponse, } from "./radio-pages.js";
83
83
  import { adCsp, guideAds, guideCsp, withPageAd, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
84
84
  import { StationRunner } from "./station-runner.js";
85
85
  import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
@@ -2291,6 +2291,15 @@ export function createHandler(engine, options) {
2291
2291
  const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0].trim().replace(/:\d+$/, "").toLowerCase();
2292
2292
  const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
2293
2293
  (path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
2294
+ // nixamp.com's own /radio is the radio app (web/radio.html), not live.streaming's
2295
+ // station list; its other /radio pages name nixamp.com, not the box behind it.
2296
+ const onDirectory = (() => { try {
2297
+ return new URL(storeSite).host === requestHost;
2298
+ }
2299
+ catch {
2300
+ return false;
2301
+ } })();
2302
+ const radioPath = isRadioPath(path) && !(onDirectory && (path === "/radio" || path === "/radio/"));
2294
2303
  // The built web bundle is the same public code nixamp.com hands anyone;
2295
2304
  // a station's page loads it, and a station link carries no server key.
2296
2305
  const isPublicAsset = (request.method === "GET" || request.method === "HEAD") &&
@@ -2330,7 +2339,7 @@ export function createHandler(engine, options) {
2330
2339
  !((path === "/vod" || /^\/vod\/[A-Za-z0-9_-]{1,32}$/.test(path) || path === "/api/v1/guide/vods" || path.startsWith("/api/v1/guide/vods/")) && Boolean(options.stations?.vods)) &&
2331
2340
  !STATION_PAGE.test(path) &&
2332
2341
  // Public, listed stations under readable names, and their artists and albums (radio-pages.ts).
2333
- !(isRadioPath(path) && Boolean(options.stations)) &&
2342
+ !(radioPath && Boolean(options.stations)) &&
2334
2343
  // Following an artist from those pages, signed in at nixamp.com.
2335
2344
  !(path === "/follow" && Boolean(options.follows && options.accounts)) &&
2336
2345
  !isPublicAsset &&
@@ -2657,11 +2666,11 @@ export function createHandler(engine, options) {
2657
2666
  return;
2658
2667
  }
2659
2668
  }
2660
- if (options.stations && isRadioPath(path) && (request.method === "GET" || request.method === "HEAD")) {
2669
+ if (options.stations && radioPath && (request.method === "GET" || request.method === "HEAD")) {
2661
2670
  const stations = options.stations;
2662
2671
  const named = radioStations(stations.store.list());
2663
2672
  const publicBase = stations.publicBase?.() ?? "";
2664
- const base = radioBaseFor(request, publicBase);
2673
+ const base = onDirectory ? storeSite : radioBaseFor(request, publicBase);
2665
2674
  const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide");
2666
2675
  const send = (status, page, csp = RADIO_CSP) => {
2667
2676
  const ads = guideAds();
@@ -2765,7 +2774,9 @@ export function createHandler(engine, options) {
2765
2774
  const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html"), "utf8") : null;
2766
2775
  // The built page carries the one ad; the inline fallback stays as it is.
2767
2776
  const ads = built ? guideAds() : {};
2768
- const page = withPageAd(asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav), ads);
2777
+ // Its link preview carries the cover of an album it plays.
2778
+ const image = stationImage(radioCatalogue(), slug, base);
2779
+ const page = withPageAd(asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav, undefined, image), ads);
2769
2780
  response.writeHead(200, {
2770
2781
  "content-type": "text/html; charset=utf-8",
2771
2782
  "cache-control": "no-cache",
@@ -2888,6 +2899,7 @@ export function createHandler(engine, options) {
2888
2899
  catch {
2889
2900
  return null;
2890
2901
  } },
2902
+ site: storeSite,
2891
2903
  released: music.released,
2892
2904
  ...(music.veo ? { veo: music.veo } : {}),
2893
2905
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.60.0",
3
+ "version": "0.60.2",
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",
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 {
@@ -158,6 +159,8 @@ 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 }>;
163
166
  /** Veo shots for a label server's videos: the month's spend (GET), a clip (POST); answers bytes or JSON. */
@@ -215,10 +218,25 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
215
218
  }
216
219
  return true;
217
220
  }
218
- // /music and everything under it is the app; it reads /api/v1/music.
219
- const page = ctx.page();
220
- if (!page) { ctx.json(response, 404, { error: "the music app is not built here" }); return true; }
221
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
222
- 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
+ // An empty catalogue (nixamp.com just restarted, no server has said hello yet) or
232
+ // one without the server a page names is not knowing, not "no such page": the
233
+ // shell as it is, never a 404 a crawler would drop the page for.
234
+ const servers = new Set(catalogue?.genres.flatMap((one) => one.artists.map((artist) => artist.server)) ?? []);
235
+ const named = /^\/music\/(?:artist|album)\/([a-f0-9]{10})\//.exec(path)?.[1];
236
+ const known = servers.size > 0 && (!named || servers.has(named));
237
+ const view = catalogue && known ? musicPage(path, catalogue, site) : null;
238
+ const html = view ? renderMusicPage(shell, view, `${site}${path.replace(/\/+$/, "") || "/music"}`) : shell;
239
+ response.writeHead(view?.status ?? 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
240
+ response.end(method === "HEAD" ? undefined : html);
223
241
  return true;
224
242
  }
@@ -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
+ }