nixamp 0.78.6 → 0.80.2
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 +238 -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 +241 -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-jQ_1OaPw.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-_goMpUzl.js} +1 -1
- package/web/dist/assets/{da-218F21ho.js → da-BgRIZmHA.js} +1 -1
- package/web/dist/assets/{de-DesLnw7U.js → de-BzryMgxO.js} +1 -1
- package/web/dist/assets/{es-Bc9fW4qY.js → es-Bspt-kuE.js} +1 -1
- package/web/dist/assets/{fi-Df_rWRQA.js → fi-74bqR7jK.js} +1 -1
- package/web/dist/assets/{fr-D8xzxUiQ.js → fr-C0yUFanu.js} +1 -1
- package/web/dist/assets/{hi-Ddh5AA1z.js → hi-Bow4uBzj.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-C_2Dn0h7.js} +1 -1
- package/web/dist/assets/{id-dNB_py4J.js → id-C8L6KxB3.js} +1 -1
- package/web/dist/assets/{it-DyHyXE3N.js → it-DMNQHxWL.js} +1 -1
- package/web/dist/assets/main-C5xMXFFr.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-8e4KRSbc.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-VCpBV7hb.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-9BcESvUT.js} +1 -1
- package/web/dist/assets/{uk-Bi3fEoz1.js → uk-D0xNvfSC.js} +1 -1
- package/web/dist/assets/{vi-DqrcyLEz.js → vi-CTmBIxVv.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-BQ28dg08.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/station-cli.js
CHANGED
|
@@ -552,6 +552,30 @@ async function labelCommand(argv, call, show) {
|
|
|
552
552
|
return `Label: ${String(settings["holder"])}, ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s an album, every ${String(settings["everyDays"])} days, at most ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : `${String(settings["monthlySeconds"])} s`} a month${settings["enabled"] ? "" : " (off)"}.`;
|
|
553
553
|
});
|
|
554
554
|
}
|
|
555
|
+
case "adopt": {
|
|
556
|
+
// Albums already under the label's folder (copied from another server) back on the label.
|
|
557
|
+
// --keys FILE: {"artists": {"<name>": "<old key>"}, "albums": {"<artist key>/<album title>": "<old key>"}}.
|
|
558
|
+
const file = flag(argv, "--keys");
|
|
559
|
+
let keys = {};
|
|
560
|
+
if (file) {
|
|
561
|
+
try {
|
|
562
|
+
keys = JSON.parse((await import("node:fs")).readFileSync(file, "utf8"));
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
console.error(`nixamp station label adopt: could not read ${file}: ${error.message}`);
|
|
566
|
+
return 2;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const body = { keys: keys["artists"] ?? {}, albums: keys["albums"] ?? {}, ...(has(argv, "--off") ? { enabled: false } : {}) };
|
|
570
|
+
return show(await call("POST", "/api/v1/stations/label/adopt", body), (answer) => {
|
|
571
|
+
const done = answer["adopted"];
|
|
572
|
+
const skipped = done["skipped"] ?? [];
|
|
573
|
+
return [
|
|
574
|
+
`Adopted ${String(done["artists"])} artists, ${String(done["albums"])} albums, ${String(done["songs"])} songs and ${String(done["videos"])} filmed videos; ${String(done["oldKeys"])} artists kept their old keys.`,
|
|
575
|
+
...skipped.map((one) => ` skipped ${one}`),
|
|
576
|
+
].join("\n");
|
|
577
|
+
});
|
|
578
|
+
}
|
|
555
579
|
case "run":
|
|
556
580
|
return show(await call("POST", "/api/v1/stations/label/run", {}), (body) => body["waiting"] ? `Nothing to record now: ${String(body["waiting"])}.` : "Recording whatever is due.");
|
|
557
581
|
case "resume":
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const STATION_TOOLS: ({
|
|
|
2
2
|
name: string;
|
|
3
3
|
description: string;
|
|
4
4
|
inputSchema: {
|
|
5
|
+
required?: undefined;
|
|
5
6
|
type: string;
|
|
6
7
|
properties: {
|
|
7
8
|
server: {
|
|
@@ -72,7 +73,6 @@ export declare const STATION_TOOLS: ({
|
|
|
72
73
|
id?: undefined;
|
|
73
74
|
private?: undefined;
|
|
74
75
|
};
|
|
75
|
-
required?: undefined;
|
|
76
76
|
};
|
|
77
77
|
} | {
|
|
78
78
|
name: string;
|
|
@@ -531,6 +531,7 @@ export declare const STATION_TOOLS: ({
|
|
|
531
531
|
name: string;
|
|
532
532
|
description: string;
|
|
533
533
|
inputSchema: {
|
|
534
|
+
required?: undefined;
|
|
534
535
|
type: string;
|
|
535
536
|
properties: {
|
|
536
537
|
playlist?: undefined;
|
|
@@ -615,12 +616,12 @@ export declare const STATION_TOOLS: ({
|
|
|
615
616
|
id?: undefined;
|
|
616
617
|
private?: undefined;
|
|
617
618
|
};
|
|
618
|
-
required?: undefined;
|
|
619
619
|
};
|
|
620
620
|
} | {
|
|
621
621
|
name: string;
|
|
622
622
|
description: string;
|
|
623
623
|
inputSchema: {
|
|
624
|
+
required?: undefined;
|
|
624
625
|
type: string;
|
|
625
626
|
properties: {
|
|
626
627
|
playlist?: undefined;
|
|
@@ -718,12 +719,12 @@ export declare const STATION_TOOLS: ({
|
|
|
718
719
|
id?: undefined;
|
|
719
720
|
private?: undefined;
|
|
720
721
|
};
|
|
721
|
-
required?: undefined;
|
|
722
722
|
};
|
|
723
723
|
} | {
|
|
724
724
|
name: string;
|
|
725
725
|
description: string;
|
|
726
726
|
inputSchema: {
|
|
727
|
+
required?: undefined;
|
|
727
728
|
type: string;
|
|
728
729
|
properties: {
|
|
729
730
|
playlist?: undefined;
|
|
@@ -862,12 +863,12 @@ export declare const STATION_TOOLS: ({
|
|
|
862
863
|
id?: undefined;
|
|
863
864
|
private?: undefined;
|
|
864
865
|
};
|
|
865
|
-
required?: undefined;
|
|
866
866
|
};
|
|
867
867
|
} | {
|
|
868
868
|
name: string;
|
|
869
869
|
description: string;
|
|
870
870
|
inputSchema: {
|
|
871
|
+
required?: undefined;
|
|
871
872
|
type: string;
|
|
872
873
|
properties: {
|
|
873
874
|
playlist?: undefined;
|
|
@@ -960,12 +961,12 @@ export declare const STATION_TOOLS: ({
|
|
|
960
961
|
license?: undefined;
|
|
961
962
|
private?: undefined;
|
|
962
963
|
};
|
|
963
|
-
required?: undefined;
|
|
964
964
|
};
|
|
965
965
|
} | {
|
|
966
966
|
name: string;
|
|
967
967
|
description: string;
|
|
968
968
|
inputSchema: {
|
|
969
|
+
required?: undefined;
|
|
969
970
|
type: string;
|
|
970
971
|
properties: {
|
|
971
972
|
playlist?: undefined;
|
|
@@ -1060,7 +1061,6 @@ export declare const STATION_TOOLS: ({
|
|
|
1060
1061
|
};
|
|
1061
1062
|
private?: undefined;
|
|
1062
1063
|
};
|
|
1063
|
-
required?: undefined;
|
|
1064
1064
|
};
|
|
1065
1065
|
} | {
|
|
1066
1066
|
name: string;
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
|
|
2
|
-
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in),\n NIXAMP_MUSIC_MODEL (music_v1|music_v2|music_v2_5), from stdin\n\n AI label (AI artists who release albums on their own; a station per genre; the catalogue at /label):\n nixamp station label artists, albums, this month's seconds, what it is waiting for\n nixamp station label sign --roster the roster: three artists in each of Rock, Hip-Hop, Synthwave, Country,\n Metal and Pop (nixamp station label lists their keys)\n nixamp station label sign (--preset KEY | --name NAME --genre GENRE --sound TEXT [--voice TEXT] [--bio TEXT] [--themes TEXT])\n nixamp station label album KEY plan the artist's next album now and record it\n nixamp station label set KEY [--sound TEXT] [--voice TEXT] [--bio TEXT] [--themes TEXT] [--off|--on]\n nixamp station label drop KEY [--files]\n nixamp station label config [--holder \"Profullstack, Inc.\"] [--budget SECONDS] [--every-days 30] [--seed-albums 1]\n [--songs 8] [--seconds 150] [--on|--off] [--public|--unlisted]\n [--parallel 4] [--genres \"Punk,Blues,Country\"] songs made at once; genres first\n [--per-day 1] [--new-artists 0.25] after the first sweep: albums a day, share by new artists\n [--videos|--no-videos] a music video per song and a TV station of them\n [--reset-day 21] the day the monthly budget renews (the provider's billing day)\n [--veo|--no-veo] [--veo-shots 4] filmed Veo shots of the band in each album's single (nixamp.com caps the month)\n nixamp station label run record whatever is due now\n nixamp station label resume after the provider ran out of credit\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
|
2
|
+
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | C0MPUTE_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in),\n NIXAMP_MUSIC_MODEL (music_v1|music_v2|music_v2_5), from stdin\n\n AI label (AI artists who release albums on their own; a station per genre; the catalogue at /label):\n nixamp station label artists, albums, this month's seconds, what it is waiting for\n nixamp station label sign --roster the roster: three artists in each of Rock, Hip-Hop, Synthwave, Country,\n Metal and Pop (nixamp station label lists their keys)\n nixamp station label sign (--preset KEY | --name NAME --genre GENRE --sound TEXT [--voice TEXT] [--bio TEXT] [--themes TEXT])\n nixamp station label album KEY plan the artist's next album now and record it\n nixamp station label set KEY [--sound TEXT] [--voice TEXT] [--bio TEXT] [--themes TEXT] [--off|--on]\n nixamp station label drop KEY [--files]\n nixamp station label config [--holder \"Profullstack, Inc.\"] [--budget SECONDS] [--every-days 30] [--seed-albums 1]\n [--songs 8] [--seconds 150] [--on|--off] [--public|--unlisted]\n [--parallel 4] [--genres \"Punk,Blues,Country\"] songs made at once; genres first\n [--per-day 1] [--new-artists 0.25] after the first sweep: albums a day, share by new artists\n [--videos|--no-videos] a music video per song and a TV station of them\n [--reset-day 21] the day the monthly budget renews (the provider's billing day)\n [--veo|--no-veo] [--veo-shots 4] filmed Veo shots of the band in each album's single (nixamp.com caps the month)\n nixamp station label adopt [--keys FILE] [--off] albums already in the label's folder (copied from another\n server) back on the label; FILE keeps old keys: {\"artists\": {NAME: KEY},\n \"albums\": {\"ARTISTKEY/TITLE\": KEY}}\n nixamp station label run record whatever is due now\n nixamp station label resume after the provider ran out of credit\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
package/dist/station-usage.js
CHANGED
|
@@ -32,7 +32,7 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
32
32
|
nixamp station ai remove KEY [--files]
|
|
33
33
|
nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)
|
|
34
34
|
nixamp station ai resume after the provider ran out of credit
|
|
35
|
-
nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,
|
|
35
|
+
nixamp station ai key NAME ELEVENLABS_API_KEY | C0MPUTE_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,
|
|
36
36
|
NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in),
|
|
37
37
|
NIXAMP_MUSIC_MODEL (music_v1|music_v2|music_v2_5), from stdin
|
|
38
38
|
|
|
@@ -51,6 +51,9 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
51
51
|
[--videos|--no-videos] a music video per song and a TV station of them
|
|
52
52
|
[--reset-day 21] the day the monthly budget renews (the provider's billing day)
|
|
53
53
|
[--veo|--no-veo] [--veo-shots 4] filmed Veo shots of the band in each album's single (nixamp.com caps the month)
|
|
54
|
+
nixamp station label adopt [--keys FILE] [--off] albums already in the label's folder (copied from another
|
|
55
|
+
server) back on the label; FILE keeps old keys: {"artists": {NAME: KEY},
|
|
56
|
+
"albums": {"ARTISTKEY/TITLE": KEY}}
|
|
54
57
|
nixamp station label run record whatever is due now
|
|
55
58
|
nixamp station label resume after the provider ran out of credit
|
|
56
59
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { footerHtml, footerHtmlSync } from "@profullstack/footer";
|
|
2
|
+
const OPTIONS = {
|
|
3
|
+
site: "https://nixamp.com/",
|
|
4
|
+
since: 2026,
|
|
5
|
+
tagline: "MIT License",
|
|
6
|
+
links: [
|
|
7
|
+
{ label: "Code", href: "https://github.com/profullstack/nixamp" },
|
|
8
|
+
{ label: "r/nixamp", href: "https://www.reddit.com/r/nixamp" },
|
|
9
|
+
],
|
|
10
|
+
builtIndieHref: "https://built.indie.pit.world/for?site=https://nixamp.com/",
|
|
11
|
+
pwamartHref: "https://pwamart.com/for?site=https://nixamp.com/",
|
|
12
|
+
};
|
|
13
|
+
const REFRESH_MS = 15 * 60 * 1000;
|
|
14
|
+
let html = footerHtmlSync(OPTIONS);
|
|
15
|
+
let fetchedAt = 0;
|
|
16
|
+
let inflight = null;
|
|
17
|
+
function refresh() {
|
|
18
|
+
inflight ??= footerHtml(OPTIONS)
|
|
19
|
+
.then((value) => {
|
|
20
|
+
html = value;
|
|
21
|
+
fetchedAt = Date.now();
|
|
22
|
+
})
|
|
23
|
+
.catch(() => {
|
|
24
|
+
fetchedAt = Date.now() - REFRESH_MS + 60_000;
|
|
25
|
+
})
|
|
26
|
+
.finally(() => {
|
|
27
|
+
inflight = null;
|
|
28
|
+
});
|
|
29
|
+
return inflight;
|
|
30
|
+
}
|
|
31
|
+
/** Add the shared footer to Nixamp pages while avoiding a second copy in its app shell. */
|
|
32
|
+
export function withUniversalFooter(page) {
|
|
33
|
+
if (Date.now() - fetchedAt > REFRESH_MS)
|
|
34
|
+
void refresh();
|
|
35
|
+
if (page.includes("data-pfs-footer="))
|
|
36
|
+
return page;
|
|
37
|
+
const at = page.lastIndexOf("</body>");
|
|
38
|
+
return at < 0 ? page : page.slice(0, at) + html + page.slice(at);
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.2",
|
|
4
4
|
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,15 +45,17 @@
|
|
|
45
45
|
"pack:cli": "bun scripts/pack-cli.ts"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
+
"@profullstack/ai-providers": "0.1.2",
|
|
48
49
|
"@profullstack/auth-system": "^0.8.3",
|
|
50
|
+
"@profullstack/footer": "latest",
|
|
49
51
|
"@profullstack/group-call": "^0.1.0",
|
|
50
52
|
"@profullstack/hqtui": "^0.11.1",
|
|
51
53
|
"@profullstack/live-translate": "^0.3.0",
|
|
52
54
|
"@profullstack/notifications": "^0.1.4",
|
|
53
|
-
"@profullstack/profile": "0.1.
|
|
55
|
+
"@profullstack/profile": "0.1.8",
|
|
54
56
|
"@profullstack/synconfig": "^0.1.3",
|
|
55
|
-
"@profullstack/throttle": "^0.4.
|
|
56
|
-
"@profullstack/x402-gateway": "^0.
|
|
57
|
+
"@profullstack/throttle": "^0.4.1",
|
|
58
|
+
"@profullstack/x402-gateway": "^0.16.1",
|
|
57
59
|
"acme-client": "5.4.0",
|
|
58
60
|
"pg": "^8.23.1"
|
|
59
61
|
},
|
|
@@ -86,6 +88,6 @@
|
|
|
86
88
|
"overrides": {
|
|
87
89
|
"https-proxy-agent": "7.0.6",
|
|
88
90
|
"tslib": "2.8.1",
|
|
89
|
-
"@profullstack/x402-gateway": "^0.
|
|
91
|
+
"@profullstack/x402-gateway": "^0.16.1"
|
|
90
92
|
}
|
|
91
93
|
}
|
package/src/ai-keys.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { dirname, join } from "node:path";
|
|
|
11
11
|
import { stateDir } from "./daemon.ts";
|
|
12
12
|
|
|
13
13
|
export const AI_KEY_NAMES = [
|
|
14
|
-
"ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
|
14
|
+
"ELEVENLABS_API_KEY", "C0MPUTE_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
|
15
15
|
// Any OpenAI-compatible endpoint in OpenAI's place (Cloudflare Workers AI, Ollama, ...) and its model.
|
|
16
16
|
"NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL",
|
|
17
17
|
// The label's songwriter on that endpoint (album plans, lyrics, new artists), e.g. @cf/openai/gpt-oss-120b; NIXAMP_OPENAI_MODEL when unset.
|
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,163 @@ 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: 1080p Veo cuts run 5-15 Mbit/s, the old slideshows under 2.5. */
|
|
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
|
+
// server1's filmed singles ran 5.5-15 Mbit/s, its slideshows at most 2.4.
|
|
3061
|
+
return (statSync(video).size * 8) / probed.duration > 4_000_000;
|
|
3062
|
+
} catch {
|
|
3063
|
+
return false;
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
|
|
2847
3067
|
startSchedule(everyMs = 5 * 60_000): void {
|
|
2848
3068
|
if (this.timer) return;
|
|
2849
3069
|
this.timer = setInterval(() => { void this.tick(); void this.videos(); void this.timeWords(); }, everyMs);
|
|
@@ -3190,7 +3410,7 @@ export class AiLabel {
|
|
|
3190
3410
|
const files = this.list()
|
|
3191
3411
|
.flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => ((song.videoVersion ?? 1) >= SHOWN ? song.video ?? "" : ""))))
|
|
3192
3412
|
.filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
|
|
3193
|
-
const existing = this.
|
|
3413
|
+
const existing = this.claimed("ai:label:videos", `${this.labelName()} AI · Music Videos`);
|
|
3194
3414
|
if (files.length === 0) {
|
|
3195
3415
|
// Nothing filmed yet (older slideshow videos are not shown): off the guide until there is.
|
|
3196
3416
|
if (existing && existing.listed) this.deps.store.setAccess(existing.id, { visibility: "private", listed: false }, this.deps.owner || "ai-label");
|
|
@@ -3476,7 +3696,23 @@ export class AiLabel {
|
|
|
3476
3696
|
}
|
|
3477
3697
|
|
|
3478
3698
|
private stationFor(genre: string): ReturnType<StationStore["get"]> {
|
|
3479
|
-
return this.
|
|
3699
|
+
return this.claimed(`ai:label:${genreSlug(genre)}`, `${this.labelName()} AI · ${genre}`);
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
/** "Profullstack" for "Profullstack, Inc.": how the label's stations are named. */
|
|
3703
|
+
private labelName(): string {
|
|
3704
|
+
return this.settings.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
/**
|
|
3708
|
+
* The label's station for this key; else one made by hand under the very name the
|
|
3709
|
+
* label would give it (a label rebuilt on another server), taken over, not doubled.
|
|
3710
|
+
*/
|
|
3711
|
+
private claimed(key: string, name: string): ReturnType<StationStore["get"]> {
|
|
3712
|
+
const marked = this.deps.store.byAuto(key);
|
|
3713
|
+
if (marked) return marked;
|
|
3714
|
+
const named = this.deps.store.list().find((one) => !one.auto && one.name === name);
|
|
3715
|
+
return named ? this.deps.store.markAuto(named.id, key) : undefined;
|
|
3480
3716
|
}
|
|
3481
3717
|
|
|
3482
3718
|
/** The genre's station gets every song of that genre: made with the first, then kept in step. */
|