nixamp 0.52.2 → 0.53.3

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 (50) hide show
  1. package/dist/ai-label.d.ts +50 -4
  2. package/dist/ai-label.js +219 -35
  3. package/dist/channels.js +3 -0
  4. package/dist/playlist-input.d.ts +9 -1
  5. package/dist/playlist-input.js +10 -4
  6. package/dist/server.js +12 -6
  7. package/dist/station-routes.d.ts +2 -0
  8. package/dist/station-routes.js +5 -0
  9. package/dist/station-runner.d.ts +7 -0
  10. package/dist/station-runner.js +16 -2
  11. package/dist/stations.d.ts +13 -0
  12. package/dist/stations.js +36 -0
  13. package/package.json +1 -1
  14. package/src/ai-label.ts +216 -31
  15. package/src/channels.ts +2 -0
  16. package/src/playlist-input.ts +14 -8
  17. package/src/server.ts +12 -6
  18. package/src/station-routes.ts +7 -0
  19. package/src/station-runner.ts +18 -2
  20. package/src/stations.ts +35 -0
  21. package/web/dist/assets/{ar-Cu3HNStu.js → ar-DsQNv_tK.js} +1 -1
  22. package/web/dist/assets/{cs-uqfWO8Tj.js → cs-BWVuDS0X.js} +1 -1
  23. package/web/dist/assets/{da-D3ZenKKD.js → da-yMLNhkak.js} +1 -1
  24. package/web/dist/assets/{de-D7hSBOda.js → de-DVSMvq22.js} +1 -1
  25. package/web/dist/assets/{es-Dr-i4Jfe.js → es-B5xdyPjG.js} +1 -1
  26. package/web/dist/assets/{fi-DL7cLP_e.js → fi-_hVnUyE3.js} +1 -1
  27. package/web/dist/assets/{fr-C-_Cil5p.js → fr-ChOWYR7D.js} +1 -1
  28. package/web/dist/assets/{hi-BG6Nbvu1.js → hi-Dtw7Mrwj.js} +1 -1
  29. package/web/dist/assets/{hu-CO6uMAnx.js → hu-DSrmbSIn.js} +1 -1
  30. package/web/dist/assets/{id-D_0dB7Cc.js → id-BF5hzSrf.js} +1 -1
  31. package/web/dist/assets/{it-KC5QkD3e.js → it-Df8Yov7v.js} +1 -1
  32. package/web/dist/assets/{main-D8ga1_tc.js → main-CbkdPOhq.js} +2 -2
  33. package/web/dist/assets/{nl-DJtjRzKr.js → nl-DUHEGAjS.js} +1 -1
  34. package/web/dist/assets/{radio-Ct2xxni3.css → radio-CtAne8M1.css} +1 -1
  35. package/web/dist/assets/{radio-B8n_n-WD.js → radio-ZiViiR-a.js} +1 -1
  36. package/web/dist/assets/{ru-By0X_8As.js → ru-BTX4-kNd.js} +1 -1
  37. package/web/dist/assets/{station-Be-z7jQ2.css → station-Bqk5iV8L.css} +1 -1
  38. package/web/dist/assets/{station-CrAZxHcX.js → station-DUt85zQk.js} +1 -1
  39. package/web/dist/assets/{sv-Dt4z_CkV.js → sv-DT2zVu1w.js} +1 -1
  40. package/web/dist/assets/{uk-B39fMx80.js → uk-V9OBaE62.js} +1 -1
  41. package/web/dist/assets/{vi-BnlBuEHd.js → vi-DaV25yrA.js} +1 -1
  42. package/web/dist/assets/{zh-5rQ6L9ym.js → zh-BgJ6hib9.js} +1 -1
  43. package/web/dist/index.html +1 -1
  44. package/web/dist/radio.html +9 -4
  45. package/web/dist/station.html +6 -2
  46. package/web/dist/sw.js +26 -26
  47. package/web/dist/vod.html +2 -2
  48. package/web/dist/watch.html +2 -2
  49. /package/web/dist/assets/{vod-CrvTO3FN.js → vod-BSo-pl8M.js} +0 -0
  50. /package/web/dist/assets/{watch-itlvgIf8.js → watch-DBSszFQm.js} +0 -0
package/dist/stations.js CHANGED
@@ -284,6 +284,10 @@ export class StationStore {
284
284
  let listed = access.listed === undefined ? station.listed : access.listed === true;
285
285
  if (visibility !== "public")
286
286
  listed = false;
287
+ // Made private, a station forgets what was declared for it: going public
288
+ // again asks afresh, so a stale (or false) declaration cannot republish it.
289
+ if (visibility === "private" && !access.rights)
290
+ station.rights = null;
287
291
  if (access.rights) {
288
292
  const basis = access.rights.basis;
289
293
  if (typeof basis !== "string" || !RIGHTS_BASES.includes(basis)) {
@@ -360,6 +364,38 @@ export function shuffled(length, random = Math.random) {
360
364
  }
361
365
  return order;
362
366
  }
367
+ /**
368
+ * A radio shuffle: the whole list in a random order, with no artist twice in
369
+ * a row while another is left to play. A plain shuffle of a genre where a few
370
+ * artists have whole albums kept playing one artist in series. Each pick is
371
+ * an artist drawn by how many of their tracks are left, so the spread stays
372
+ * even to the end, then one of that artist's tracks at random.
373
+ */
374
+ export function spreadByArtist(files, artistOf, random = Math.random) {
375
+ const groups = new Map();
376
+ files.forEach((file, index) => {
377
+ const key = artistOf(file).trim().toLowerCase();
378
+ const group = groups.get(key);
379
+ if (group)
380
+ group.push(index);
381
+ else
382
+ groups.set(key, [index]);
383
+ });
384
+ const left = [...groups.entries()].map(([key, indexes]) => ({ key, indexes: shuffled(indexes.length, random).map((one) => indexes[one]) }));
385
+ const order = [];
386
+ let last = "";
387
+ let total = files.length;
388
+ while (total > 0) {
389
+ const others = left.filter((one) => one.indexes.length > 0 && one.key !== last);
390
+ const pool = others.length > 0 ? others : left.filter((one) => one.indexes.length > 0);
391
+ let pick = random() * pool.reduce((sum, one) => sum + one.indexes.length, 0);
392
+ const group = pool.find((one) => (pick -= one.indexes.length) < 0) ?? pool[pool.length - 1];
393
+ order.push(group.indexes.pop());
394
+ last = group.key;
395
+ total -= 1;
396
+ }
397
+ return order;
398
+ }
363
399
  /**
364
400
  * Restart after an unexpected end: 2 s, doubling, at most a minute apart.
365
401
  * After the last of these the station is offline but still wanted, and is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.52.2",
3
+ "version": "0.53.3",
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/ai-label.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { execFile } from "node:child_process";
30
30
  import { randomBytes } from "node:crypto";
31
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
31
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
32
32
  import { dirname, join, resolve, sep } from "node:path";
33
33
  import { aiKey } from "./ai-keys.ts";
34
34
  import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
@@ -51,6 +51,10 @@ export interface LabelSong {
51
51
  lyricsFile: string;
52
52
  /** Its music video (001. <title>.mp4), "" until made; absent on songs from before videos. */
53
53
  video?: string;
54
+ /** Whether the video carries its song's tags (videos from 0.52.1 and before do not, until retagged). */
55
+ videoTagged?: boolean;
56
+ /** 2 for a storyboard video (scenes that move); absent or 1 for the cover-and-waveform kind, which is made again. */
57
+ videoVersion?: number;
54
58
  /** Who wrote the words and who sang them, e.g. "llama-3.3 + elevenlabs/music_v2_5". */
55
59
  made: string;
56
60
  composedAt: number;
@@ -518,11 +522,98 @@ export async function workersAiCover(prompt: string, env: Record<string, string
518
522
  return image;
519
523
  }
520
524
 
521
- /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
525
+ const STORYBOARD_SYSTEM = [
526
+ "You storyboard music videos for original songs. Write one image prompt per scene, in order, following the song's story and mood.",
527
+ "Keep the same main character(s), setting and color palette across scenes so it reads as one video; vary the shots (wide, close-up, action, detail).",
528
+ "Each prompt: cinematic, photorealistic or stylized to suit the genre, at most 300 characters. No text, letters, logos, real people or real brands.",
529
+ "Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
530
+ ].join(" ");
531
+
532
+ /** Image prompts for a song's video, one a scene, from its words and mood. */
533
+ export async function writeStoryboard(
534
+ artist: Pick<LabelArtist, "name" | "genre" | "sound">,
535
+ album: Pick<LabelAlbum, "title" | "concept">,
536
+ song: Pick<LabelSong, "title" | "style" | "lyrics">,
537
+ scenes: number,
538
+ ask: typeof askJson = askJson,
539
+ ): Promise<string[]> {
540
+ const answer = await ask({
541
+ system: STORYBOARD_SYSTEM,
542
+ prompt: [
543
+ `Song: "${song.title}" by ${artist.name} (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
544
+ `Mood and arrangement: ${song.style}`,
545
+ song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
546
+ `Exactly ${scenes} scenes.`,
547
+ ].filter(Boolean).join("\n"),
548
+ maxTokens: 2500,
549
+ timeoutMs: 90_000,
550
+ });
551
+ const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
552
+ const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
553
+ if (prompts.length < 2) throw new Error("the model wrote no storyboard");
554
+ return prompts;
555
+ }
556
+
557
+ /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
558
+ function titleFont(): string {
559
+ 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"]) {
560
+ try { if (statSync(file).isFile()) return file; } catch { /* next */ }
561
+ }
562
+ return "";
563
+ }
564
+
565
+ /** ffmpeg at the lowest priority, so the stations it shares the machine with come first. */
566
+ function runLow(ffmpeg: string[], args: string[], timeout: number, what: string): Promise<void> {
567
+ const nice = process.platform === "linux" ? (() => { try { return statSync("/usr/bin/nice").isFile(); } catch { return false; } })() : false;
568
+ const [bin, rest] = nice ? ["/usr/bin/nice", ["-n", "19", ffmpeg[0]!, ...args]] : [ffmpeg[0]!, args];
569
+ return new Promise((done, fail) => {
570
+ execFile(bin, rest, { timeout, maxBuffer: 4 * 1024 * 1024 }, (error) => (error ? fail(new Error(`${what}: ${error.message}`)) : done()));
571
+ });
572
+ }
573
+
574
+ /**
575
+ * A song's music video from its storyboard: each scene's picture moves (a
576
+ * slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
577
+ * the artist and title come up at the start, and the MP3's audio and tags
578
+ * (title, artist, album, words) are copied in. 720p H.264.
579
+ */
580
+ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }): Promise<void> {
581
+ const fade = 1;
582
+ const count = input.images.length;
583
+ const each = input.seconds / count + fade;
584
+ const frames = Math.max(25, Math.round(each * 25));
585
+ const moves = [
586
+ `z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
587
+ `z='1.15':x='(iw-iw/zoom)*on/${frames}':y='ih/2-(ih/zoom/2)'`,
588
+ `z='1.15-0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
589
+ `z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
590
+ ];
591
+ const parts: string[] = [];
592
+ input.images.forEach((_, i) => {
593
+ 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}]`);
594
+ });
595
+ let last = "v0";
596
+ for (let i = 1; i < count; i++) {
597
+ parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${(i * (each - fade)).toFixed(2)}[x${i}]`);
598
+ last = `x${i}`;
599
+ }
600
+ const font = titleFont();
601
+ const card = `${input.artist} — ${input.title}`.replace(/[\\':%]/g, " ").slice(0, 80);
602
+ parts.push(font
603
+ ? `[${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]`
604
+ : `[${last}]format=yuv420p[v]`);
605
+ const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
606
+ ...input.images.flatMap((image) => ["-loop", "1", "-t", each.toFixed(2), "-i", image]),
607
+ "-i", input.audio, "-filter_complex", parts.join(";"),
608
+ "-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
609
+ "-movflags", "+faststart+use_metadata_tags", input.out];
610
+ return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
611
+ }
612
+
522
613
  /**
523
- * A song's music video: its album cover over a blurred copy of itself, the
524
- * song's waveform along the bottom, 720p H.264 with the MP3's audio copied in.
525
- * Without a cover, a dark plate.
614
+ * A song's music video without a storyboard: its album cover over a blurred
615
+ * copy of itself, the song's waveform along the bottom, 720p H.264 with the
616
+ * MP3's audio and tags copied in. Without a cover, a dark plate.
526
617
  */
527
618
  export function renderVideo(ffmpeg: string[], audio: string, cover: string, out: string): Promise<void> {
528
619
  const picture = cover ? ["-loop", "1", "-framerate", "25", "-i", cover] : ["-f", "lavfi", "-i", "color=c=0x0d1410:s=1280x720:r=25"];
@@ -534,12 +625,21 @@ export function renderVideo(ffmpeg: string[], audio: string, cover: string, out:
534
625
  "[a][viz]overlay=0:H-160,format=yuv420p[v]",
535
626
  ].join(";");
536
627
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", ...picture, "-i", audio, "-filter_complex", graph,
537
- "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest", "-movflags", "+faststart", out];
628
+ "-map", "[v]", "-map", "1:a", "-map_metadata", "1", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest",
629
+ "-movflags", "+faststart+use_metadata_tags", out];
630
+ return runLow(ffmpeg, args, 20 * 60_000, "the video failed");
631
+ }
632
+
633
+ /** A video made before videos carried tags, given its song's: streams copied, nothing re-encoded. */
634
+ export function retagVideo(ffmpeg: string[], video: string, audio: string, out: string): Promise<void> {
635
+ const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", video, "-i", audio,
636
+ "-map", "0:v", "-map", "0:a", "-map_metadata", "1", "-c", "copy", "-movflags", "+faststart+use_metadata_tags", out];
538
637
  return new Promise((done, fail) => {
539
- execFile(ffmpeg[0]!, args, { timeout: 20 * 60_000 }, (error) => (error ? fail(new Error(`the video failed: ${error.message}`)) : done()));
638
+ execFile(ffmpeg[0]!, args, { timeout: 5 * 60_000 }, (error) => (error ? fail(new Error(`retagging the video failed: ${error.message}`)) : done()));
540
639
  });
541
640
  }
542
641
 
642
+ /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
543
643
  function tagFile(ffmpeg: string[], from: string, to: string, tags: Record<string, string>, cover: string): Promise<void> {
544
644
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
545
645
  if (cover) args.push("-i", cover, "-map", "0:a", "-map", "1:v", "-c", "copy", "-disposition:v", "attached_pic");
@@ -574,6 +674,10 @@ export interface AiLabelDeps {
574
674
  tag?: (from: string, to: string, tags: Record<string, string>, cover: string) => Promise<void>;
575
675
  /** Renders a song's music video to `out`; ffmpeg by default (renderVideo). */
576
676
  video?: (audio: string, cover: string, out: string) => Promise<void>;
677
+ /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
678
+ storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }) => Promise<void>;
679
+ /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
680
+ image?: (prompt: string) => Promise<Buffer | null>;
577
681
  /** The day's pick (artist, genre, new or not); Math.random by default. */
578
682
  random?: () => number;
579
683
  now?: () => number;
@@ -596,6 +700,8 @@ export class AiLabel {
596
700
  private filming: Promise<number> | null = null;
597
701
  /** Songs whose video failed since this server started. */
598
702
  private videoFailed = new Set<string>();
703
+ /** Old videos already tried as storyboards since this server started. */
704
+ private upgradeTried = new Set<string>();
599
705
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
600
706
  private dailyTriedAt = 0;
601
707
  /** Genre stations are kept in step one change at a time. */
@@ -865,37 +971,104 @@ export class AiLabel {
865
971
  return total;
866
972
  }
867
973
 
974
+ /**
975
+ * One pass: songs without a video first, then old videos given their
976
+ * song's tags (seconds each), then old cover-and-waveform videos made again
977
+ * as storyboards.
978
+ */
868
979
  private async filmPass(): Promise<number> {
980
+ const songs = this.list().flatMap((artist) => artist.albums
981
+ .filter((album) => album.cover || album.coverTried)
982
+ .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
983
+ const missing = songs.filter(({ song }) => !song.video);
984
+ const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
985
+ const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
869
986
  let made = 0;
870
- {
871
- for (const artist of this.list()) {
872
- for (const album of artist.albums) {
873
- if (!album.cover && !album.coverTried) continue;
874
- for (const song of album.songs) {
875
- if (!song.file || song.video || this.videoFailed.has(song.file) || !this.settings.videos) continue;
876
- const out = join(album.folder, `${songBase(song)}.mp4`);
877
- const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
878
- try {
879
- await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, partial);
880
- renameSync(partial, out);
881
- song.video = out;
882
- made += 1;
883
- this.save();
884
- // On the air as soon as it is made, not after the whole pass.
885
- await this.placeVideos().catch(() => undefined);
886
- } catch (error) {
887
- this.videoFailed.add(song.file);
888
- this.log(`no video for "${song.title}": ${(error as Error).message}`);
889
- } finally {
890
- rmSync(partial, { force: true });
891
- }
892
- }
893
- }
987
+ for (const { album, song } of untagged) {
988
+ if (!this.settings.videos) break;
989
+ // Made before videos carried tags: give it its song's, in place.
990
+ const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.retag.mp4`);
991
+ try {
992
+ await retagVideo(this.deps.ffmpeg, song.video!, song.file, partial);
993
+ renameSync(partial, song.video!);
994
+ song.videoTagged = true;
995
+ made += 1;
996
+ this.save();
997
+ } catch (error) {
998
+ this.log(`could not tag the video of "${song.title}": ${(error as Error).message}`);
999
+ song.videoTagged = true; // a storyboard remake brings the tags anyway
1000
+ } finally {
1001
+ rmSync(partial, { force: true });
1002
+ }
1003
+ }
1004
+ for (const { artist, album, song } of [...missing, ...old]) {
1005
+ if (!this.settings.videos) break;
1006
+ // An old video is only made again as a storyboard, and tried once a run.
1007
+ const upgrade = Boolean(song.video);
1008
+ if (upgrade) this.upgradeTried.add(song.file);
1009
+ const out = join(album.folder, `${songBase(song)}.mp4`);
1010
+ const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
1011
+ const scenes = join(album.folder, `.scenes-${song.n}-${randomBytes(3).toString("hex")}`);
1012
+ try {
1013
+ song.videoVersion = await this.filmSong(artist, album, song, scenes, partial, upgrade);
1014
+ renameSync(partial, out);
1015
+ song.video = out;
1016
+ song.videoTagged = true;
1017
+ made += 1;
1018
+ this.save();
1019
+ // On the air as soon as it is made, not after the whole pass.
1020
+ await this.placeVideos().catch(() => undefined);
1021
+ } catch (error) {
1022
+ if (!upgrade) this.videoFailed.add(song.file);
1023
+ this.log(`no ${upgrade ? "storyboard " : ""}video for "${song.title}": ${(error as Error).message}`);
1024
+ } finally {
1025
+ rmSync(partial, { force: true });
1026
+ rmSync(scenes, { recursive: true, force: true });
894
1027
  }
895
1028
  }
896
1029
  return made;
897
1030
  }
898
1031
 
1032
+ /**
1033
+ * One song's video: a storyboard of scenes when a model can write it and an
1034
+ * image model can draw it (version 2), else the cover-and-waveform kind (1).
1035
+ */
1036
+ private async filmSong(artist: LabelArtist, album: LabelAlbum, song: LabelSong, scenes: string, out: string, storyOnly = false): Promise<number> {
1037
+ let seconds = song.seconds;
1038
+ try {
1039
+ const probed = await this.deps.probe(song.file);
1040
+ if (probed.duration > 5) seconds = probed.duration;
1041
+ } catch { /* the asked-for length */ }
1042
+ const images: string[] = [];
1043
+ if (this.canAsk()) {
1044
+ try {
1045
+ const count = Math.max(4, Math.min(16, Math.round(seconds / 9)));
1046
+ const prompts = await writeStoryboard(artist, album, song, count, this.deps.ask ?? askJson);
1047
+ mkdirSync(scenes, { recursive: true });
1048
+ const draw = this.deps.image ?? ((prompt: string) => workersAiCover(prompt));
1049
+ // A few at a time: the image model is quick, the machine is shared.
1050
+ for (let i = 0; i < prompts.length; i += 3) {
1051
+ const drawn = await Promise.all(prompts.slice(i, i + 3).map((prompt) => draw(`${prompt} Cinematic 16:9 music video still, no text.`).catch(() => null)));
1052
+ drawn.forEach((picture, j) => {
1053
+ if (!picture) return;
1054
+ const file = join(scenes, `${String(i + j).padStart(2, "0")}.jpg`);
1055
+ writeFileSync(file, picture);
1056
+ images.push(file);
1057
+ });
1058
+ }
1059
+ } catch (error) {
1060
+ this.log(`no storyboard for "${song.title}" (${(error as Error).message}); the cover video instead`);
1061
+ }
1062
+ }
1063
+ if (images.length >= 2) {
1064
+ await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out });
1065
+ return 2;
1066
+ }
1067
+ if (storyOnly) throw new Error("no storyboard could be drawn; the video it has stays");
1068
+ await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
1069
+ return 1;
1070
+ }
1071
+
899
1072
  /** The label's music videos as one TV station: free to watch, with its breaks. */
900
1073
  private placeVideos(): Promise<void> {
901
1074
  const next = this.placing.then(async () => {
@@ -1257,6 +1430,18 @@ export class AiLabel {
1257
1430
  return null;
1258
1431
  }
1259
1432
 
1433
+ /** The song's MP3 when `file` is one of the label's music videos (it holds the tags, cover and words); else `file`. */
1434
+ audioFor(file: string): string {
1435
+ if (!file.endsWith(".mp4")) return file;
1436
+ for (const artist of this.artists.values()) {
1437
+ for (const album of artist.albums) {
1438
+ const song = album.songs.find((one) => one.video === file);
1439
+ if (song) return song.file;
1440
+ }
1441
+ }
1442
+ return file;
1443
+ }
1444
+
1260
1445
  /** A released song's MP3, when this grant's scope covers it. */
1261
1446
  songFile(artistKey: string, albumKey: string, n: number): string {
1262
1447
  return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
package/src/channels.ts CHANGED
@@ -586,6 +586,8 @@ export class Channel {
586
586
  const seconds = Number(match[1]) / 1e6;
587
587
  if (playlistInput) {
588
588
  const place = playlistInput.place(seconds);
589
+ // A break (-1) belongs to no list; a track's index belongs to its own.
590
+ if (place.at >= 0 && this.info.playlist !== place.list) this.info.playlist = place.list as string[];
589
591
  this.info.playlistAt = place.at;
590
592
  this.info.position = place.position;
591
593
  } else this.info.position = from + seconds;
@@ -22,7 +22,7 @@ export interface PlaylistInput {
22
22
  * pass: a station-wide shuffle is one order for everybody listening, drawn
23
23
  * again each time round. Unset is the list's own order.
24
24
  */
25
- order?: (length: number) => number[];
25
+ order?: (length: number, files: readonly string[]) => number[];
26
26
  /** Read at every entry boundary, so an edit to the list lands on the next track. */
27
27
  current?: () => string[];
28
28
  /** Entries already known not to play, passed over without being tried. */
@@ -56,7 +56,7 @@ export const MAX_FAILURES_IN_A_ROW = 5;
56
56
  * whose input codecs differ. Only one entry is encoded at a time, with pipe
57
57
  * backpressure bounding read-ahead independently of the directory's size. */
58
58
  export class PlaylistInputStream {
59
- private entries: { at: number; file: string; start: number; position: number }[] = [];
59
+ private entries: { at: number; file: string; start: number; position: number; list: readonly string[] }[] = [];
60
60
  error: Error | null = null;
61
61
  complete = false;
62
62
  /**
@@ -68,14 +68,20 @@ export class PlaylistInputStream {
68
68
  constructor(private readonly options: PlaylistInput) {}
69
69
 
70
70
  /** Every entry that has started, in the order it started, with where it sits in the stream. */
71
- get history(): ReadonlyArray<{ at: number; file: string; start: number; position: number }> {
71
+ get history(): ReadonlyArray<{ at: number; file: string; start: number; position: number; list: readonly string[] }> {
72
72
  return this.entries;
73
73
  }
74
74
 
75
- place(seconds: number): { at: number; position: number } {
75
+ /**
76
+ * What is on at this point of the stream. `at` indexes `list`, the list as
77
+ * it was when that entry started: a station re-reads its list at every
78
+ * boundary, so after the library changes the index no longer fits the
79
+ * list the channel was dialled with.
80
+ */
81
+ place(seconds: number): { at: number; position: number; list: readonly string[] } {
76
82
  const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
77
- return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
78
- : { at: this.options.at, position: this.options.position };
83
+ return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position), list: entry.list }
84
+ : { at: this.options.at, position: this.options.position, list: this.options.files };
79
85
  }
80
86
 
81
87
  /** The decoder for the entry that is on, so skip() can end it early. */
@@ -101,7 +107,7 @@ export class PlaylistInputStream {
101
107
  let first = true;
102
108
  try {
103
109
  const orderFor = (list: string[]): number[] =>
104
- o.order ? o.order(list.length).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
110
+ o.order ? o.order(list.length, list).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
105
111
  let files = o.current?.() ?? o.files;
106
112
  if (files.length === 0) throw new Error("The playlist is empty");
107
113
  let order = orderFor(files);
@@ -173,7 +179,7 @@ export class PlaylistInputStream {
173
179
  const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
174
180
  const cut = room >= 1 && full - room > 5;
175
181
  const duration = cut ? room : full;
176
- this.entries.push({ at, file, start: offset, position });
182
+ this.entries.push({ at, file, start: offset, position, list: files });
177
183
  const hasVideo = Boolean(codecs.video);
178
184
  const extra = o.video && !hasVideo
179
185
  ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
package/src/server.ts CHANGED
@@ -3165,6 +3165,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3165
3165
  },
3166
3166
  trackFor: (file) => trackFor(engine, file),
3167
3167
  ...(options.library ? { isVideo: (file: string) => options.library!.index.itemAt(file)?.video === true } : {}),
3168
+ ...(options.library ? { durationOf: (file: string) => options.library!.index.itemAt(file)?.duration ?? 0 } : {}),
3168
3169
  base: `${scheme}://${request.headers.host ?? "localhost"}`,
3169
3170
  publicBase: options.stations.publicBase?.() ?? "",
3170
3171
  json,
@@ -7211,6 +7212,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7211
7212
  breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
7212
7213
  onRestart: (id) => stationUsage.restarted(id),
7213
7214
  onFailure: (id) => stationUsage.failed(id),
7215
+ artistOf: (file) => trackFor(engine, file)?.artist ?? "",
7214
7216
  store: stationStore,
7215
7217
  channels,
7216
7218
  pull: async (channelId, name, files, known) =>
@@ -7370,7 +7372,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7370
7372
  stations.holders = holderStore;
7371
7373
  // Covers read once per file (and its size and time), a few hundred kept.
7372
7374
  const covers = new Map<string, { key: string; picture: { bytes: Buffer; type: string } | null }>();
7373
- stations.cover = async (file) => {
7375
+ stations.cover = async (playing) => {
7376
+ // A label music video's cover is its song's.
7377
+ const file = aiLabel.audioFor(playing);
7374
7378
  let key = "";
7375
7379
  try { const info = statSync(file); key = `${info.size}:${info.mtimeMs}`; } catch { return null; }
7376
7380
  const hit = covers.get(file);
@@ -7383,7 +7387,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7383
7387
  };
7384
7388
  // The words of what is on: the file's own tag, else nichedb.dev's.
7385
7389
  const lyricsFinder = new LyricsFinder({ tags: (file) => tagReader.read(file), known: (file) => trackFor(engine, file) });
7386
- stations.lyrics = (_station, file) => lyricsFinder.find(file);
7390
+ stations.lyrics = (_station, file) => lyricsFinder.find(aiLabel.audioFor(file));
7387
7391
  // Our own songs' words, back to nichedb, when a key is set: on any station
7388
7392
  // change, after a library scan, and once at start.
7389
7393
  const lyricsPublisher = new LyricsPublisher({
@@ -7409,9 +7413,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7409
7413
  let stamp = "";
7410
7414
  try { stamp = String(Math.round(statSync(file).mtimeMs)); } catch { stamp = ""; }
7411
7415
  const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
7412
- const words = await lyricsFinder.find(file).catch(() => null);
7416
+ // A label music video is told about as its song: the MP3 holds the tags and the words.
7417
+ const audio = aiLabel.audioFor(file);
7418
+ const words = await lyricsFinder.find(audio).catch(() => null);
7413
7419
  // A label song is for sale on nixamp.com: the song, its album, everything by the artist.
7414
- const song = aiLabel.songAt(file);
7420
+ const song = aiLabel.songAt(audio);
7415
7421
  const here = stations.publicBase?.() ?? "";
7416
7422
  const shop = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
7417
7423
  const store = song && /^https:\/\//.test(here) ? {
@@ -7425,8 +7431,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7425
7431
  art,
7426
7432
  ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
7427
7433
  station, file,
7428
- tags: await tagReader.read(file),
7429
- known: trackFor(engine, file),
7434
+ tags: await tagReader.read(audio),
7435
+ known: trackFor(engine, audio),
7430
7436
  holders: holderStore.list(),
7431
7437
  share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
7432
7438
  aiTrack: composed ? { made: composed.made } : null,
@@ -66,6 +66,8 @@ export interface StationContext {
66
66
  throttle?: () => { retryAfter: number } | null;
67
67
  /** What a listener may know about the track on the air (track-facts.ts). */
68
68
  facts?: (station: Station, file: string) => Promise<TrackFacts>;
69
+ /** How long a file is, in seconds (the library's probe); 0 when unknown. For the page's progress bar. */
70
+ durationOf?: (file: string) => number;
69
71
  /** The cover picture inside a file (JPEG/PNG bytes), or null. */
70
72
  cover?: (file: string) => Promise<{ bytes: Buffer; type: string } | null>;
71
73
  /** The words of a file, from its tags or nichedb; null when none are known. */
@@ -116,6 +118,10 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
116
118
  // What the channel is carrying wins over the flag: a station made by hand
117
119
  // from films streams video whether or not anyone said so.
118
120
  const video = station.video || ctx.channels.kindOf(channelOf(station.id)) === "video";
121
+ // How far into the song (or film) on the air, by the encoder's clock, and how long it is.
122
+ const position = file ? ctx.channels.info(channelOf(station.id))?.position : undefined;
123
+ const duration = file && ctx.durationOf ? ctx.durationOf(file) : 0;
124
+ const progress = typeof position === "number" && duration > 0 ? { position: Math.round(Math.min(position, duration) * 10) / 10, duration: Math.round(duration * 10) / 10 } : null;
119
125
  return {
120
126
  id: station.id,
121
127
  name: station.name,
@@ -124,6 +130,7 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
124
130
  state,
125
131
  // Tags, or nothing: a bare file name is the owner's, not the public's.
126
132
  nowPlaying: track && (track.title || track.artist) ? track : null,
133
+ progress,
127
134
  kind: video ? "video" : "audio",
128
135
  format: state === "stopped" || state === "offline" ? null : video
129
136
  ? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { Channels, ChannelInfo, PullResume } from "./channels.ts";
13
13
  import {
14
- EXCLUDE_AFTER, OFFLINE_RETRY_MS, RECOVERY_DELAYS_MS, StationError, channelOf, insideRoots, shuffled,
14
+ EXCLUDE_AFTER, OFFLINE_RETRY_MS, RECOVERY_DELAYS_MS, StationError, channelOf, insideRoots, spreadByArtist,
15
15
  stationOfChannel, type Observed, type Station, type StationStore,
16
16
  } from "./stations.ts";
17
17
 
@@ -29,6 +29,20 @@ export interface RunnerDeps {
29
29
  onFailure?: (id: string) => void;
30
30
  /** A file for a break on a radio (false) or TV (true) station, or null for none. */
31
31
  breaks?: (video: boolean) => Promise<string | null>;
32
+ /** Who a file is by, from the library's tags, for a shuffle that spreads artists out; "" when unknown. */
33
+ artistOf?: (file: string) => string;
34
+ }
35
+
36
+ /**
37
+ * Who a file is by when the tags do not say: "Artist - Album/track" names
38
+ * them in the folder, and "Artist/Album/track" one folder up.
39
+ */
40
+ export function artistFromPath(file: string): string {
41
+ const parts = file.split(/[\\/]/);
42
+ const folder = parts[parts.length - 2] ?? "";
43
+ const dash = folder.indexOf(" - ");
44
+ if (dash > 0) return folder.slice(0, dash);
45
+ return parts[parts.length - 3] ?? folder;
32
46
  }
33
47
 
34
48
  /** Something a listener is connected through, that can be ended. */
@@ -151,7 +165,9 @@ export class StationRunner {
151
165
  repeat: station.repeat,
152
166
  passOver: true,
153
167
  // Read at every pass, so turning shuffle on or off lands then.
154
- order: (length) => (store.get(id)?.shuffle ? shuffled(length) : [...Array(length).keys()]),
168
+ order: (length, files) => (store.get(id)?.shuffle
169
+ ? spreadByArtist(files, (file) => this.deps.artistOf?.(file) || artistFromPath(file))
170
+ : [...Array(length).keys()]),
155
171
  // Read at every track, so an edit lands on the next one.
156
172
  current: () => {
157
173
  const now = store.get(id);
package/src/stations.ts CHANGED
@@ -338,6 +338,9 @@ export class StationStore {
338
338
  }
339
339
  let listed = access.listed === undefined ? station.listed : access.listed === true;
340
340
  if (visibility !== "public") listed = false;
341
+ // Made private, a station forgets what was declared for it: going public
342
+ // again asks afresh, so a stale (or false) declaration cannot republish it.
343
+ if (visibility === "private" && !access.rights) station.rights = null;
341
344
  if (access.rights) {
342
345
  const basis = access.rights.basis;
343
346
  if (typeof basis !== "string" || !RIGHTS_BASES.includes(basis as RightsBasis)) {
@@ -422,6 +425,36 @@ export function shuffled(length: number, random: () => number = Math.random): nu
422
425
  return order;
423
426
  }
424
427
 
428
+ /**
429
+ * A radio shuffle: the whole list in a random order, with no artist twice in
430
+ * a row while another is left to play. A plain shuffle of a genre where a few
431
+ * artists have whole albums kept playing one artist in series. Each pick is
432
+ * an artist drawn by how many of their tracks are left, so the spread stays
433
+ * even to the end, then one of that artist's tracks at random.
434
+ */
435
+ export function spreadByArtist(files: readonly string[], artistOf: (file: string) => string, random: () => number = Math.random): number[] {
436
+ const groups = new Map<string, number[]>();
437
+ files.forEach((file, index) => {
438
+ const key = artistOf(file).trim().toLowerCase();
439
+ const group = groups.get(key);
440
+ if (group) group.push(index); else groups.set(key, [index]);
441
+ });
442
+ const left = [...groups.entries()].map(([key, indexes]) => ({ key, indexes: shuffled(indexes.length, random).map((one) => indexes[one]!) }));
443
+ const order: number[] = [];
444
+ let last = "";
445
+ let total = files.length;
446
+ while (total > 0) {
447
+ const others = left.filter((one) => one.indexes.length > 0 && one.key !== last);
448
+ const pool = others.length > 0 ? others : left.filter((one) => one.indexes.length > 0);
449
+ let pick = random() * pool.reduce((sum, one) => sum + one.indexes.length, 0);
450
+ const group = pool.find((one) => (pick -= one.indexes.length) < 0) ?? pool[pool.length - 1]!;
451
+ order.push(group.indexes.pop()!);
452
+ last = group.key;
453
+ total -= 1;
454
+ }
455
+ return order;
456
+ }
457
+
425
458
  /**
426
459
  * Restart after an unexpected end: 2 s, doubling, at most a minute apart.
427
460
  * After the last of these the station is offline but still wanted, and is
@@ -440,6 +473,8 @@ export interface PublicStation {
440
473
  /** Radio or TV. */
441
474
  kind: "audio" | "video";
442
475
  nowPlaying: { title: string; artist: string; album: string } | null;
476
+ /** Seconds into what is on and how long it is, when both are known. */
477
+ progress?: { position: number; duration: number } | null;
443
478
  format: { codec: string; bitrateKbps: number; sampleRate: number; channels: number } | null;
444
479
  listeners: number;
445
480
  }