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.
- package/dist/ai-label.d.ts +37 -0
- package/dist/ai-label.js +127 -10
- package/dist/music-hub.d.ts +8 -0
- package/dist/music-hub.js +30 -6
- package/dist/music-seo.d.ts +38 -0
- package/dist/music-seo.js +160 -0
- package/dist/radio-pages.d.ts +4 -2
- package/dist/radio-pages.js +11 -4
- package/dist/server.d.ts +5 -0
- package/dist/server.js +81 -7
- package/dist/station-cli.js +2 -0
- package/dist/station-mcp.d.ts +30 -0
- package/dist/station-mcp.js +3 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +1 -0
- package/dist/veo-studio.d.ts +39 -0
- package/dist/veo-studio.js +110 -0
- package/package.json +1 -1
- package/src/ai-label.ts +128 -12
- package/src/music-hub.ts +29 -6
- package/src/music-seo.ts +191 -0
- package/src/radio-pages.ts +12 -4
- package/src/server.ts +62 -8
- package/src/station-cli.ts +2 -0
- package/src/station-mcp.ts +3 -1
- package/src/station-usage.ts +1 -0
- package/src/veo-studio.ts +123 -0
- package/web/dist/assets/{ar-D7L1Lzg5.js → ar-5jTau3by.js} +1 -1
- package/web/dist/assets/{cs-DR-oumcX.js → cs-nlVt79qU.js} +1 -1
- package/web/dist/assets/{da-B7ofIVod.js → da-C_mRyuJW.js} +1 -1
- package/web/dist/assets/{de-CglXPk_n.js → de-Duqxb30z.js} +1 -1
- package/web/dist/assets/{es-CYVNiI20.js → es-CAHgck_F.js} +1 -1
- package/web/dist/assets/{fi-Bktga6d_.js → fi-BPXwol2s.js} +1 -1
- package/web/dist/assets/{fr-CNxMDHg5.js → fr-C9N4WLtT.js} +1 -1
- package/web/dist/assets/{hi-DEyWHgib.js → hi-PWlH7Sya.js} +1 -1
- package/web/dist/assets/{hu-mcHUOvhY.js → hu-DHmawwq3.js} +1 -1
- package/web/dist/assets/{id-T9wfahs5.js → id-DwMuYDOT.js} +1 -1
- package/web/dist/assets/{it-CASLaiyC.js → it-DuVsDcAz.js} +1 -1
- package/web/dist/assets/{main-D9-BuE2r.js → main-BV2N47tZ.js} +2 -2
- package/web/dist/assets/{nl-DQ-YPUBS.js → nl-BcdvPtpE.js} +1 -1
- package/web/dist/assets/{ru-CDSDPqFZ.js → ru-CfMl9LlQ.js} +1 -1
- package/web/dist/assets/{sv-liuu51-p.js → sv-CDtolH7I.js} +1 -1
- package/web/dist/assets/{uk-B3vreqbA.js → uk-DwjjSnVU.js} +1 -1
- package/web/dist/assets/{vi-B0NyoUEc.js → vi-CNYK9Tlb.js} +1 -1
- package/web/dist/assets/{zh-Bw5OQASg.js → zh-6nGSjON5.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
package/dist/ai-label.d.ts
CHANGED
|
@@ -88,6 +88,10 @@ export interface LabelSettings {
|
|
|
88
88
|
songSeconds: number;
|
|
89
89
|
/** A music video for every song (the cover over itself, blurred, and the song's waveform), and a TV station of them. */
|
|
90
90
|
videos: boolean;
|
|
91
|
+
/** Each album's single (song 1) gets filmed Veo shots of the band, while the month's Veo budget lasts. */
|
|
92
|
+
veo: boolean;
|
|
93
|
+
/** How many 8 s Veo shots a single gets. */
|
|
94
|
+
veoShots: number;
|
|
91
95
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
92
96
|
publish: boolean;
|
|
93
97
|
/** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
|
|
@@ -160,6 +164,15 @@ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLin
|
|
|
160
164
|
export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
|
|
161
165
|
cast?: string;
|
|
162
166
|
}, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
|
|
167
|
+
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
168
|
+
export declare class VeoUnavailable extends Error {
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The prompts for a single's filmed shots. The people are the reference
|
|
172
|
+
* images, described by role and look only: Veo refuses names with faces
|
|
173
|
+
* ("we can't create videos with real people's names or likenesses").
|
|
174
|
+
*/
|
|
175
|
+
export declare function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[];
|
|
163
176
|
/**
|
|
164
177
|
* A song's music video from its storyboard: each scene's picture moves (a
|
|
165
178
|
* slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
|
|
@@ -173,6 +186,10 @@ export declare function renderStoryVideo(ffmpeg: string[], input: {
|
|
|
173
186
|
artist: string;
|
|
174
187
|
title: string;
|
|
175
188
|
out: string;
|
|
189
|
+
clips?: Array<{
|
|
190
|
+
path: string;
|
|
191
|
+
seconds: number;
|
|
192
|
+
}>;
|
|
176
193
|
}): Promise<void>;
|
|
177
194
|
/**
|
|
178
195
|
* A song's music video without a storyboard: its album cover over a blurred
|
|
@@ -210,6 +227,12 @@ export interface AiLabelDeps {
|
|
|
210
227
|
video?: (audio: string, cover: string, out: string) => Promise<void>;
|
|
211
228
|
/** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
|
|
212
229
|
align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
|
|
230
|
+
/** A filmed shot (Veo, through nixamp.com): the MP4, or a VeoUnavailable when the month's budget is spent. */
|
|
231
|
+
veo?: ((input: {
|
|
232
|
+
prompt: string;
|
|
233
|
+
seconds: number;
|
|
234
|
+
references: Buffer[];
|
|
235
|
+
}) => Promise<Buffer>) | null;
|
|
213
236
|
/** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
|
|
214
237
|
storyVideo?: (input: {
|
|
215
238
|
audio: string;
|
|
@@ -218,6 +241,10 @@ export interface AiLabelDeps {
|
|
|
218
241
|
artist: string;
|
|
219
242
|
title: string;
|
|
220
243
|
out: string;
|
|
244
|
+
clips?: Array<{
|
|
245
|
+
path: string;
|
|
246
|
+
seconds: number;
|
|
247
|
+
}>;
|
|
221
248
|
}) => Promise<void>;
|
|
222
249
|
/** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
|
|
223
250
|
image?: (prompt: string) => Promise<Buffer | null>;
|
|
@@ -250,6 +277,8 @@ export declare class AiLabel {
|
|
|
250
277
|
private videoFailed;
|
|
251
278
|
/** Songs whose words were already sent to the aligner since this server started. */
|
|
252
279
|
private alignTried;
|
|
280
|
+
/** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
|
|
281
|
+
private veoSpentIn;
|
|
253
282
|
/** Old videos already tried as storyboards since this server started. */
|
|
254
283
|
private upgradeTried;
|
|
255
284
|
/** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
|
|
@@ -308,6 +337,14 @@ export declare class AiLabel {
|
|
|
308
337
|
* image model can draw it (version 2), else the cover-and-waveform kind (1).
|
|
309
338
|
*/
|
|
310
339
|
private filmSong;
|
|
340
|
+
/** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
|
|
341
|
+
private wantsVeo;
|
|
342
|
+
/**
|
|
343
|
+
* A single's filmed shots: the band, from their portraits (up to three as
|
|
344
|
+
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
345
|
+
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
346
|
+
*/
|
|
347
|
+
private filmShots;
|
|
311
348
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
|
312
349
|
private placeVideos;
|
|
313
350
|
stopSchedule(): void;
|
package/dist/ai-label.js
CHANGED
|
@@ -272,6 +272,8 @@ export const DEFAULTS = {
|
|
|
272
272
|
songSeconds: 150,
|
|
273
273
|
publish: true,
|
|
274
274
|
videos: true,
|
|
275
|
+
veo: false,
|
|
276
|
+
veoShots: 4,
|
|
275
277
|
parallel: 1,
|
|
276
278
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
277
279
|
};
|
|
@@ -486,6 +488,33 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
|
|
|
486
488
|
throw new Error("the model wrote no storyboard");
|
|
487
489
|
return prompts;
|
|
488
490
|
}
|
|
491
|
+
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
492
|
+
export class VeoUnavailable extends Error {
|
|
493
|
+
}
|
|
494
|
+
const SHOTS = [
|
|
495
|
+
"Wide shot of the whole band performing on a stage, the crowd in the foreground",
|
|
496
|
+
"Close-up of the lead singer singing the chorus into the microphone",
|
|
497
|
+
"The camera moves across the stage between the band members as they play hard",
|
|
498
|
+
"Low-angle shot of the band at the song's climax, lights flaring behind them",
|
|
499
|
+
"Over-the-shoulder shot from behind the drums, looking out at the band and the crowd",
|
|
500
|
+
"Slow push-in on the band in a quieter moment, moody light",
|
|
501
|
+
"Handheld shot from the crowd, fans with hands up, the band lit on stage",
|
|
502
|
+
"The band walking out on stage before the show, backlit silhouettes turning into faces",
|
|
503
|
+
];
|
|
504
|
+
/**
|
|
505
|
+
* The prompts for a single's filmed shots. The people are the reference
|
|
506
|
+
* images, described by role and look only: Veo refuses names with faces
|
|
507
|
+
* ("we can't create videos with real people's names or likenesses").
|
|
508
|
+
*/
|
|
509
|
+
export function veoPrompts(artist, song, members, count) {
|
|
510
|
+
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("; ");
|
|
511
|
+
return SHOTS.slice(0, count).map((shot) => [
|
|
512
|
+
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
513
|
+
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
514
|
+
`Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
|
|
515
|
+
"Cinematic, 35mm film look, dynamic camera. No text, no logos.",
|
|
516
|
+
].filter(Boolean).join(" "));
|
|
517
|
+
}
|
|
489
518
|
/** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
|
|
490
519
|
function titleFont() {
|
|
491
520
|
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"]) {
|
|
@@ -518,8 +547,22 @@ function runLow(ffmpeg, args, timeout, what) {
|
|
|
518
547
|
*/
|
|
519
548
|
export function renderStoryVideo(ffmpeg, input) {
|
|
520
549
|
const fade = 1;
|
|
521
|
-
const
|
|
522
|
-
|
|
550
|
+
const clips = input.clips ?? [];
|
|
551
|
+
// Stills fill what the filmed shots do not; each gets at least 3 s, so on a short song some are left out.
|
|
552
|
+
let images = [...input.images];
|
|
553
|
+
const clipTime = clips.reduce((sum, one) => sum + one.seconds, 0);
|
|
554
|
+
const stillFor = (count) => (input.seconds + (count + clips.length - 1) * fade - clipTime) / Math.max(1, count);
|
|
555
|
+
while (images.length > 0 && stillFor(images.length) < 3)
|
|
556
|
+
images = images.slice(0, -1);
|
|
557
|
+
const each = images.length ? stillFor(images.length) : 0;
|
|
558
|
+
// A still, then a filmed shot, in turn; whatever is left of either at the end.
|
|
559
|
+
const order = [];
|
|
560
|
+
for (let i = 0; i < Math.max(images.length, clips.length); i++) {
|
|
561
|
+
if (images[i])
|
|
562
|
+
order.push({ kind: "image", path: images[i] });
|
|
563
|
+
if (clips[i])
|
|
564
|
+
order.push({ kind: "clip", ...clips[i] });
|
|
565
|
+
}
|
|
523
566
|
const frames = Math.max(25, Math.round(each * 25));
|
|
524
567
|
const moves = [
|
|
525
568
|
`z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
|
|
@@ -528,12 +571,26 @@ export function renderStoryVideo(ffmpeg, input) {
|
|
|
528
571
|
`z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
|
|
529
572
|
];
|
|
530
573
|
const parts = [];
|
|
531
|
-
|
|
532
|
-
|
|
574
|
+
const lengths = [];
|
|
575
|
+
let still = 0;
|
|
576
|
+
order.forEach((item, i) => {
|
|
577
|
+
if (item.kind === "image") {
|
|
578
|
+
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}]`);
|
|
579
|
+
lengths.push(each);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
// A filmed shot plays as it was filmed; the last one holds its final frame if the song runs on.
|
|
583
|
+
const hold = i === order.length - 1 ? `,tpad=stop_mode=clone:stop_duration=${Math.max(0, input.seconds).toFixed(2)}` : "";
|
|
584
|
+
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}]`);
|
|
585
|
+
lengths.push(item.seconds);
|
|
586
|
+
}
|
|
533
587
|
});
|
|
588
|
+
const count = order.length;
|
|
534
589
|
let last = "v0";
|
|
590
|
+
let at = 0;
|
|
535
591
|
for (let i = 1; i < count; i++) {
|
|
536
|
-
|
|
592
|
+
at += lengths[i - 1] - fade;
|
|
593
|
+
parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${at.toFixed(2)}[x${i}]`);
|
|
537
594
|
last = `x${i}`;
|
|
538
595
|
}
|
|
539
596
|
const font = titleFont();
|
|
@@ -542,7 +599,7 @@ export function renderStoryVideo(ffmpeg, input) {
|
|
|
542
599
|
? `[${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]`
|
|
543
600
|
: `[${last}]format=yuv420p[v]`);
|
|
544
601
|
const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
|
|
545
|
-
...
|
|
602
|
+
...order.flatMap((item) => (item.kind === "image" ? ["-loop", "1", "-t", each.toFixed(2), "-i", item.path] : ["-i", item.path])),
|
|
546
603
|
"-i", input.audio, "-filter_complex", parts.join(";"),
|
|
547
604
|
"-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
|
|
548
605
|
"-movflags", "+faststart+use_metadata_tags", input.out];
|
|
@@ -632,6 +689,8 @@ export class AiLabel {
|
|
|
632
689
|
videoFailed = new Set();
|
|
633
690
|
/** Songs whose words were already sent to the aligner since this server started. */
|
|
634
691
|
alignTried = new Set();
|
|
692
|
+
/** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
|
|
693
|
+
veoSpentIn = "";
|
|
635
694
|
/** Old videos already tried as storyboards since this server started. */
|
|
636
695
|
upgradeTried = new Set();
|
|
637
696
|
/** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
|
|
@@ -782,6 +841,11 @@ export class AiLabel {
|
|
|
782
841
|
this.settings.publish = input["publish"] !== false;
|
|
783
842
|
if (input["videos"] !== undefined)
|
|
784
843
|
this.settings.videos = input["videos"] !== false;
|
|
844
|
+
if (input["veo"] !== undefined)
|
|
845
|
+
this.settings.veo = input["veo"] === true;
|
|
846
|
+
const shots = whole("veoShots", 1, 8);
|
|
847
|
+
if (shots !== undefined)
|
|
848
|
+
this.settings.veoShots = shots;
|
|
785
849
|
const perDay = whole("albumsPerDay", 0, 24);
|
|
786
850
|
if (perDay !== undefined)
|
|
787
851
|
this.settings.albumsPerDay = perDay;
|
|
@@ -973,7 +1037,7 @@ export class AiLabel {
|
|
|
973
1037
|
.flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
|
|
974
1038
|
const missing = songs.filter(({ song }) => !song.video);
|
|
975
1039
|
const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
|
|
976
|
-
const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
|
|
1040
|
+
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));
|
|
977
1041
|
let made = 0;
|
|
978
1042
|
for (const { album, song } of untagged) {
|
|
979
1043
|
if (!this.settings.videos)
|
|
@@ -1062,15 +1126,68 @@ export class AiLabel {
|
|
|
1062
1126
|
this.log(`no storyboard for "${song.title}" (${error.message}); the cover video instead`);
|
|
1063
1127
|
}
|
|
1064
1128
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1129
|
+
// An album's single gets filmed shots of the band too, while the month's Veo budget lasts.
|
|
1130
|
+
const clips = this.wantsVeo(song) ? await this.filmShots(artist, song, scenes) : [];
|
|
1131
|
+
if (images.length >= 2 || clips.length > 0) {
|
|
1132
|
+
if (storyOnly && (song.videoVersion ?? 1) >= 2 && clips.length === 0)
|
|
1133
|
+
throw new Error("no filmed shots this time; the video it has stays");
|
|
1134
|
+
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 } : {}) });
|
|
1135
|
+
return clips.length ? 3 : 2;
|
|
1068
1136
|
}
|
|
1069
1137
|
if (storyOnly)
|
|
1070
1138
|
throw new Error("no storyboard could be drawn; the video it has stays");
|
|
1071
1139
|
await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
|
|
1072
1140
|
return 1;
|
|
1073
1141
|
}
|
|
1142
|
+
/** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
|
|
1143
|
+
wantsVeo(song) {
|
|
1144
|
+
return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* A single's filmed shots: the band, from their portraits (up to three as
|
|
1148
|
+
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
1149
|
+
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
1150
|
+
*/
|
|
1151
|
+
async filmShots(artist, song, dir) {
|
|
1152
|
+
const veo = this.deps.veo;
|
|
1153
|
+
if (!veo)
|
|
1154
|
+
return [];
|
|
1155
|
+
mkdirSync(dir, { recursive: true });
|
|
1156
|
+
const members = this.cast(artist);
|
|
1157
|
+
const references = [];
|
|
1158
|
+
for (const member of members.slice(0, 3)) {
|
|
1159
|
+
const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
|
|
1160
|
+
const small = join(dir, `ref-${member.slug}.jpg`);
|
|
1161
|
+
try {
|
|
1162
|
+
// 512 px is plenty for a face and keeps the request small.
|
|
1163
|
+
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");
|
|
1164
|
+
references.push(readFileSync(small));
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
// A member without a portrait is described in words only.
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
const clips = [];
|
|
1171
|
+
for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
|
|
1172
|
+
try {
|
|
1173
|
+
const bytes = await veo({ prompt, seconds: 8, references });
|
|
1174
|
+
const file = join(dir, `shot-${i}.mp4`);
|
|
1175
|
+
writeFileSync(file, bytes);
|
|
1176
|
+
clips.push({ path: file, seconds: 8 });
|
|
1177
|
+
}
|
|
1178
|
+
catch (error) {
|
|
1179
|
+
if (error instanceof VeoUnavailable) {
|
|
1180
|
+
this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
|
|
1181
|
+
this.log(`Veo: ${error.message}; singles get the storyboard until next month`);
|
|
1182
|
+
break;
|
|
1183
|
+
}
|
|
1184
|
+
this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (clips.length)
|
|
1188
|
+
this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1189
|
+
return clips;
|
|
1190
|
+
}
|
|
1074
1191
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
|
1075
1192
|
placeVideos() {
|
|
1076
1193
|
const next = this.placing.then(async () => {
|
package/dist/music-hub.d.ts
CHANGED
|
@@ -87,11 +87,19 @@ 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;
|
|
93
95
|
body: unknown;
|
|
94
96
|
}>;
|
|
97
|
+
/** Veo shots for a label server's videos: the month's spend (GET), a clip (POST); answers bytes or JSON. */
|
|
98
|
+
veo?: (request: IncomingMessage) => Promise<{
|
|
99
|
+
status: number;
|
|
100
|
+
body: unknown;
|
|
101
|
+
video?: Buffer;
|
|
102
|
+
}>;
|
|
95
103
|
}
|
|
96
104
|
export declare function handleMusicRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: MusicRoutesContext): Promise<boolean>;
|
|
97
105
|
export {};
|
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);
|
|
@@ -136,7 +137,7 @@ export async function musicPageFor(hub, path) {
|
|
|
136
137
|
return "";
|
|
137
138
|
}
|
|
138
139
|
export function isMusicPath(path) {
|
|
139
|
-
return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released";
|
|
140
|
+
return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
|
|
140
141
|
}
|
|
141
142
|
export async function handleMusicRoutes(request, response, path, ctx) {
|
|
142
143
|
if (!isMusicPath(path))
|
|
@@ -159,6 +160,20 @@ export async function handleMusicRoutes(request, response, path, ctx) {
|
|
|
159
160
|
ctx.json(response, answer.status, answer.body);
|
|
160
161
|
return true;
|
|
161
162
|
}
|
|
163
|
+
if (path === "/api/v1/music/veo") {
|
|
164
|
+
if (!ctx.veo) {
|
|
165
|
+
ctx.json(response, 404, { error: "Veo is not set up here" });
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
const answer = await ctx.veo(request);
|
|
169
|
+
if (answer.video) {
|
|
170
|
+
response.writeHead(200, { "content-type": "video/mp4", "content-length": String(answer.video.length), "cache-control": "no-store" });
|
|
171
|
+
response.end(answer.video);
|
|
172
|
+
}
|
|
173
|
+
else
|
|
174
|
+
ctx.json(response, answer.status, answer.body);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
162
177
|
if (method !== "GET" && method !== "HEAD") {
|
|
163
178
|
ctx.json(response, 405, { error: "not like that" });
|
|
164
179
|
return true;
|
|
@@ -207,13 +222,22 @@ export async function handleMusicRoutes(request, response, path, ctx) {
|
|
|
207
222
|
}
|
|
208
223
|
return true;
|
|
209
224
|
}
|
|
210
|
-
// /music and everything under it is the app; it reads /api/v1/music.
|
|
211
|
-
|
|
212
|
-
|
|
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) {
|
|
213
230
|
ctx.json(response, 404, { error: "the music app is not built here" });
|
|
214
231
|
return true;
|
|
215
232
|
}
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
const view = catalogue ? musicPage(path, catalogue, site) : null;
|
|
239
|
+
const html = view ? renderMusicPage(shell, view, `${site}${path.replace(/\/+$/, "") || "/music"}`) : shell;
|
|
240
|
+
response.writeHead(view?.status ?? 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
|
|
241
|
+
response.end(method === "HEAD" ? undefined : html);
|
|
218
242
|
return true;
|
|
219
243
|
}
|
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
}
|
package/dist/radio-pages.d.ts
CHANGED
|
@@ -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;
|
package/dist/radio-pages.js
CHANGED
|
@@ -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
|
-
|
|
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.d.ts
CHANGED
|
@@ -841,6 +841,11 @@ export interface HandlerOptions {
|
|
|
841
841
|
status: number;
|
|
842
842
|
body: unknown;
|
|
843
843
|
}>;
|
|
844
|
+
veo?: (request: IncomingMessage) => Promise<{
|
|
845
|
+
status: number;
|
|
846
|
+
body: unknown;
|
|
847
|
+
video?: Buffer;
|
|
848
|
+
}>;
|
|
844
849
|
};
|
|
845
850
|
sharedTranslations?: SharedTranslations;
|
|
846
851
|
/** Translation: texts in another language, by a model here. The same optional library. */
|