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.
- package/dist/ai-label.d.ts +50 -4
- package/dist/ai-label.js +219 -35
- package/dist/channels.js +3 -0
- package/dist/playlist-input.d.ts +9 -1
- package/dist/playlist-input.js +10 -4
- package/dist/server.js +12 -6
- package/dist/station-routes.d.ts +2 -0
- package/dist/station-routes.js +5 -0
- package/dist/station-runner.d.ts +7 -0
- package/dist/station-runner.js +16 -2
- package/dist/stations.d.ts +13 -0
- package/dist/stations.js +36 -0
- package/package.json +1 -1
- package/src/ai-label.ts +216 -31
- package/src/channels.ts +2 -0
- package/src/playlist-input.ts +14 -8
- package/src/server.ts +12 -6
- package/src/station-routes.ts +7 -0
- package/src/station-runner.ts +18 -2
- package/src/stations.ts +35 -0
- package/web/dist/assets/{ar-Cu3HNStu.js → ar-DsQNv_tK.js} +1 -1
- package/web/dist/assets/{cs-uqfWO8Tj.js → cs-BWVuDS0X.js} +1 -1
- package/web/dist/assets/{da-D3ZenKKD.js → da-yMLNhkak.js} +1 -1
- package/web/dist/assets/{de-D7hSBOda.js → de-DVSMvq22.js} +1 -1
- package/web/dist/assets/{es-Dr-i4Jfe.js → es-B5xdyPjG.js} +1 -1
- package/web/dist/assets/{fi-DL7cLP_e.js → fi-_hVnUyE3.js} +1 -1
- package/web/dist/assets/{fr-C-_Cil5p.js → fr-ChOWYR7D.js} +1 -1
- package/web/dist/assets/{hi-BG6Nbvu1.js → hi-Dtw7Mrwj.js} +1 -1
- package/web/dist/assets/{hu-CO6uMAnx.js → hu-DSrmbSIn.js} +1 -1
- package/web/dist/assets/{id-D_0dB7Cc.js → id-BF5hzSrf.js} +1 -1
- package/web/dist/assets/{it-KC5QkD3e.js → it-Df8Yov7v.js} +1 -1
- package/web/dist/assets/{main-D8ga1_tc.js → main-CbkdPOhq.js} +2 -2
- package/web/dist/assets/{nl-DJtjRzKr.js → nl-DUHEGAjS.js} +1 -1
- package/web/dist/assets/{radio-Ct2xxni3.css → radio-CtAne8M1.css} +1 -1
- package/web/dist/assets/{radio-B8n_n-WD.js → radio-ZiViiR-a.js} +1 -1
- package/web/dist/assets/{ru-By0X_8As.js → ru-BTX4-kNd.js} +1 -1
- package/web/dist/assets/{station-Be-z7jQ2.css → station-Bqk5iV8L.css} +1 -1
- package/web/dist/assets/{station-CrAZxHcX.js → station-DUt85zQk.js} +1 -1
- package/web/dist/assets/{sv-Dt4z_CkV.js → sv-DT2zVu1w.js} +1 -1
- package/web/dist/assets/{uk-B39fMx80.js → uk-V9OBaE62.js} +1 -1
- package/web/dist/assets/{vi-BnlBuEHd.js → vi-DaV25yrA.js} +1 -1
- package/web/dist/assets/{zh-5rQ6L9ym.js → zh-BgJ6hib9.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +9 -4
- package/web/dist/station.html +6 -2
- package/web/dist/sw.js +26 -26
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- /package/web/dist/assets/{vod-CrvTO3FN.js → vod-BSo-pl8M.js} +0 -0
- /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
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
|
-
|
|
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
|
|
524
|
-
* song's waveform along the bottom, 720p H.264 with the
|
|
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",
|
|
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:
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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;
|
package/src/playlist-input.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
7429
|
-
known: trackFor(engine,
|
|
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,
|
package/src/station-routes.ts
CHANGED
|
@@ -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 }
|
package/src/station-runner.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
}
|