nixamp 0.78.5 → 0.80.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +6 -5
  2. package/backtoschool/dist/assets/{ar-CYu0byMf.js → ar-BYDVUbTZ.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-BTTM7DeB.js → cs-DsCx7BAy.js} +1 -1
  4. package/backtoschool/dist/assets/{da-iixezQtd.js → da-5tLM7Wal.js} +1 -1
  5. package/backtoschool/dist/assets/{de-Sx3acevR.js → de-2Q8k6IKm.js} +1 -1
  6. package/backtoschool/dist/assets/{es-ByCnw8ja.js → es-CVwFkQ0E.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-BCeFoeH5.js → fi-D7KuvUvL.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-Nz5hMzAE.js → fr-CkWg-nDv.js} +1 -1
  9. package/backtoschool/dist/assets/{hi-x5-IG1rl.js → hi-DayNvhO4.js} +1 -1
  10. package/backtoschool/dist/assets/{hls-OR5FY6YP-O3qhTXm4.js → hls-OR5FY6YP-D2hwLr-P.js} +1 -1
  11. package/backtoschool/dist/assets/{hu-QqIkGcMe.js → hu-BqS46UyM.js} +1 -1
  12. package/backtoschool/dist/assets/{id-OVwO1Uoi.js → id-DTiKw_BS.js} +1 -1
  13. package/backtoschool/dist/assets/{index-Dgqs6yM_.js → index-EM-0SwBp.js} +3 -3
  14. package/backtoschool/dist/assets/{it-CmssY5Ut.js → it-B60apeGa.js} +1 -1
  15. package/backtoschool/dist/assets/{mpegts-RQlLEU1A.js → mpegts-C4v5WI5W.js} +1 -1
  16. package/backtoschool/dist/assets/{mpegts-Y2FARLFE-y49QH7a5.js → mpegts-Y2FARLFE-D4OTyGyp.js} +1 -1
  17. package/backtoschool/dist/assets/{nl-VkQxlPku.js → nl-DctlIuWS.js} +1 -1
  18. package/backtoschool/dist/assets/{ru-Dk8FAbDx.js → ru-CO8g0gfi.js} +1 -1
  19. package/backtoschool/dist/assets/{sv-CNs1UySx.js → sv-CoFMz-Qg.js} +1 -1
  20. package/backtoschool/dist/assets/{uk-BDB1YayY.js → uk-C3_GprSP.js} +1 -1
  21. package/backtoschool/dist/assets/{vi-DnEIKzy7.js → vi-B0RDG4iN.js} +1 -1
  22. package/backtoschool/dist/assets/{zh-B_atHA4S.js → zh--yO6izcR.js} +1 -1
  23. package/backtoschool/dist/index.html +3 -3
  24. package/dist/ai-keys.d.ts +1 -1
  25. package/dist/ai-keys.js +1 -1
  26. package/dist/ai-label.d.ts +38 -0
  27. package/dist/ai-label.js +237 -5
  28. package/dist/ai-text.d.ts +42 -1
  29. package/dist/ai-text.js +69 -7
  30. package/dist/event-writer.d.ts +10 -1
  31. package/dist/event-writer.js +27 -8
  32. package/dist/label-routes.js +9 -0
  33. package/dist/library-mcp.d.ts +6 -6
  34. package/dist/music-hub.d.ts +11 -0
  35. package/dist/music-hub.js +33 -2
  36. package/dist/nixamp-link-api.d.ts +5 -1
  37. package/dist/nixamp-link-api.js +12 -2
  38. package/dist/nixamp-link-types.d.ts +2 -0
  39. package/dist/nixamp-link.d.ts +22 -9
  40. package/dist/nixamp-link.js +75 -58
  41. package/dist/oauth.d.ts +1 -0
  42. package/dist/oauth.js +8 -1
  43. package/dist/profiles.d.ts +1 -1
  44. package/dist/profiles.js +0 -0
  45. package/dist/server.js +19 -10
  46. package/dist/station-cli.js +24 -0
  47. package/dist/station-mcp.d.ts +6 -6
  48. package/dist/station-usage.d.ts +1 -1
  49. package/dist/station-usage.js +4 -1
  50. package/dist/universal-footer.d.ts +2 -0
  51. package/dist/universal-footer.js +39 -0
  52. package/package.json +7 -5
  53. package/src/ai-keys.ts +1 -1
  54. package/src/ai-label.ts +240 -5
  55. package/src/ai-text.ts +84 -8
  56. package/src/event-writer.ts +36 -11
  57. package/src/label-routes.ts +9 -0
  58. package/src/music-hub.ts +38 -2
  59. package/src/nixamp-link-api.ts +15 -3
  60. package/src/nixamp-link-types.ts +2 -0
  61. package/src/nixamp-link.ts +77 -61
  62. package/src/oauth.ts +9 -1
  63. package/src/profiles.ts +0 -0
  64. package/src/server.ts +19 -10
  65. package/src/station-cli.ts +23 -0
  66. package/src/station-usage.ts +4 -1
  67. package/src/universal-footer.ts +41 -0
  68. package/web/dist/assets/{ar-CqVLKLkR.js → ar-C5qVRZh9.js} +1 -1
  69. package/web/dist/assets/{chunk-55GBNX64-BDcCEJUP.js → chunk-NW3WDGEQ-CnyjTRUh.js} +1 -1
  70. package/web/dist/assets/{cs-DwgnZ10A.js → cs-fajZsJrs.js} +1 -1
  71. package/web/dist/assets/{da-C6V-fzX4.js → da-fycV1k1N.js} +1 -1
  72. package/web/dist/assets/{de-CyZ00iAl.js → de-QUzTQ_9X.js} +1 -1
  73. package/web/dist/assets/{es-jlhhpSVu.js → es-CQu2leAJ.js} +1 -1
  74. package/web/dist/assets/{fi-o92ZPZq7.js → fi-AgKEsukC.js} +1 -1
  75. package/web/dist/assets/{fr-BcCMVWa_.js → fr-CRbHnW4y.js} +1 -1
  76. package/web/dist/assets/{hi-Dbgp5pgN.js → hi-BuU7xy9L.js} +1 -1
  77. package/web/dist/assets/{hls-OR5FY6YP-_WOdANsC.js → hls-OR5FY6YP-BxZwqFB9.js} +1 -1
  78. package/web/dist/assets/{hu-CRa0dBhC.js → hu-C40Z-HcJ.js} +1 -1
  79. package/web/dist/assets/{id-BF7Q2wE4.js → id-2b7626UL.js} +1 -1
  80. package/web/dist/assets/{it-z9MVvK3u.js → it-7sr3nJuS.js} +1 -1
  81. package/web/dist/assets/main-zuI3sOLC.js +33 -0
  82. package/web/dist/assets/{mpegts-CL9RC5Ls.js → mpegts-DPRtdBXL.js} +1 -1
  83. package/web/dist/assets/{mpegts-Y2FARLFE-C91SD8Pl.js → mpegts-Y2FARLFE-BY7parIQ.js} +1 -1
  84. package/web/dist/assets/{nl-C-9KQwX0.js → nl-BdDGgfzj.js} +1 -1
  85. package/web/dist/assets/{radio-BnQ78a-O.js → radio-CeRhNjpU.js} +1 -1
  86. package/web/dist/assets/{ru-D835Y1dr.js → ru-BMGI8Oa4.js} +1 -1
  87. package/web/dist/assets/{station-C_6L4CDU.js → station-D5tW9jq4.js} +1 -1
  88. package/web/dist/assets/station-D6KfgFXf.css +1 -0
  89. package/web/dist/assets/{sv-DLSLSdjL.js → sv-BXASdl7c.js} +1 -1
  90. package/web/dist/assets/{uk-DgCBRDrZ.js → uk-urnhuqtG.js} +1 -1
  91. package/web/dist/assets/{vi-C23GbtRZ.js → vi-BhLPAKuD.js} +1 -1
  92. package/web/dist/assets/{vod-CbSC5NFA.js → vod-Dt73bt8P.js} +1 -1
  93. package/web/dist/assets/{watch-D-ZRxfJ_.js → watch-GxgHBQcI.js} +1 -1
  94. package/web/dist/assets/{zh-D0dXViuL.js → zh-BaOCIB9u.js} +1 -1
  95. package/web/dist/index.html +58 -12
  96. package/web/dist/music.html +1 -1
  97. package/web/dist/radio.html +5 -5
  98. package/web/dist/station.html +4 -4
  99. package/web/dist/sw.js +30 -30
  100. package/web/dist/vod.html +4 -4
  101. package/web/dist/watch.html +4 -4
  102. package/dist/pit-footer.d.ts +0 -3
  103. package/dist/pit-footer.js +0 -40
  104. package/src/pit-footer.ts +0 -40
  105. package/web/dist/assets/main-Q8gHrr9u.js +0 -26
  106. package/web/dist/assets/station-CxJvFwzV.css +0 -1
  107. /package/web/dist/assets/{chunk-55GBNX64-DR1XNWCJ.css → chunk-NW3WDGEQ-DR1XNWCJ.css} +0 -0
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 { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
31
+ import { existsSync, mkdirSync, readdirSync, 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";
@@ -1895,6 +1895,69 @@ export function genreSlug(genre: string): string {
1895
1895
  return genre.normalize("NFKD").toLowerCase().replace(/&/g, "n").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "music";
1896
1896
  }
1897
1897
 
1898
+ /** What `adopt` took back from disk. */
1899
+ export interface AdoptReport {
1900
+ artists: number;
1901
+ albums: number;
1902
+ /** Recorded songs (planned ones without a file are kept too, to be made). */
1903
+ songs: number;
1904
+ /** Filmed music videos, shown on /music. */
1905
+ videos: number;
1906
+ /** Artists that kept the key they had before. */
1907
+ oldKeys: number;
1908
+ /** Folders left alone, and why. */
1909
+ skipped: string[];
1910
+ }
1911
+
1912
+ /** The folders in a folder, hidden ones left out; none when it cannot be read. */
1913
+ function listDirs(folder: string): string[] {
1914
+ try {
1915
+ return readdirSync(folder, { withFileTypes: true }).filter((one) => one.isDirectory() && !one.name.startsWith(".")).map((one) => one.name).sort();
1916
+ } catch {
1917
+ return [];
1918
+ }
1919
+ }
1920
+
1921
+ function readText(file: string): string {
1922
+ try {
1923
+ return readFileSync(file, "utf8");
1924
+ } catch {
1925
+ return "";
1926
+ }
1927
+ }
1928
+
1929
+ function readJson(file: string): Record<string, unknown> | null {
1930
+ try {
1931
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
1932
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
1933
+ } catch {
1934
+ return null;
1935
+ }
1936
+ }
1937
+
1938
+ function mtimeOf(file: string): number {
1939
+ try {
1940
+ return Math.round(statSync(file).mtimeMs);
1941
+ } catch {
1942
+ return 0;
1943
+ }
1944
+ }
1945
+
1946
+ /** The text under "## <heading>" in an artist.md (bandMarkdown). */
1947
+ function section(page: string, heading: string): string {
1948
+ const at = page.indexOf(`\n## ${heading}\n`);
1949
+ if (at < 0) return "";
1950
+ const rest = page.slice(at + heading.length + 5);
1951
+ const end = rest.indexOf("\n## ");
1952
+ return (end < 0 ? rest : rest.slice(0, end)).trim();
1953
+ }
1954
+
1955
+ /** The bio: the paragraph between the facts list and the first section of an artist.md. */
1956
+ function intro(page: string): string {
1957
+ const head = page.split("\n## ")[0] ?? "";
1958
+ return head.split("\n").filter((one) => one.trim() && !one.startsWith("#") && !one.startsWith("- ")).join(" ").trim();
1959
+ }
1960
+
1898
1961
  /** "001. Title", the start of a song's file names. */
1899
1962
  export function songBase(song: Pick<LabelSong, "n" | "title">): string {
1900
1963
  return `${String(song.n).padStart(3, "0")}. ${fileName(song.title)}`;
@@ -2680,7 +2743,7 @@ export class AiLabel {
2680
2743
  const why = !this.settings.enabled ? "the label is switched off"
2681
2744
  : this.settings.pausedReason
2682
2745
  || (composer ? "" : "no composer: set ELEVENLABS_API_KEY (nixamp station ai key ELEVENLABS_API_KEY) or NIXAMP_AI_MUSIC_COMMAND")
2683
- || (this.canAsk() ? "" : "no language model to write the albums: set GEMINI_API_KEY, OPENAI_API_KEY (or NIXAMP_OPENAI_URL) or ANTHROPIC_API_KEY")
2746
+ || (this.canAsk() ? "" : "no language model to write the albums: set C0MPUTE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY (or NIXAMP_OPENAI_URL) or ANTHROPIC_API_KEY")
2684
2747
  || (this.budgetLeft() < this.settings.songSeconds ? `this month's ${this.settings.monthlySeconds} s are used` : "");
2685
2748
  return {
2686
2749
  root: this.root,
@@ -2816,7 +2879,7 @@ export class AiLabel {
2816
2879
  /** Plan the next album for this artist now, whatever its schedule; it is composed song by song from here. */
2817
2880
  async plan(key: string): Promise<LabelAlbum> {
2818
2881
  const artist = this.require(key);
2819
- if (!this.canAsk()) throw new StationError("no_writer", "no language model to write the album: set GEMINI_API_KEY, OPENAI_API_KEY (or NIXAMP_OPENAI_URL) or ANTHROPIC_API_KEY", 409);
2882
+ if (!this.canAsk()) throw new StationError("no_writer", "no language model to write the album: set C0MPUTE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY (or NIXAMP_OPENAI_URL) or ANTHROPIC_API_KEY", 409);
2820
2883
  const pending = artist.albums.find((one) => !one.doneAt);
2821
2884
  if (pending) throw new StationError("album_in_progress", `"${pending.title}" is still being made`, 409);
2822
2885
  await this.ensureProfile(artist).catch(() => undefined);
@@ -2844,6 +2907,162 @@ export class AiLabel {
2844
2907
  return album;
2845
2908
  }
2846
2909
 
2910
+ /**
2911
+ * Take back a catalogue that is on disk but not in the state file: the label's
2912
+ * folders copied from another server (or a state file lost with its box).
2913
+ * Every <genre>/<artist>/<year>-<album>/ under the root becomes the artist and
2914
+ * album it was, from artist.md, album.json, the members' pages and the songs'
2915
+ * own files, so /music, the genre stations, zips and purchases work again.
2916
+ * `keys` gives artists their old keys by name and `albums` their albums' by
2917
+ * "<artist key>/<album title>", so links and grants made before still match;
2918
+ * the rest get new ones. Nothing is composed or drawn and nothing is moved;
2919
+ * artists and albums already on the label are left as they are.
2920
+ */
2921
+ async adopt(input: { keys?: Record<string, string>; albums?: Record<string, string>; enabled?: boolean } = {}): Promise<AdoptReport> {
2922
+ const report: AdoptReport = { artists: 0, albums: 0, songs: 0, videos: 0, oldKeys: 0, skipped: [] };
2923
+ const used = new Set([...this.artists.keys()]);
2924
+ const usedAlbums = new Set(this.list().flatMap((one) => one.albums.map((album) => album.key)));
2925
+ const keyFor = (name: string): string => {
2926
+ const old = input.keys?.[name];
2927
+ if (old && /^[a-f0-9]{10}$/.test(old) && !used.has(old)) {
2928
+ report.oldKeys += 1;
2929
+ return old;
2930
+ }
2931
+ let key = randomBytes(5).toString("hex");
2932
+ while (used.has(key)) key = randomBytes(5).toString("hex");
2933
+ return key;
2934
+ };
2935
+ const albumKeyFor = (artistKey: string, title: string): string => {
2936
+ const old = input.albums?.[`${artistKey}/${title}`];
2937
+ if (old && /^[a-f0-9]{8}$/.test(old) && !usedAlbums.has(old)) return old;
2938
+ let key = randomBytes(4).toString("hex");
2939
+ while (usedAlbums.has(key)) key = randomBytes(4).toString("hex");
2940
+ return key;
2941
+ };
2942
+ const touched = new Set<string>();
2943
+ for (const genreDir of listDirs(this.root)) {
2944
+ for (const artistDir of listDirs(join(this.root, genreDir))) {
2945
+ const folder = join(this.root, genreDir, artistDir);
2946
+ const page = readText(join(folder, "artist.md"));
2947
+ const albumDirs = listDirs(folder).filter((one) => /^\d{4}-./.test(one));
2948
+ const firstJson = albumDirs.map((one) => readJson(join(folder, one, "album.json"))).find((one) => one);
2949
+ const name = clean(/^# (.+)$/m.exec(page)?.[1] ?? firstJson?.["artist"] ?? artistDir, 80);
2950
+ const genre = clean(/^- \*\*Genre\*\*: (.+)$/m.exec(page)?.[1] ?? firstJson?.["genre"] ?? genreDir, 40);
2951
+ if (genreSlug(genre) !== genreDir || fileName(name) !== artistDir) {
2952
+ report.skipped.push(`${genreDir}/${artistDir}: its pages say ${genre}/${name}, which files elsewhere`);
2953
+ continue;
2954
+ }
2955
+ const preset = Object.values(ROSTER).find((one) => one.name.toLowerCase() === name.toLowerCase());
2956
+ let artist = this.list().find((one) => one.name.toLowerCase() === name.toLowerCase());
2957
+ if (!artist) {
2958
+ const key = keyFor(name);
2959
+ used.add(key);
2960
+ artist = {
2961
+ key, name, genre,
2962
+ sound: clean(section(page, "Sound") || preset?.sound || genre, 600),
2963
+ voice: clean(section(page, "Lead voice") || preset?.voice || "expressive lead vocal", 300),
2964
+ bio: clean(intro(page) || preset?.bio || "", 400),
2965
+ themes: clean(section(page, "Themes") || preset?.themes || "", 300),
2966
+ enabled: input.enabled !== false,
2967
+ albums: [],
2968
+ createdAt: this.now(),
2969
+ lastAttemptAt: 0,
2970
+ lastError: "",
2971
+ failures: 0,
2972
+ };
2973
+ const members = [...page.matchAll(/\(members\/([a-z0-9-]{1,40})\.md\)/g)].map((one) => one[1]!)
2974
+ .filter((slug) => existsSync(join(folder, "members", `${slug}.md`)));
2975
+ if (members.length) artist.members = members;
2976
+ this.artists.set(artist.key, artist);
2977
+ report.artists += 1;
2978
+ }
2979
+ for (const albumDir of albumDirs) {
2980
+ const albumFolder = join(folder, albumDir);
2981
+ const json = readJson(join(albumFolder, "album.json")) ?? {};
2982
+ const title = clean(json["album"] ?? albumDir.replace(/^\d{4}-/, ""), 120);
2983
+ if (artist.albums.some((one) => one.title.toLowerCase() === title.toLowerCase())) continue;
2984
+ const planned = Array.isArray(json["songs"]) ? json["songs"] as Array<Record<string, unknown>> : [];
2985
+ const files = readdirSync(albumFolder).filter((one) => /^\d{3}\. .+\.mp3$/.test(one));
2986
+ const numbers = new Set([...planned.map((one) => Number(one["n"])), ...files.map((one) => Number(one.slice(0, 3)))].filter((n) => Number.isInteger(n) && n > 0));
2987
+ const songs: LabelSong[] = [];
2988
+ for (const n of [...numbers].sort((a, b) => a - b)) {
2989
+ const entry = planned.find((one) => Number(one["n"]) === n);
2990
+ const mp3 = files.find((one) => Number(one.slice(0, 3)) === n);
2991
+ const base = mp3 ? mp3.replace(/\.mp3$/, "") : "";
2992
+ const songTitle = clean(entry?.["title"] ?? base.replace(/^\d{3}\. /, ""), 120);
2993
+ const at = (ext: string): string => base && existsSync(join(albumFolder, `${base}${ext}`)) ? join(albumFolder, `${base}${ext}`) : "";
2994
+ const file = mp3 ? join(albumFolder, mp3) : "";
2995
+ let seconds = Number(entry?.["seconds"]) || this.settings.songSeconds;
2996
+ let made = "";
2997
+ if (file) {
2998
+ try {
2999
+ const probed = await this.deps.probe(file);
3000
+ if (probed.duration > 0) seconds = Math.round(probed.duration);
3001
+ } catch { /* the planned length */ }
3002
+ made = "adopted";
3003
+ }
3004
+ const lyricsFile = at(".lyrics.txt");
3005
+ const song: LabelSong = {
3006
+ n, title: songTitle, style: clean(entry?.["style"] ?? json["concept"] ?? "", 600), lyrics: lyricsFile ? readText(lyricsFile).trim() : "",
3007
+ seconds, file, lyricsFile, made, composedAt: file ? mtimeOf(file) : 0,
3008
+ };
3009
+ const lrc = at(".lrc");
3010
+ if (lrc) song.lrc = lrc;
3011
+ const video = at(".mp4");
3012
+ if (video && file) {
3013
+ song.video = video;
3014
+ song.videoTagged = true;
3015
+ // Filmed videos are 1080p Veo footage (tens of Mbit/s); the older cover-and-waveform ones were never shown.
3016
+ const filmed = await this.isFilmed(video);
3017
+ song.videoVersion = filmed ? FILMED : 1;
3018
+ // Whether its singer was synced is not on disk; it is not filmed again for that.
3019
+ if (filmed) song.singer = "unsynced";
3020
+ if (filmed) report.videos += 1;
3021
+ }
3022
+ songs.push(song);
3023
+ if (file) report.songs += 1;
3024
+ }
3025
+ if (songs.length === 0) continue;
3026
+ const key = albumKeyFor(artist.key, title);
3027
+ usedAlbums.add(key);
3028
+ const year = Number(json["year"]) || Number(albumDir.slice(0, 4)) || new Date(this.now()).getUTCFullYear();
3029
+ const times = songs.filter((one) => one.file).map((one) => one.composedAt);
3030
+ const cover = existsSync(join(albumFolder, "cover.jpg")) ? join(albumFolder, "cover.jpg") : "";
3031
+ artist.albums.push({
3032
+ key, title, year,
3033
+ concept: clean(json["concept"] ?? "", 600),
3034
+ folder: albumFolder,
3035
+ cover,
3036
+ coverTried: true,
3037
+ songs,
3038
+ writer: "",
3039
+ plannedAt: times.length ? Math.min(...times) : mtimeOf(albumFolder),
3040
+ doneAt: songs.every((one) => one.file) ? Math.max(...times) : 0,
3041
+ });
3042
+ artist.albums.sort((a, b) => a.plannedAt - b.plannedAt);
3043
+ artist.createdAt = Math.min(artist.createdAt, artist.albums[0]!.plannedAt || artist.createdAt);
3044
+ report.albums += 1;
3045
+ touched.add(artist.genre);
3046
+ }
3047
+ }
3048
+ }
3049
+ this.save();
3050
+ for (const genre of touched) await this.place(genre).catch((error: unknown) => this.log(`could not put ${genre} on the air: ${(error as Error).message}`));
3051
+ this.log(`adopted ${report.artists} artists, ${report.albums} albums, ${report.songs} songs (${report.oldKeys} kept their old keys)`);
3052
+ return report;
3053
+ }
3054
+
3055
+ /** A music video cut from Veo footage, by its bit rate: those are 1080p at tens of Mbit/s, the old slideshows a couple. */
3056
+ private async isFilmed(video: string): Promise<boolean> {
3057
+ try {
3058
+ const probed = await this.deps.probe(video);
3059
+ if (!(probed.duration > 0)) return false;
3060
+ return (statSync(video).size * 8) / probed.duration > 8_000_000;
3061
+ } catch {
3062
+ return false;
3063
+ }
3064
+ }
3065
+
2847
3066
  startSchedule(everyMs = 5 * 60_000): void {
2848
3067
  if (this.timer) return;
2849
3068
  this.timer = setInterval(() => { void this.tick(); void this.videos(); void this.timeWords(); }, everyMs);
@@ -3190,7 +3409,7 @@ export class AiLabel {
3190
3409
  const files = this.list()
3191
3410
  .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => ((song.videoVersion ?? 1) >= SHOWN ? song.video ?? "" : ""))))
3192
3411
  .filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
3193
- const existing = this.deps.store.byAuto("ai:label:videos");
3412
+ const existing = this.claimed("ai:label:videos", `${this.labelName()} AI · Music Videos`);
3194
3413
  if (files.length === 0) {
3195
3414
  // Nothing filmed yet (older slideshow videos are not shown): off the guide until there is.
3196
3415
  if (existing && existing.listed) this.deps.store.setAccess(existing.id, { visibility: "private", listed: false }, this.deps.owner || "ai-label");
@@ -3476,7 +3695,23 @@ export class AiLabel {
3476
3695
  }
3477
3696
 
3478
3697
  private stationFor(genre: string): ReturnType<StationStore["get"]> {
3479
- return this.deps.store.byAuto(`ai:label:${genreSlug(genre)}`);
3698
+ return this.claimed(`ai:label:${genreSlug(genre)}`, `${this.labelName()} AI · ${genre}`);
3699
+ }
3700
+
3701
+ /** "Profullstack" for "Profullstack, Inc.": how the label's stations are named. */
3702
+ private labelName(): string {
3703
+ return this.settings.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
3704
+ }
3705
+
3706
+ /**
3707
+ * The label's station for this key; else one made by hand under the very name the
3708
+ * label would give it (a label rebuilt on another server), taken over, not doubled.
3709
+ */
3710
+ private claimed(key: string, name: string): ReturnType<StationStore["get"]> {
3711
+ const marked = this.deps.store.byAuto(key);
3712
+ if (marked) return marked;
3713
+ const named = this.deps.store.list().find((one) => !one.auto && one.name === name);
3714
+ return named ? this.deps.store.markAuto(named.id, key) : undefined;
3480
3715
  }
3481
3716
 
3482
3717
  /** The genre's station gets every song of that genre: made with the first, then kept in step. */
package/src/ai-text.ts CHANGED
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * AI radio asks for small things: a title and a prompt for the next track, a
5
5
  * genre and a verdict for a shared file. Whichever model has a key answers --
6
- * Gemini, then OpenAI, then Anthropic -- and when none has, the caller is told
7
- * so and does without (a rule of its own, or a person). The answer is parsed
8
- * and handed back as an object; a model that rambles round its JSON is read
9
- * for the first object in what it said.
6
+ * c0mpute, then Gemini, then OpenAI, then Anthropic -- and when none has, the
7
+ * caller is told so and does without (a rule of its own, or a person). The
8
+ * answer is parsed and handed back as an object; a model that rambles round
9
+ * its JSON is read for the first object in what it said.
10
10
  */
11
+ import { ProviderError, createAiProviders, type AiRouter } from "@profullstack/ai-providers";
11
12
  import { aiKey } from "./ai-keys.ts";
12
13
 
13
14
  export interface AskOptions {
@@ -15,7 +16,7 @@ export interface AskOptions {
15
16
  system: string;
16
17
  /** The question, with what it should answer from. */
17
18
  prompt: string;
18
- /** Milliseconds before giving up (default 30 s). */
19
+ /** Milliseconds before giving up on a hosted model (default 30 s); c0mpute gets at least C0MPUTE_MIN_WAIT_MS. */
19
20
  timeoutMs?: number;
20
21
  /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
21
22
  maxTokens?: number;
@@ -33,7 +34,7 @@ export interface Answer {
33
34
 
34
35
  export class NoModel extends Error {
35
36
  constructor() {
36
- super("no language model key is set (GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
37
+ super("no language model key is set (C0MPUTE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
37
38
  }
38
39
  }
39
40
 
@@ -61,7 +62,7 @@ export function escapeInStrings(text: string): string {
61
62
 
62
63
  /** The first JSON object in a model's answer. */
63
64
  export function firstObject(text: string): Record<string, unknown> {
64
- const fenced = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
65
+ const fenced = text.replace(/<think>[\s\S]*?<\/think>/gi, "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
65
66
  const cleaned = (() => {
66
67
  try { JSON.parse(fenced); return fenced; } catch { return escapeInStrings(fenced); }
67
68
  })();
@@ -80,14 +81,79 @@ export function firstObject(text: string): Record<string, unknown> {
80
81
  throw new Error("the model did not answer with a JSON object");
81
82
  }
82
83
 
84
+ /** The c0mpute key, or "" when there is none or C0MPUTE_DISABLED turns it off. */
85
+ export function c0mputeKey(env: Record<string, string | undefined> = process.env): string {
86
+ return /^(1|true|yes|on)$/i.test(env["C0MPUTE_DISABLED"] ?? "") ? "" : aiKey("C0MPUTE_API_KEY", env);
87
+ }
88
+
83
89
  /** Whether any model can be asked. */
84
90
  export function hasModel(env: Record<string, string | undefined> = process.env): boolean {
85
- return Boolean(aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
91
+ return Boolean(c0mputeKey(env) || aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
92
+ }
93
+
94
+ /**
95
+ * c0mpute's 9B model takes 30 s for a short JSON answer, longer on a busy
96
+ * node, so it never gets a hosted model's 30 s budget.
97
+ */
98
+ export const C0MPUTE_MIN_WAIT_MS = 120_000;
99
+
100
+ /** c0mpute alone: the fallbacks are this file's own chain, not the package's. */
101
+ export function c0mputeRouter(key: string, options: { env?: Record<string, string | undefined>; fetcher?: typeof fetch; concurrency?: number } = {}): AiRouter {
102
+ return createAiProviders({
103
+ env: { ...(options.env ?? process.env), C0MPUTE_API_KEY: key },
104
+ paidFallback: false,
105
+ c0mpute: {
106
+ ...(options.fetcher ? { fetch: options.fetcher } : {}),
107
+ ...(options.concurrency ? { concurrency: options.concurrency } : {}),
108
+ },
109
+ });
110
+ }
111
+
112
+ export interface C0mputeAsk {
113
+ system: string;
114
+ user: string;
115
+ maxTokens: number;
116
+ timeoutMs: number;
117
+ temperature?: number;
118
+ signal?: AbortSignal;
119
+ }
120
+
121
+ /**
122
+ * One answer from c0mpute, read by `read`. An answer `read` refuses gets one
123
+ * more try on the same model, told what was wrong with it, before the caller
124
+ * moves on: a small model misses far more often than a hosted one.
125
+ */
126
+ export async function askC0mpute<T>(router: AiRouter, ask: C0mputeAsk, read: (text: string) => T): Promise<{ model: string; value: T }> {
127
+ const { value } = await router.run(async (provider) => {
128
+ const first = await provider.generate(ask);
129
+ try {
130
+ return { model: first.model, value: read(first.text) };
131
+ } catch (error) {
132
+ const again = await provider.generate({
133
+ ...ask,
134
+ user: `${ask.user}\n\nYour previous answer could not be used: ${(error as Error).message}\nAnswer again with only the corrected JSON object.`,
135
+ });
136
+ try {
137
+ return { model: again.model, value: read(again.text) };
138
+ } catch (second) {
139
+ throw new ProviderError("c0mpute", (second as Error).message, { kind: "output" });
140
+ }
141
+ }
142
+ });
143
+ return value;
144
+ }
145
+
146
+ /** The router askJson shares across calls, so a dead or busy c0mpute is parked for all of them. */
147
+ let shared: { key: string; router: AiRouter } | undefined;
148
+ function sharedRouter(key: string, env: Record<string, string | undefined>): AiRouter {
149
+ if (shared?.key !== key) shared = { key, router: c0mputeRouter(key, { env }) };
150
+ return shared.router;
86
151
  }
87
152
 
88
153
  export async function askJson(options: AskOptions): Promise<Answer> {
89
154
  const env = options.env ?? process.env;
90
155
  const send = options.fetcher ?? fetch;
156
+ const c0mpute = c0mputeKey(env);
91
157
  const gemini = aiKey("GEMINI_API_KEY", env);
92
158
  const openai = aiKey("OPENAI_API_KEY", env);
93
159
  const anthropic = aiKey("ANTHROPIC_API_KEY", env);
@@ -95,6 +161,16 @@ export async function askJson(options: AskOptions): Promise<Answer> {
95
161
  // answers 400 "User location is not supported" from some networks) or is
96
162
  // down is not a reason to go without when another can answer.
97
163
  const tries: Array<() => Promise<Answer>> = [];
164
+ if (c0mpute) tries.push(() => askC0mpute(
165
+ options.fetcher ? c0mputeRouter(c0mpute, { env, fetcher: options.fetcher }) : sharedRouter(c0mpute, env),
166
+ {
167
+ system: `${options.system}\nAnswer with one JSON object and nothing else.`,
168
+ user: options.prompt,
169
+ maxTokens: options.maxTokens ?? 1024,
170
+ timeoutMs: Math.max(options.timeoutMs ?? 0, C0MPUTE_MIN_WAIT_MS),
171
+ },
172
+ firstObject,
173
+ ));
98
174
  if (gemini) tries.push(async () => {
99
175
  const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
100
176
  const response = await send(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
@@ -1,10 +1,13 @@
1
+ import { jsonInstructions, type AiRouter } from "@profullstack/ai-providers";
2
+ import { askC0mpute, c0mputeRouter, C0MPUTE_MIN_WAIT_MS, firstObject } from "./ai-text.ts";
1
3
  import { Guard } from "./guard.ts";
2
4
 
3
5
  export interface EventDraft { title: string; description: string; topic: string }
4
6
  export interface WriterInput extends EventDraft {
5
7
  hostName: string; startsAt: string; timezone: string; duration: string; recurrence: string;
6
8
  }
7
- export interface EventDraftResult { draft: EventDraft; provider: "openai" | "claude" }
9
+ type Provider = "c0mpute" | "openai" | "claude";
10
+ export interface EventDraftResult { draft: EventDraft; provider: Provider }
8
11
  export class EventWriterError extends Error {
9
12
  constructor(message: string, readonly status: number, readonly retryAfter?: number) { super(message); }
10
13
  }
@@ -37,8 +40,7 @@ Write in the language of the host's notes. Describe what students will learn and
37
40
  Keep concrete names and facts. Do not invent dates, prices, qualifications, prerequisites, links, credentials, or promises. Omit unknown logistics. Do not expand into unrelated subjects.
38
41
  The schedule and host name are context only; never propose changing them. Return plain text in the required JSON fields, with no HTML. Short paragraphs or simple bullet lists are fine.`;
39
42
 
40
- function draftFrom(text: string): EventDraft {
41
- const value: unknown = JSON.parse(text);
43
+ function draftOf(value: unknown): EventDraft {
42
44
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid draft");
43
45
  const row = value as Record<string, unknown>;
44
46
  const draft = {} as EventDraft;
@@ -51,34 +53,46 @@ function draftFrom(text: string): EventDraft {
51
53
  }
52
54
 
53
55
  export interface EventWriterOptions {
54
- openaiKey?: string; anthropicKey?: string;
56
+ c0mputeKey?: string; openaiKey?: string; anthropicKey?: string;
55
57
  openaiModel?: string; claudeModel?: string;
56
58
  fetch?: typeof globalThis.fetch;
59
+ /** Per hosted provider (default 25 s). */
57
60
  timeoutMs?: number;
61
+ /** For c0mpute, which needs longer (default C0MPUTE_MIN_WAIT_MS). */
62
+ c0mputeTimeoutMs?: number;
58
63
  }
59
64
 
60
65
  /** Server-side only. Drafting has no access to the event store and cannot publish. */
61
66
  export class EventWriter {
62
67
  private readonly guard = new Guard();
63
68
  private readonly active = new Set<string>();
64
- constructor(private readonly options: EventWriterOptions) {}
69
+ // Its own router: a cancelled draft must not park c0mpute for AI radio, and
70
+ // a draft someone is waiting for must not queue behind a label's album.
71
+ private readonly c0mpute?: AiRouter;
72
+ constructor(private readonly options: EventWriterOptions) {
73
+ if (options.c0mputeKey) this.c0mpute = c0mputeRouter(options.c0mputeKey, { concurrency: 4, ...(options.fetch ? { fetcher: options.fetch } : {}) });
74
+ }
75
+
76
+ private key(provider: Exclude<Provider, "c0mpute">): string | undefined {
77
+ return provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
78
+ }
65
79
 
66
80
  async draft(accountId: string, value: unknown, signal?: AbortSignal): Promise<EventDraftResult> {
67
81
  if (!accountId) throw new EventWriterError("Sign in to use the AI writer.", 401);
68
82
  const input = writerInput(value);
69
- if (!this.options.openaiKey && !this.options.anthropicKey) throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
83
+ if (!this.c0mpute && !this.options.openaiKey && !this.options.anthropicKey) throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
70
84
  if (this.active.has(accountId)) throw new EventWriterError("A draft is already being written. Wait for it or cancel it first.", 429, 5);
71
85
  if (this.active.size >= 4) throw new EventWriterError("The AI writer is busy. Try again in a moment.", 503, 10);
72
86
  const limit = this.guard.check(accountId, {allowed: 6, windowMs: 10 * 60_000});
73
87
  if (!limit.ok) throw new EventWriterError("You have requested several drafts. Try again in a few minutes.", 429, limit.retryAfter);
74
88
  this.active.add(accountId);
75
89
  try {
76
- for (const provider of ["openai", "claude"] as const) {
77
- const key = provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
78
- if (!key) continue;
90
+ for (const provider of ["c0mpute", "openai", "claude"] as const) {
91
+ const key = provider === "c0mpute" ? "" : this.key(provider);
92
+ if (provider === "c0mpute" ? !this.c0mpute : !key) continue;
79
93
  if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
80
94
  try {
81
- const draft = await this.generate(provider, key, input, signal);
95
+ const draft = provider === "c0mpute" ? await this.generateC0mpute(input, signal) : await this.generate(provider, key as string, input, signal);
82
96
  if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
83
97
  return {draft, provider};
84
98
  }
@@ -94,6 +108,17 @@ export class EventWriter {
94
108
  } finally { this.active.delete(accountId); }
95
109
  }
96
110
 
111
+ private async generateC0mpute(input: WriterInput, signal?: AbortSignal): Promise<EventDraft> {
112
+ const {value} = await askC0mpute(this.c0mpute as AiRouter, {
113
+ system: `${INSTRUCTIONS}\n\n${jsonInstructions("event_draft", SCHEMA)}`,
114
+ user: JSON.stringify(input),
115
+ maxTokens: 2048,
116
+ timeoutMs: this.options.c0mputeTimeoutMs ?? C0MPUTE_MIN_WAIT_MS,
117
+ ...(signal ? {signal} : {}),
118
+ }, text => draftOf(firstObject(text)));
119
+ return value;
120
+ }
121
+
97
122
  private async generate(provider: "openai" | "claude", key: string, input: WriterInput, signal?: AbortSignal): Promise<EventDraft> {
98
123
  const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 25_000);
99
124
  const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -121,6 +146,6 @@ export class EventWriter {
121
146
  if (body["stop_reason"] === "refusal" || blocks.some(block => block?.type === "refusal")) throw new Refused();
122
147
  if (openai ? body["status"] !== "completed" : body["stop_reason"] !== "end_turn") throw new Error("incomplete draft");
123
148
  const text = blocks.filter(block => block?.type === (openai ? "output_text" : "text")).map(block => typeof block.text === "string" ? block.text : "").join("");
124
- return draftFrom(text);
149
+ return draftOf(JSON.parse(text));
125
150
  }
126
151
  }
@@ -202,6 +202,15 @@ export async function handleLabelRoutes(
202
202
  ctx.json(response, 202, label.status());
203
203
  return true;
204
204
  }
205
+ if (path === "/api/v1/stations/label/adopt" && method === "POST") {
206
+ // Albums already on disk (copied from another server) back on the label, with their old keys where given.
207
+ const input = await jsonBody(ctx, request);
208
+ const record = (value: unknown): Record<string, string> => value && typeof value === "object" && !Array.isArray(value)
209
+ ? Object.fromEntries(Object.entries(value as Record<string, unknown>).filter(([, one]) => typeof one === "string")) as Record<string, string>
210
+ : {};
211
+ ctx.json(response, 200, { adopted: await label.adopt({ keys: record(input["keys"]), albums: record(input["albums"]), enabled: input["enabled"] !== false }) });
212
+ return true;
213
+ }
205
214
  if (path === "/api/v1/stations/label/artists" && method === "POST") {
206
215
  const input = await jsonBody(ctx, request);
207
216
  if (input["roster"] === true) {
package/src/music-hub.ts CHANGED
@@ -45,6 +45,21 @@ export interface MusicHubOptions {
45
45
  peers: () => string[];
46
46
  fetcher?: typeof fetch;
47
47
  now?: () => number;
48
+ /**
49
+ * Label servers that moved, old https origin to new (a box gone, its label adopted
50
+ * on another): /music addresses under the old origin's id go to the new one's.
51
+ */
52
+ moved?: Record<string, string>;
53
+ }
54
+
55
+ /** NIXAMP_MUSIC_MOVED: "https://old:4321>https://new:4321", several separated by commas. */
56
+ export function parseMoved(text: string | undefined): Record<string, string> {
57
+ const moved: Record<string, string> = {};
58
+ for (const pair of (text ?? "").split(",")) {
59
+ const [from, to] = pair.split(">").map((one) => one.trim().replace(/\/+$/, ""));
60
+ if (from && to && /^https?:\/\//.test(from) && /^https?:\/\//.test(to) && from !== to) moved[from] = to;
61
+ }
62
+ return moved;
48
63
  }
49
64
 
50
65
  export class MusicHub {
@@ -60,10 +75,22 @@ export class MusicHub {
60
75
  /** The https origin behind an opaque id, among the servers known now. */
61
76
  resolve(id: string): string {
62
77
  if (!/^[a-f0-9]{10}$/.test(id)) return "";
63
- if (!this.bases.has(id)) for (const base of this.options.peers()) this.bases.set(serverId(base), base);
78
+ if (!this.bases.has(id)) for (const base of this.peers()) this.bases.set(serverId(base), base);
64
79
  return this.bases.get(id) ?? "";
65
80
  }
66
81
 
82
+ /** The servers to gather, less any that moved (the same label would be listed twice). */
83
+ private peers(): string[] {
84
+ const moved = this.options.moved ?? {};
85
+ return this.options.peers().filter((base) => !moved[base.replace(/\/+$/, "")]);
86
+ }
87
+
88
+ /** The id a moved server goes by now, or "" when this id never moved. */
89
+ movedTo(id: string): string {
90
+ for (const [from, to] of Object.entries(this.options.moved ?? {})) if (serverId(from) === id) return serverId(to);
91
+ return "";
92
+ }
93
+
67
94
  /** The catalogue, gathered at most once a minute (a slow or gone server is left out, not waited on). */
68
95
  catalogue(): Promise<HubCatalogue> {
69
96
  if (this.cache && this.now() - this.cache.at < 60_000) return this.cache.value;
@@ -85,7 +112,7 @@ export class MusicHub {
85
112
  }
86
113
 
87
114
  private async gather(): Promise<HubCatalogue> {
88
- const peers = [...new Set(this.options.peers())];
115
+ const peers = [...new Set(this.peers())];
89
116
  const holders = new Set<string>();
90
117
  const genres = new Map<string, { genre: string; stations: HubStation[]; artists: HubArtist[] }>();
91
118
  const stations: HubStation[] = [];
@@ -192,6 +219,15 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
192
219
  return true;
193
220
  }
194
221
  if (method !== "GET" && method !== "HEAD") { ctx.json(response, 405, { error: "not like that" }); return true; }
222
+ // A label that moved to another server: its old addresses (shared links, search results) go to the new ones.
223
+ const old = /^(\/music\/(?:artist|album|member)\/|\/api\/v1\/music\/(?:file|member)\/)([a-f0-9]{10})(\/.*)$/.exec(path);
224
+ const now = old ? ctx.hub.movedTo?.(old[2]!) ?? "" : "";
225
+ if (old && now) {
226
+ const query = (request.url ?? "").includes("?") ? (request.url ?? "").slice((request.url ?? "").indexOf("?")) : "";
227
+ response.writeHead(301, { location: `${old[1]}${now}${old[3]}${query}`, "cache-control": "public, max-age=86400" });
228
+ response.end();
229
+ return true;
230
+ }
195
231
  if (path === "/api/v1/music") {
196
232
  ctx.json(response, 200, await ctx.hub.catalogue());
197
233
  return true;