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/dist/ai-label.js 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.js";
34
34
  import { composerFromEnv, OutOfCredit } from "./ai-radio.js";
@@ -1742,6 +1742,54 @@ export function fileName(text) {
1742
1742
  export function genreSlug(genre) {
1743
1743
  return genre.normalize("NFKD").toLowerCase().replace(/&/g, "n").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "music";
1744
1744
  }
1745
+ /** The folders in a folder, hidden ones left out; none when it cannot be read. */
1746
+ function listDirs(folder) {
1747
+ try {
1748
+ return readdirSync(folder, { withFileTypes: true }).filter((one) => one.isDirectory() && !one.name.startsWith(".")).map((one) => one.name).sort();
1749
+ }
1750
+ catch {
1751
+ return [];
1752
+ }
1753
+ }
1754
+ function readText(file) {
1755
+ try {
1756
+ return readFileSync(file, "utf8");
1757
+ }
1758
+ catch {
1759
+ return "";
1760
+ }
1761
+ }
1762
+ function readJson(file) {
1763
+ try {
1764
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
1765
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1766
+ }
1767
+ catch {
1768
+ return null;
1769
+ }
1770
+ }
1771
+ function mtimeOf(file) {
1772
+ try {
1773
+ return Math.round(statSync(file).mtimeMs);
1774
+ }
1775
+ catch {
1776
+ return 0;
1777
+ }
1778
+ }
1779
+ /** The text under "## <heading>" in an artist.md (bandMarkdown). */
1780
+ function section(page, heading) {
1781
+ const at = page.indexOf(`\n## ${heading}\n`);
1782
+ if (at < 0)
1783
+ return "";
1784
+ const rest = page.slice(at + heading.length + 5);
1785
+ const end = rest.indexOf("\n## ");
1786
+ return (end < 0 ? rest : rest.slice(0, end)).trim();
1787
+ }
1788
+ /** The bio: the paragraph between the facts list and the first section of an artist.md. */
1789
+ function intro(page) {
1790
+ const head = page.split("\n## ")[0] ?? "";
1791
+ return head.split("\n").filter((one) => one.trim() && !one.startsWith("#") && !one.startsWith("- ")).join(" ").trim();
1792
+ }
1745
1793
  /** "001. Title", the start of a song's file names. */
1746
1794
  export function songBase(song) {
1747
1795
  return `${String(song.n).padStart(3, "0")}. ${fileName(song.title)}`;
@@ -2452,7 +2500,7 @@ export class AiLabel {
2452
2500
  const why = !this.settings.enabled ? "the label is switched off"
2453
2501
  : this.settings.pausedReason
2454
2502
  || (composer ? "" : "no composer: set ELEVENLABS_API_KEY (nixamp station ai key ELEVENLABS_API_KEY) or NIXAMP_AI_MUSIC_COMMAND")
2455
- || (this.canAsk() ? "" : "no language model to write the albums: set GEMINI_API_KEY, OPENAI_API_KEY (or NIXAMP_OPENAI_URL) or ANTHROPIC_API_KEY")
2503
+ || (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")
2456
2504
  || (this.budgetLeft() < this.settings.songSeconds ? `this month's ${this.settings.monthlySeconds} s are used` : "");
2457
2505
  return {
2458
2506
  root: this.root,
@@ -2615,7 +2663,7 @@ export class AiLabel {
2615
2663
  async plan(key) {
2616
2664
  const artist = this.require(key);
2617
2665
  if (!this.canAsk())
2618
- 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);
2666
+ 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);
2619
2667
  const pending = artist.albums.find((one) => !one.doneAt);
2620
2668
  if (pending)
2621
2669
  throw new StationError("album_in_progress", `"${pending.title}" is still being made`, 409);
@@ -2644,6 +2692,175 @@ export class AiLabel {
2644
2692
  this.log(`${artist.name} has a new album planned: "${album.title}" (${album.songs.length} songs)`);
2645
2693
  return album;
2646
2694
  }
2695
+ /**
2696
+ * Take back a catalogue that is on disk but not in the state file: the label's
2697
+ * folders copied from another server (or a state file lost with its box).
2698
+ * Every <genre>/<artist>/<year>-<album>/ under the root becomes the artist and
2699
+ * album it was, from artist.md, album.json, the members' pages and the songs'
2700
+ * own files, so /music, the genre stations, zips and purchases work again.
2701
+ * `keys` gives artists their old keys by name and `albums` their albums' by
2702
+ * "<artist key>/<album title>", so links and grants made before still match;
2703
+ * the rest get new ones. Nothing is composed or drawn and nothing is moved;
2704
+ * artists and albums already on the label are left as they are.
2705
+ */
2706
+ async adopt(input = {}) {
2707
+ const report = { artists: 0, albums: 0, songs: 0, videos: 0, oldKeys: 0, skipped: [] };
2708
+ const used = new Set([...this.artists.keys()]);
2709
+ const usedAlbums = new Set(this.list().flatMap((one) => one.albums.map((album) => album.key)));
2710
+ const keyFor = (name) => {
2711
+ const old = input.keys?.[name];
2712
+ if (old && /^[a-f0-9]{10}$/.test(old) && !used.has(old)) {
2713
+ report.oldKeys += 1;
2714
+ return old;
2715
+ }
2716
+ let key = randomBytes(5).toString("hex");
2717
+ while (used.has(key))
2718
+ key = randomBytes(5).toString("hex");
2719
+ return key;
2720
+ };
2721
+ const albumKeyFor = (artistKey, title) => {
2722
+ const old = input.albums?.[`${artistKey}/${title}`];
2723
+ if (old && /^[a-f0-9]{8}$/.test(old) && !usedAlbums.has(old))
2724
+ return old;
2725
+ let key = randomBytes(4).toString("hex");
2726
+ while (usedAlbums.has(key))
2727
+ key = randomBytes(4).toString("hex");
2728
+ return key;
2729
+ };
2730
+ const touched = new Set();
2731
+ for (const genreDir of listDirs(this.root)) {
2732
+ for (const artistDir of listDirs(join(this.root, genreDir))) {
2733
+ const folder = join(this.root, genreDir, artistDir);
2734
+ const page = readText(join(folder, "artist.md"));
2735
+ const albumDirs = listDirs(folder).filter((one) => /^\d{4}-./.test(one));
2736
+ const firstJson = albumDirs.map((one) => readJson(join(folder, one, "album.json"))).find((one) => one);
2737
+ const name = clean(/^# (.+)$/m.exec(page)?.[1] ?? firstJson?.["artist"] ?? artistDir, 80);
2738
+ const genre = clean(/^- \*\*Genre\*\*: (.+)$/m.exec(page)?.[1] ?? firstJson?.["genre"] ?? genreDir, 40);
2739
+ if (genreSlug(genre) !== genreDir || fileName(name) !== artistDir) {
2740
+ report.skipped.push(`${genreDir}/${artistDir}: its pages say ${genre}/${name}, which files elsewhere`);
2741
+ continue;
2742
+ }
2743
+ const preset = Object.values(ROSTER).find((one) => one.name.toLowerCase() === name.toLowerCase());
2744
+ let artist = this.list().find((one) => one.name.toLowerCase() === name.toLowerCase());
2745
+ if (!artist) {
2746
+ const key = keyFor(name);
2747
+ used.add(key);
2748
+ artist = {
2749
+ key, name, genre,
2750
+ sound: clean(section(page, "Sound") || preset?.sound || genre, 600),
2751
+ voice: clean(section(page, "Lead voice") || preset?.voice || "expressive lead vocal", 300),
2752
+ bio: clean(intro(page) || preset?.bio || "", 400),
2753
+ themes: clean(section(page, "Themes") || preset?.themes || "", 300),
2754
+ enabled: input.enabled !== false,
2755
+ albums: [],
2756
+ createdAt: this.now(),
2757
+ lastAttemptAt: 0,
2758
+ lastError: "",
2759
+ failures: 0,
2760
+ };
2761
+ const members = [...page.matchAll(/\(members\/([a-z0-9-]{1,40})\.md\)/g)].map((one) => one[1])
2762
+ .filter((slug) => existsSync(join(folder, "members", `${slug}.md`)));
2763
+ if (members.length)
2764
+ artist.members = members;
2765
+ this.artists.set(artist.key, artist);
2766
+ report.artists += 1;
2767
+ }
2768
+ for (const albumDir of albumDirs) {
2769
+ const albumFolder = join(folder, albumDir);
2770
+ const json = readJson(join(albumFolder, "album.json")) ?? {};
2771
+ const title = clean(json["album"] ?? albumDir.replace(/^\d{4}-/, ""), 120);
2772
+ if (artist.albums.some((one) => one.title.toLowerCase() === title.toLowerCase()))
2773
+ continue;
2774
+ const planned = Array.isArray(json["songs"]) ? json["songs"] : [];
2775
+ const files = readdirSync(albumFolder).filter((one) => /^\d{3}\. .+\.mp3$/.test(one));
2776
+ 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));
2777
+ const songs = [];
2778
+ for (const n of [...numbers].sort((a, b) => a - b)) {
2779
+ const entry = planned.find((one) => Number(one["n"]) === n);
2780
+ const mp3 = files.find((one) => Number(one.slice(0, 3)) === n);
2781
+ const base = mp3 ? mp3.replace(/\.mp3$/, "") : "";
2782
+ const songTitle = clean(entry?.["title"] ?? base.replace(/^\d{3}\. /, ""), 120);
2783
+ const at = (ext) => base && existsSync(join(albumFolder, `${base}${ext}`)) ? join(albumFolder, `${base}${ext}`) : "";
2784
+ const file = mp3 ? join(albumFolder, mp3) : "";
2785
+ let seconds = Number(entry?.["seconds"]) || this.settings.songSeconds;
2786
+ let made = "";
2787
+ if (file) {
2788
+ try {
2789
+ const probed = await this.deps.probe(file);
2790
+ if (probed.duration > 0)
2791
+ seconds = Math.round(probed.duration);
2792
+ }
2793
+ catch { /* the planned length */ }
2794
+ made = "adopted";
2795
+ }
2796
+ const lyricsFile = at(".lyrics.txt");
2797
+ const song = {
2798
+ n, title: songTitle, style: clean(entry?.["style"] ?? json["concept"] ?? "", 600), lyrics: lyricsFile ? readText(lyricsFile).trim() : "",
2799
+ seconds, file, lyricsFile, made, composedAt: file ? mtimeOf(file) : 0,
2800
+ };
2801
+ const lrc = at(".lrc");
2802
+ if (lrc)
2803
+ song.lrc = lrc;
2804
+ const video = at(".mp4");
2805
+ if (video && file) {
2806
+ song.video = video;
2807
+ song.videoTagged = true;
2808
+ // Filmed videos are 1080p Veo footage (tens of Mbit/s); the older cover-and-waveform ones were never shown.
2809
+ const filmed = await this.isFilmed(video);
2810
+ song.videoVersion = filmed ? FILMED : 1;
2811
+ // Whether its singer was synced is not on disk; it is not filmed again for that.
2812
+ if (filmed)
2813
+ song.singer = "unsynced";
2814
+ if (filmed)
2815
+ report.videos += 1;
2816
+ }
2817
+ songs.push(song);
2818
+ if (file)
2819
+ report.songs += 1;
2820
+ }
2821
+ if (songs.length === 0)
2822
+ continue;
2823
+ const key = albumKeyFor(artist.key, title);
2824
+ usedAlbums.add(key);
2825
+ const year = Number(json["year"]) || Number(albumDir.slice(0, 4)) || new Date(this.now()).getUTCFullYear();
2826
+ const times = songs.filter((one) => one.file).map((one) => one.composedAt);
2827
+ const cover = existsSync(join(albumFolder, "cover.jpg")) ? join(albumFolder, "cover.jpg") : "";
2828
+ artist.albums.push({
2829
+ key, title, year,
2830
+ concept: clean(json["concept"] ?? "", 600),
2831
+ folder: albumFolder,
2832
+ cover,
2833
+ coverTried: true,
2834
+ songs,
2835
+ writer: "",
2836
+ plannedAt: times.length ? Math.min(...times) : mtimeOf(albumFolder),
2837
+ doneAt: songs.every((one) => one.file) ? Math.max(...times) : 0,
2838
+ });
2839
+ artist.albums.sort((a, b) => a.plannedAt - b.plannedAt);
2840
+ artist.createdAt = Math.min(artist.createdAt, artist.albums[0].plannedAt || artist.createdAt);
2841
+ report.albums += 1;
2842
+ touched.add(artist.genre);
2843
+ }
2844
+ }
2845
+ }
2846
+ this.save();
2847
+ for (const genre of touched)
2848
+ await this.place(genre).catch((error) => this.log(`could not put ${genre} on the air: ${error.message}`));
2849
+ this.log(`adopted ${report.artists} artists, ${report.albums} albums, ${report.songs} songs (${report.oldKeys} kept their old keys)`);
2850
+ return report;
2851
+ }
2852
+ /** A music video cut from Veo footage, by its bit rate: those are 1080p at tens of Mbit/s, the old slideshows a couple. */
2853
+ async isFilmed(video) {
2854
+ try {
2855
+ const probed = await this.deps.probe(video);
2856
+ if (!(probed.duration > 0))
2857
+ return false;
2858
+ return (statSync(video).size * 8) / probed.duration > 8_000_000;
2859
+ }
2860
+ catch {
2861
+ return false;
2862
+ }
2863
+ }
2647
2864
  startSchedule(everyMs = 5 * 60_000) {
2648
2865
  if (this.timer)
2649
2866
  return;
@@ -3038,7 +3255,7 @@ export class AiLabel {
3038
3255
  const files = this.list()
3039
3256
  .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => ((song.videoVersion ?? 1) >= SHOWN ? song.video ?? "" : ""))))
3040
3257
  .filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
3041
- const existing = this.deps.store.byAuto("ai:label:videos");
3258
+ const existing = this.claimed("ai:label:videos", `${this.labelName()} AI · Music Videos`);
3042
3259
  if (files.length === 0) {
3043
3260
  // Nothing filmed yet (older slideshow videos are not shown): off the guide until there is.
3044
3261
  if (existing && existing.listed)
@@ -3342,7 +3559,22 @@ export class AiLabel {
3342
3559
  return [...new Set(this.list().map((one) => one.genre))].sort();
3343
3560
  }
3344
3561
  stationFor(genre) {
3345
- return this.deps.store.byAuto(`ai:label:${genreSlug(genre)}`);
3562
+ return this.claimed(`ai:label:${genreSlug(genre)}`, `${this.labelName()} AI · ${genre}`);
3563
+ }
3564
+ /** "Profullstack" for "Profullstack, Inc.": how the label's stations are named. */
3565
+ labelName() {
3566
+ return this.settings.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
3567
+ }
3568
+ /**
3569
+ * The label's station for this key; else one made by hand under the very name the
3570
+ * label would give it (a label rebuilt on another server), taken over, not doubled.
3571
+ */
3572
+ claimed(key, name) {
3573
+ const marked = this.deps.store.byAuto(key);
3574
+ if (marked)
3575
+ return marked;
3576
+ const named = this.deps.store.list().find((one) => !one.auto && one.name === name);
3577
+ return named ? this.deps.store.markAuto(named.id, key) : undefined;
3346
3578
  }
3347
3579
  /** The genre's station gets every song of that genre: made with the first, then kept in step. */
3348
3580
  place(genre) {
package/dist/ai-text.d.ts CHANGED
@@ -1,9 +1,20 @@
1
+ /**
2
+ * One question to a language model, one JSON answer.
3
+ *
4
+ * AI radio asks for small things: a title and a prompt for the next track, a
5
+ * genre and a verdict for a shared file. Whichever model has a key answers --
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
+ */
11
+ import { type AiRouter } from "@profullstack/ai-providers";
1
12
  export interface AskOptions {
2
13
  /** What the model is for, said once. */
3
14
  system: string;
4
15
  /** The question, with what it should answer from. */
5
16
  prompt: string;
6
- /** Milliseconds before giving up (default 30 s). */
17
+ /** Milliseconds before giving up on a hosted model (default 30 s); c0mpute gets at least C0MPUTE_MIN_WAIT_MS. */
7
18
  timeoutMs?: number;
8
19
  /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
9
20
  maxTokens?: number;
@@ -27,6 +38,36 @@ export declare class NoModel extends Error {
27
38
  export declare function escapeInStrings(text: string): string;
28
39
  /** The first JSON object in a model's answer. */
29
40
  export declare function firstObject(text: string): Record<string, unknown>;
41
+ /** The c0mpute key, or "" when there is none or C0MPUTE_DISABLED turns it off. */
42
+ export declare function c0mputeKey(env?: Record<string, string | undefined>): string;
30
43
  /** Whether any model can be asked. */
31
44
  export declare function hasModel(env?: Record<string, string | undefined>): boolean;
45
+ /**
46
+ * c0mpute's 9B model takes 30 s for a short JSON answer, longer on a busy
47
+ * node, so it never gets a hosted model's 30 s budget.
48
+ */
49
+ export declare const C0MPUTE_MIN_WAIT_MS = 120000;
50
+ /** c0mpute alone: the fallbacks are this file's own chain, not the package's. */
51
+ export declare function c0mputeRouter(key: string, options?: {
52
+ env?: Record<string, string | undefined>;
53
+ fetcher?: typeof fetch;
54
+ concurrency?: number;
55
+ }): AiRouter;
56
+ export interface C0mputeAsk {
57
+ system: string;
58
+ user: string;
59
+ maxTokens: number;
60
+ timeoutMs: number;
61
+ temperature?: number;
62
+ signal?: AbortSignal;
63
+ }
64
+ /**
65
+ * One answer from c0mpute, read by `read`. An answer `read` refuses gets one
66
+ * more try on the same model, told what was wrong with it, before the caller
67
+ * moves on: a small model misses far more often than a hosted one.
68
+ */
69
+ export declare function askC0mpute<T>(router: AiRouter, ask: C0mputeAsk, read: (text: string) => T): Promise<{
70
+ model: string;
71
+ value: T;
72
+ }>;
32
73
  export declare function askJson(options: AskOptions): Promise<Answer>;
package/dist/ai-text.js CHANGED
@@ -3,15 +3,16 @@
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 } from "@profullstack/ai-providers";
11
12
  import { aiKey } from "./ai-keys.js";
12
13
  export class NoModel extends Error {
13
14
  constructor() {
14
- super("no language model key is set (GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
15
+ super("no language model key is set (C0MPUTE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
15
16
  }
16
17
  }
17
18
  /**
@@ -49,7 +50,7 @@ export function escapeInStrings(text) {
49
50
  }
50
51
  /** The first JSON object in a model's answer. */
51
52
  export function firstObject(text) {
52
- const fenced = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
53
+ const fenced = text.replace(/<think>[\s\S]*?<\/think>/gi, "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
53
54
  const cleaned = (() => {
54
55
  try {
55
56
  JSON.parse(fenced);
@@ -76,13 +77,67 @@ export function firstObject(text) {
76
77
  }
77
78
  throw new Error("the model did not answer with a JSON object");
78
79
  }
80
+ /** The c0mpute key, or "" when there is none or C0MPUTE_DISABLED turns it off. */
81
+ export function c0mputeKey(env = process.env) {
82
+ return /^(1|true|yes|on)$/i.test(env["C0MPUTE_DISABLED"] ?? "") ? "" : aiKey("C0MPUTE_API_KEY", env);
83
+ }
79
84
  /** Whether any model can be asked. */
80
85
  export function hasModel(env = process.env) {
81
- return Boolean(aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
86
+ return Boolean(c0mputeKey(env) || aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
87
+ }
88
+ /**
89
+ * c0mpute's 9B model takes 30 s for a short JSON answer, longer on a busy
90
+ * node, so it never gets a hosted model's 30 s budget.
91
+ */
92
+ export const C0MPUTE_MIN_WAIT_MS = 120_000;
93
+ /** c0mpute alone: the fallbacks are this file's own chain, not the package's. */
94
+ export function c0mputeRouter(key, options = {}) {
95
+ return createAiProviders({
96
+ env: { ...(options.env ?? process.env), C0MPUTE_API_KEY: key },
97
+ paidFallback: false,
98
+ c0mpute: {
99
+ ...(options.fetcher ? { fetch: options.fetcher } : {}),
100
+ ...(options.concurrency ? { concurrency: options.concurrency } : {}),
101
+ },
102
+ });
103
+ }
104
+ /**
105
+ * One answer from c0mpute, read by `read`. An answer `read` refuses gets one
106
+ * more try on the same model, told what was wrong with it, before the caller
107
+ * moves on: a small model misses far more often than a hosted one.
108
+ */
109
+ export async function askC0mpute(router, ask, read) {
110
+ const { value } = await router.run(async (provider) => {
111
+ const first = await provider.generate(ask);
112
+ try {
113
+ return { model: first.model, value: read(first.text) };
114
+ }
115
+ catch (error) {
116
+ const again = await provider.generate({
117
+ ...ask,
118
+ user: `${ask.user}\n\nYour previous answer could not be used: ${error.message}\nAnswer again with only the corrected JSON object.`,
119
+ });
120
+ try {
121
+ return { model: again.model, value: read(again.text) };
122
+ }
123
+ catch (second) {
124
+ throw new ProviderError("c0mpute", second.message, { kind: "output" });
125
+ }
126
+ }
127
+ });
128
+ return value;
129
+ }
130
+ /** The router askJson shares across calls, so a dead or busy c0mpute is parked for all of them. */
131
+ let shared;
132
+ function sharedRouter(key, env) {
133
+ if (shared?.key !== key)
134
+ shared = { key, router: c0mputeRouter(key, { env }) };
135
+ return shared.router;
82
136
  }
83
137
  export async function askJson(options) {
84
138
  const env = options.env ?? process.env;
85
139
  const send = options.fetcher ?? fetch;
140
+ const c0mpute = c0mputeKey(env);
86
141
  const gemini = aiKey("GEMINI_API_KEY", env);
87
142
  const openai = aiKey("OPENAI_API_KEY", env);
88
143
  const anthropic = aiKey("ANTHROPIC_API_KEY", env);
@@ -90,6 +145,13 @@ export async function askJson(options) {
90
145
  // answers 400 "User location is not supported" from some networks) or is
91
146
  // down is not a reason to go without when another can answer.
92
147
  const tries = [];
148
+ if (c0mpute)
149
+ tries.push(() => askC0mpute(options.fetcher ? c0mputeRouter(c0mpute, { env, fetcher: options.fetcher }) : sharedRouter(c0mpute, env), {
150
+ system: `${options.system}\nAnswer with one JSON object and nothing else.`,
151
+ user: options.prompt,
152
+ maxTokens: options.maxTokens ?? 1024,
153
+ timeoutMs: Math.max(options.timeoutMs ?? 0, C0MPUTE_MIN_WAIT_MS),
154
+ }, firstObject));
93
155
  if (gemini)
94
156
  tries.push(async () => {
95
157
  const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
@@ -10,9 +10,10 @@ export interface WriterInput extends EventDraft {
10
10
  duration: string;
11
11
  recurrence: string;
12
12
  }
13
+ type Provider = "c0mpute" | "openai" | "claude";
13
14
  export interface EventDraftResult {
14
15
  draft: EventDraft;
15
- provider: "openai" | "claude";
16
+ provider: Provider;
16
17
  }
17
18
  export declare class EventWriterError extends Error {
18
19
  readonly status: number;
@@ -21,19 +22,27 @@ export declare class EventWriterError extends Error {
21
22
  }
22
23
  export declare function writerInput(value: unknown): WriterInput;
23
24
  export interface EventWriterOptions {
25
+ c0mputeKey?: string;
24
26
  openaiKey?: string;
25
27
  anthropicKey?: string;
26
28
  openaiModel?: string;
27
29
  claudeModel?: string;
28
30
  fetch?: typeof globalThis.fetch;
31
+ /** Per hosted provider (default 25 s). */
29
32
  timeoutMs?: number;
33
+ /** For c0mpute, which needs longer (default C0MPUTE_MIN_WAIT_MS). */
34
+ c0mputeTimeoutMs?: number;
30
35
  }
31
36
  /** Server-side only. Drafting has no access to the event store and cannot publish. */
32
37
  export declare class EventWriter {
33
38
  private readonly options;
34
39
  private readonly guard;
35
40
  private readonly active;
41
+ private readonly c0mpute?;
36
42
  constructor(options: EventWriterOptions);
43
+ private key;
37
44
  draft(accountId: string, value: unknown, signal?: AbortSignal): Promise<EventDraftResult>;
45
+ private generateC0mpute;
38
46
  private generate;
39
47
  }
48
+ export {};
@@ -1,3 +1,5 @@
1
+ import { jsonInstructions } from "@profullstack/ai-providers";
2
+ import { askC0mpute, c0mputeRouter, C0MPUTE_MIN_WAIT_MS, firstObject } from "./ai-text.js";
1
3
  import { Guard } from "./guard.js";
2
4
  export class EventWriterError extends Error {
3
5
  status;
@@ -37,8 +39,7 @@ Return a clear title (at most 160 characters), a description (at most 5000 chara
37
39
  Write in the language of the host's notes. Describe what students will learn and what will happen, using only information supported by the notes.
38
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.
39
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.`;
40
- function draftFrom(text) {
41
- const value = JSON.parse(text);
42
+ function draftOf(value) {
42
43
  if (!value || typeof value !== "object" || Array.isArray(value))
43
44
  throw new Error("invalid draft");
44
45
  const row = value;
@@ -56,14 +57,22 @@ export class EventWriter {
56
57
  options;
57
58
  guard = new Guard();
58
59
  active = new Set();
60
+ // Its own router: a cancelled draft must not park c0mpute for AI radio, and
61
+ // a draft someone is waiting for must not queue behind a label's album.
62
+ c0mpute;
59
63
  constructor(options) {
60
64
  this.options = options;
65
+ if (options.c0mputeKey)
66
+ this.c0mpute = c0mputeRouter(options.c0mputeKey, { concurrency: 4, ...(options.fetch ? { fetcher: options.fetch } : {}) });
67
+ }
68
+ key(provider) {
69
+ return provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
61
70
  }
62
71
  async draft(accountId, value, signal) {
63
72
  if (!accountId)
64
73
  throw new EventWriterError("Sign in to use the AI writer.", 401);
65
74
  const input = writerInput(value);
66
- if (!this.options.openaiKey && !this.options.anthropicKey)
75
+ if (!this.c0mpute && !this.options.openaiKey && !this.options.anthropicKey)
67
76
  throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
68
77
  if (this.active.has(accountId))
69
78
  throw new EventWriterError("A draft is already being written. Wait for it or cancel it first.", 429, 5);
@@ -74,14 +83,14 @@ export class EventWriter {
74
83
  throw new EventWriterError("You have requested several drafts. Try again in a few minutes.", 429, limit.retryAfter);
75
84
  this.active.add(accountId);
76
85
  try {
77
- for (const provider of ["openai", "claude"]) {
78
- const key = provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
79
- if (!key)
86
+ for (const provider of ["c0mpute", "openai", "claude"]) {
87
+ const key = provider === "c0mpute" ? "" : this.key(provider);
88
+ if (provider === "c0mpute" ? !this.c0mpute : !key)
80
89
  continue;
81
90
  if (signal?.aborted)
82
91
  throw new EventWriterError("Draft cancelled.", 499);
83
92
  try {
84
- const draft = await this.generate(provider, key, input, signal);
93
+ const draft = provider === "c0mpute" ? await this.generateC0mpute(input, signal) : await this.generate(provider, key, input, signal);
85
94
  if (signal?.aborted)
86
95
  throw new EventWriterError("Draft cancelled.", 499);
87
96
  return { draft, provider };
@@ -102,6 +111,16 @@ export class EventWriter {
102
111
  this.active.delete(accountId);
103
112
  }
104
113
  }
114
+ async generateC0mpute(input, signal) {
115
+ const { value } = await askC0mpute(this.c0mpute, {
116
+ system: `${INSTRUCTIONS}\n\n${jsonInstructions("event_draft", SCHEMA)}`,
117
+ user: JSON.stringify(input),
118
+ maxTokens: 2048,
119
+ timeoutMs: this.options.c0mputeTimeoutMs ?? C0MPUTE_MIN_WAIT_MS,
120
+ ...(signal ? { signal } : {}),
121
+ }, text => draftOf(firstObject(text)));
122
+ return value;
123
+ }
105
124
  async generate(provider, key, input, signal) {
106
125
  const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 25_000);
107
126
  const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -134,6 +153,6 @@ export class EventWriter {
134
153
  if (openai ? body["status"] !== "completed" : body["stop_reason"] !== "end_turn")
135
154
  throw new Error("incomplete draft");
136
155
  const text = blocks.filter(block => block?.type === (openai ? "output_text" : "text")).map(block => typeof block.text === "string" ? block.text : "").join("");
137
- return draftFrom(text);
156
+ return draftOf(JSON.parse(text));
138
157
  }
139
158
  }
@@ -181,6 +181,15 @@ export async function handleLabelRoutes(request, response, path, ctx) {
181
181
  ctx.json(response, 202, label.status());
182
182
  return true;
183
183
  }
184
+ if (path === "/api/v1/stations/label/adopt" && method === "POST") {
185
+ // Albums already on disk (copied from another server) back on the label, with their old keys where given.
186
+ const input = await jsonBody(ctx, request);
187
+ const record = (value) => value && typeof value === "object" && !Array.isArray(value)
188
+ ? Object.fromEntries(Object.entries(value).filter(([, one]) => typeof one === "string"))
189
+ : {};
190
+ ctx.json(response, 200, { adopted: await label.adopt({ keys: record(input["keys"]), albums: record(input["albums"]), enabled: input["enabled"] !== false }) });
191
+ return true;
192
+ }
184
193
  if (path === "/api/v1/stations/label/artists" && method === "POST") {
185
194
  const input = await jsonBody(ctx, request);
186
195
  if (input["roster"] === true) {