nixamp 0.78.6 → 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.
- package/README.md +6 -5
- package/backtoschool/dist/assets/{ar-BBh9pRWn.js → ar-BYDVUbTZ.js} +1 -1
- package/backtoschool/dist/assets/{cs-DYDz-hZb.js → cs-DsCx7BAy.js} +1 -1
- package/backtoschool/dist/assets/{da-DL0tbrTr.js → da-5tLM7Wal.js} +1 -1
- package/backtoschool/dist/assets/{de-POU_MZN1.js → de-2Q8k6IKm.js} +1 -1
- package/backtoschool/dist/assets/{es-VLEhXfMp.js → es-CVwFkQ0E.js} +1 -1
- package/backtoschool/dist/assets/{fi-B__GWwbQ.js → fi-D7KuvUvL.js} +1 -1
- package/backtoschool/dist/assets/{fr-DDtDAMct.js → fr-CkWg-nDv.js} +1 -1
- package/backtoschool/dist/assets/{hi-C6MybeLD.js → hi-DayNvhO4.js} +1 -1
- package/backtoschool/dist/assets/{hls-OR5FY6YP-CL3tmveA.js → hls-OR5FY6YP-D2hwLr-P.js} +1 -1
- package/backtoschool/dist/assets/{hu-CgiQxTUY.js → hu-BqS46UyM.js} +1 -1
- package/backtoschool/dist/assets/{id-pzntv5iA.js → id-DTiKw_BS.js} +1 -1
- package/backtoschool/dist/assets/{index-DeCDKrPz.js → index-EM-0SwBp.js} +2 -2
- package/backtoschool/dist/assets/{it-dKw8vCrE.js → it-B60apeGa.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-B6-Xba87.js → mpegts-C4v5WI5W.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-Y2FARLFE-MgjbiVoD.js → mpegts-Y2FARLFE-D4OTyGyp.js} +1 -1
- package/backtoschool/dist/assets/{nl-DZ0o7967.js → nl-DctlIuWS.js} +1 -1
- package/backtoschool/dist/assets/{ru-s6C6TpM6.js → ru-CO8g0gfi.js} +1 -1
- package/backtoschool/dist/assets/{sv-BHj0BZlD.js → sv-CoFMz-Qg.js} +1 -1
- package/backtoschool/dist/assets/{uk-BH7Y4zb1.js → uk-C3_GprSP.js} +1 -1
- package/backtoschool/dist/assets/{vi-nf4HfTQk.js → vi-B0RDG4iN.js} +1 -1
- package/backtoschool/dist/assets/{zh-CT2P8jxm.js → zh--yO6izcR.js} +1 -1
- package/backtoschool/dist/index.html +1 -1
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +1 -1
- package/dist/ai-label.d.ts +38 -0
- package/dist/ai-label.js +237 -5
- package/dist/ai-text.d.ts +42 -1
- package/dist/ai-text.js +69 -7
- package/dist/event-writer.d.ts +10 -1
- package/dist/event-writer.js +27 -8
- package/dist/label-routes.js +9 -0
- package/dist/library-mcp.d.ts +6 -6
- package/dist/music-hub.d.ts +11 -0
- package/dist/music-hub.js +33 -2
- package/dist/nixamp-link-api.d.ts +5 -1
- package/dist/nixamp-link-api.js +12 -2
- package/dist/nixamp-link-types.d.ts +2 -0
- package/dist/nixamp-link.d.ts +22 -9
- package/dist/nixamp-link.js +75 -58
- package/dist/oauth.d.ts +1 -0
- package/dist/oauth.js +8 -1
- package/dist/server.js +18 -9
- package/dist/station-cli.js +24 -0
- package/dist/station-mcp.d.ts +6 -6
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +4 -1
- package/dist/universal-footer.d.ts +2 -0
- package/dist/universal-footer.js +39 -0
- package/package.json +7 -5
- package/src/ai-keys.ts +1 -1
- package/src/ai-label.ts +240 -5
- package/src/ai-text.ts +84 -8
- package/src/event-writer.ts +36 -11
- package/src/label-routes.ts +9 -0
- package/src/music-hub.ts +38 -2
- package/src/nixamp-link-api.ts +15 -3
- package/src/nixamp-link-types.ts +2 -0
- package/src/nixamp-link.ts +77 -61
- package/src/oauth.ts +9 -1
- package/src/server.ts +18 -9
- package/src/station-cli.ts +23 -0
- package/src/station-usage.ts +4 -1
- package/src/universal-footer.ts +41 -0
- package/web/dist/assets/{ar-7tVGBWgq.js → ar-C5qVRZh9.js} +1 -1
- package/web/dist/assets/{chunk-55GBNX64-BDcCEJUP.js → chunk-NW3WDGEQ-CnyjTRUh.js} +1 -1
- package/web/dist/assets/{cs-hTmkOWFS.js → cs-fajZsJrs.js} +1 -1
- package/web/dist/assets/{da-218F21ho.js → da-fycV1k1N.js} +1 -1
- package/web/dist/assets/{de-DesLnw7U.js → de-QUzTQ_9X.js} +1 -1
- package/web/dist/assets/{es-Bc9fW4qY.js → es-CQu2leAJ.js} +1 -1
- package/web/dist/assets/{fi-Df_rWRQA.js → fi-AgKEsukC.js} +1 -1
- package/web/dist/assets/{fr-D8xzxUiQ.js → fr-CRbHnW4y.js} +1 -1
- package/web/dist/assets/{hi-Ddh5AA1z.js → hi-BuU7xy9L.js} +1 -1
- package/web/dist/assets/{hls-OR5FY6YP-_WOdANsC.js → hls-OR5FY6YP-BxZwqFB9.js} +1 -1
- package/web/dist/assets/{hu-ChrJNlJD.js → hu-C40Z-HcJ.js} +1 -1
- package/web/dist/assets/{id-dNB_py4J.js → id-2b7626UL.js} +1 -1
- package/web/dist/assets/{it-DyHyXE3N.js → it-7sr3nJuS.js} +1 -1
- package/web/dist/assets/main-zuI3sOLC.js +33 -0
- package/web/dist/assets/{mpegts-CL9RC5Ls.js → mpegts-DPRtdBXL.js} +1 -1
- package/web/dist/assets/{mpegts-Y2FARLFE-C91SD8Pl.js → mpegts-Y2FARLFE-BY7parIQ.js} +1 -1
- package/web/dist/assets/{nl-DlwRNqNC.js → nl-BdDGgfzj.js} +1 -1
- package/web/dist/assets/{radio-BnQ78a-O.js → radio-CeRhNjpU.js} +1 -1
- package/web/dist/assets/{ru--N-8v6QS.js → ru-BMGI8Oa4.js} +1 -1
- package/web/dist/assets/{station-C_6L4CDU.js → station-D5tW9jq4.js} +1 -1
- package/web/dist/assets/station-D6KfgFXf.css +1 -0
- package/web/dist/assets/{sv-DbR0DO05.js → sv-BXASdl7c.js} +1 -1
- package/web/dist/assets/{uk-Bi3fEoz1.js → uk-urnhuqtG.js} +1 -1
- package/web/dist/assets/{vi-DqrcyLEz.js → vi-BhLPAKuD.js} +1 -1
- package/web/dist/assets/{vod-CbSC5NFA.js → vod-Dt73bt8P.js} +1 -1
- package/web/dist/assets/{watch-D-ZRxfJ_.js → watch-GxgHBQcI.js} +1 -1
- package/web/dist/assets/{zh-_4uBqFzc.js → zh-BaOCIB9u.js} +1 -1
- package/web/dist/index.html +58 -12
- package/web/dist/music.html +1 -1
- package/web/dist/radio.html +5 -5
- package/web/dist/station.html +4 -4
- package/web/dist/sw.js +30 -30
- package/web/dist/vod.html +4 -4
- package/web/dist/watch.html +4 -4
- package/dist/pit-footer.d.ts +0 -3
- package/dist/pit-footer.js +0 -40
- package/src/pit-footer.ts +0 -40
- package/web/dist/assets/main-D9XA9svM.js +0 -26
- package/web/dist/assets/station-CxJvFwzV.css +0 -1
- /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.
|
|
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.
|
|
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
|
|
7
|
-
* so and does without (a rule of its own, or a person). The
|
|
8
|
-
* and handed back as an object; a model that rambles round
|
|
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";
|
package/dist/event-writer.d.ts
CHANGED
|
@@ -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:
|
|
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 {};
|
package/dist/event-writer.js
CHANGED
|
@@ -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
|
|
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 === "
|
|
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
|
|
156
|
+
return draftOf(JSON.parse(text));
|
|
138
157
|
}
|
|
139
158
|
}
|
package/dist/label-routes.js
CHANGED
|
@@ -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) {
|