nixamp 0.68.1 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +24 -6
- package/dist/ai-label.js +40 -17
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +12 -2
- package/dist/school-cli.d.ts +2 -0
- package/dist/school-cli.js +151 -0
- package/dist/school-client.d.ts +61 -0
- package/dist/school-client.js +148 -0
- package/dist/school-discovery.d.ts +32 -0
- package/dist/school-discovery.js +204 -0
- package/dist/school-mcp.d.ts +180 -0
- package/dist/school-mcp.js +143 -0
- package/dist/server.js +10 -0
- package/dist/station-mcp.d.ts +6 -6
- package/package.json +1 -1
- package/src/ai-label.ts +44 -19
- package/src/main.ts +8 -0
- package/src/mcp.ts +14 -2
- package/src/school-cli.ts +131 -0
- package/src/school-client.ts +180 -0
- package/src/school-discovery.ts +212 -0
- package/src/school-mcp.ts +153 -0
- package/src/server.ts +9 -0
- package/web/dist/assets/{ar-BoE6ZQzu.js → ar-BAUYJ7CG.js} +1 -1
- package/web/dist/assets/{cs-CpOvjCA6.js → cs-D5Njj-lD.js} +1 -1
- package/web/dist/assets/{da-Ca2nMBY9.js → da-CQ52TUpt.js} +1 -1
- package/web/dist/assets/{de-4PaFSCuO.js → de-slj7FWP_.js} +1 -1
- package/web/dist/assets/{es-Gzfinq23.js → es-wy-duArI.js} +1 -1
- package/web/dist/assets/{fi-Drs-Eq9w.js → fi-D8tuiwIa.js} +1 -1
- package/web/dist/assets/{fr-CDmr_xoz.js → fr-AlVe-2YY.js} +1 -1
- package/web/dist/assets/{hi-C_zNwFOG.js → hi-BkrehL5x.js} +1 -1
- package/web/dist/assets/{hu-DOkQNFUb.js → hu-_3tyj6FI.js} +1 -1
- package/web/dist/assets/{id-lm2zyqUT.js → id-O2_s9_ZD.js} +1 -1
- package/web/dist/assets/{it-CprqdlrZ.js → it-D6kuTye8.js} +1 -1
- package/web/dist/assets/{main-yosVjxZk.js → main-Bh0dWH3R.js} +2 -2
- package/web/dist/assets/{nl-CK0Rwzja.js → nl-DOdu_ltu.js} +1 -1
- package/web/dist/assets/{ru-CKPEz548.js → ru-B39HLLYh.js} +1 -1
- package/web/dist/assets/{sv-B1LZvRp0.js → sv-DTDAE9DG.js} +1 -1
- package/web/dist/assets/{uk-CFGtoIfZ.js → uk-DmM6P2_W.js} +1 -1
- package/web/dist/assets/{vi-CeMrBzLP.js → vi-8EoMeh4v.js} +1 -1
- package/web/dist/assets/{zh-CkWv_swk.js → zh-DkGyIChT.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
package/package.json
CHANGED
package/src/ai-label.ts
CHANGED
|
@@ -133,9 +133,9 @@ export interface LabelSettings {
|
|
|
133
133
|
videos: boolean;
|
|
134
134
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
135
135
|
veo: boolean;
|
|
136
|
-
/** How many
|
|
136
|
+
/** How many 4 s Veo shots of the band playing (never the singer) a single is cut from. */
|
|
137
137
|
veoShots: number;
|
|
138
|
-
/** How many
|
|
138
|
+
/** How many 4 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
|
|
139
139
|
storyShots: number;
|
|
140
140
|
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
141
141
|
lipsyncShots: number;
|
|
@@ -144,6 +144,8 @@ export interface LabelSettings {
|
|
|
144
144
|
* length (shotPlan) and the three above are not used. Setting any of them turns this off.
|
|
145
145
|
*/
|
|
146
146
|
coverSong: boolean;
|
|
147
|
+
/** With coverSong: what one music video may cost in dollars, the shots planned to fit (shotPlan). */
|
|
148
|
+
videoUsd: number;
|
|
147
149
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
148
150
|
publish: boolean;
|
|
149
151
|
/** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
|
|
@@ -402,6 +404,7 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
|
|
|
402
404
|
storyShots: 4,
|
|
403
405
|
lipsyncShots: 2,
|
|
404
406
|
coverSong: true,
|
|
407
|
+
videoUsd: 8, // VIDEO_USD
|
|
405
408
|
parallel: 1,
|
|
406
409
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
407
410
|
};
|
|
@@ -807,16 +810,35 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
|
|
|
807
810
|
return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
|
|
808
811
|
}
|
|
809
812
|
|
|
813
|
+
/** What a second of Veo and of lip sync is counted at on nixamp.com (veo-studio.ts), and the shots' lengths. */
|
|
814
|
+
export const VEO_USD_PER_SECOND = 0.15;
|
|
815
|
+
export const LIPSYNC_USD_PER_SECOND = 0.1;
|
|
816
|
+
/** Story and band shots: the cut takes 4 s at a time, so a 4 s shot is used whole at half an 8 s one's price. */
|
|
817
|
+
export const SHOT_SECONDS = 4;
|
|
818
|
+
/** The singer's close-ups stay 8 s: each is lip-synced to 8 s of the song. */
|
|
819
|
+
export const SINGER_SECONDS = 8;
|
|
820
|
+
/** What one music video may cost (Anthony, 2026-10-21: "$5-10"); videoUsd in the settings. */
|
|
821
|
+
export const VIDEO_USD = 8;
|
|
822
|
+
|
|
810
823
|
/**
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
824
|
+
* The shots a single gets for its budget: two lip-synced close-ups of the singer,
|
|
825
|
+
* then as many 4 s shots as the rest buys, a third the band and the rest the
|
|
826
|
+
* story. Footage shorter than the song is cut in turn and shown again.
|
|
827
|
+
* $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
|
|
814
828
|
*/
|
|
815
|
-
export function shotPlan(
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
|
|
829
|
+
export function shotPlan(_seconds: number, singing: boolean, usd = VIDEO_USD): { story: number; band: number; singer: number } {
|
|
830
|
+
const closeUp = SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND), shot = SHOT_SECONDS * VEO_USD_PER_SECOND;
|
|
831
|
+
// Two close-ups when they leave room for at least two shots; one when the budget is tight.
|
|
832
|
+
const singer = !singing ? 0 : usd >= 2 * closeUp + 2 * shot ? 2 : 1;
|
|
833
|
+
const left = usd - singer * SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND);
|
|
834
|
+
const shots = Math.max(2, Math.floor(left / (SHOT_SECONDS * VEO_USD_PER_SECOND) + 1e-9));
|
|
835
|
+
const band = Math.max(1, Math.round(shots / 3));
|
|
836
|
+
return { story: Math.max(1, shots - band), band, singer };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** What a plan costs on nixamp.com, in dollars. */
|
|
840
|
+
export function planUsd(plan: { story: number; band: number; singer: number }): number {
|
|
841
|
+
return (plan.story + plan.band) * SHOT_SECONDS * VEO_USD_PER_SECOND + plan.singer * SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND);
|
|
820
842
|
}
|
|
821
843
|
|
|
822
844
|
/**
|
|
@@ -1201,6 +1223,8 @@ export class AiLabel {
|
|
|
1201
1223
|
// Counts given by hand mean those counts; coverSong says otherwise only when given too.
|
|
1202
1224
|
if (shots !== undefined || story !== undefined || close !== undefined) this.settings.coverSong = false;
|
|
1203
1225
|
if (input["coverSong"] !== undefined) this.settings.coverSong = input["coverSong"] === true;
|
|
1226
|
+
const usd = Number(input["videoUsd"]);
|
|
1227
|
+
if (input["videoUsd"] !== undefined && Number.isFinite(usd)) this.settings.videoUsd = Math.min(40, Math.max(2, usd));
|
|
1204
1228
|
const perDay = whole("albumsPerDay", 0, 24);
|
|
1205
1229
|
if (perDay !== undefined) this.settings.albumsPerDay = perDay;
|
|
1206
1230
|
if (input["newArtistChance"] !== undefined) {
|
|
@@ -1448,7 +1472,7 @@ export class AiLabel {
|
|
|
1448
1472
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1449
1473
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1450
1474
|
const plan = this.settings.coverSong
|
|
1451
|
-
? shotPlan(seconds, Boolean(this.deps.lipsync))
|
|
1475
|
+
? shotPlan(seconds, Boolean(this.deps.lipsync), this.settings.videoUsd ?? VIDEO_USD)
|
|
1452
1476
|
: { story: this.settings.storyShots, band: this.settings.veoShots, singer: this.settings.lipsyncShots };
|
|
1453
1477
|
const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes, plan);
|
|
1454
1478
|
const wanted = plan.band + plan.story;
|
|
@@ -1458,7 +1482,8 @@ export class AiLabel {
|
|
|
1458
1482
|
if (singing && singer.length === 0) throw new Error("the singer's close-ups are not filmed yet");
|
|
1459
1483
|
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1460
1484
|
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1461
|
-
|
|
1485
|
+
// Paid shots are kept until the video is whole (every close-up synced): a re-cut films only what is missing.
|
|
1486
|
+
if (!singing || pinned.length >= plan.singer) rmSync(shots, { recursive: true, force: true });
|
|
1462
1487
|
return { version: FILMED, ...(singing ? { singer: pinned.length ? "synced" as const : "unsynced" as const } : {}) };
|
|
1463
1488
|
}
|
|
1464
1489
|
|
|
@@ -1563,20 +1588,20 @@ export class AiLabel {
|
|
|
1563
1588
|
// the portraits: they would pull the whole band into every scene.
|
|
1564
1589
|
// The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
|
|
1565
1590
|
const plan = [
|
|
1566
|
-
...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
|
|
1567
|
-
...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[] })),
|
|
1568
|
-
...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
|
|
1591
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs, seconds: SINGER_SECONDS })) : []),
|
|
1592
|
+
...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[], seconds: SHOT_SECONDS })),
|
|
1593
|
+
...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs, seconds: SHOT_SECONDS })),
|
|
1569
1594
|
];
|
|
1570
|
-
for (const [i, { prompt, file, into, refs }] of plan.entries()) {
|
|
1595
|
+
for (const [i, { prompt, file, into, refs, seconds }] of plan.entries()) {
|
|
1571
1596
|
try {
|
|
1572
|
-
if (statSync(file).size > 10_000) { into.push({ path: file, seconds
|
|
1597
|
+
if (statSync(file).size > 10_000) { into.push({ path: file, seconds }); continue; }
|
|
1573
1598
|
} catch { /* not filmed yet */ }
|
|
1574
1599
|
if (!prompt) continue;
|
|
1575
1600
|
try {
|
|
1576
1601
|
// nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
|
|
1577
1602
|
let bytes: Buffer | null = null;
|
|
1578
1603
|
for (let attempt = 0; ; attempt++) {
|
|
1579
|
-
try { bytes = await veo({ prompt, seconds
|
|
1604
|
+
try { bytes = await veo({ prompt, seconds, references: refs }); break; } catch (error) {
|
|
1580
1605
|
if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test((error as Error).message)) throw error;
|
|
1581
1606
|
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1582
1607
|
}
|
|
@@ -1584,7 +1609,7 @@ export class AiLabel {
|
|
|
1584
1609
|
const partial = `${file}.part`;
|
|
1585
1610
|
writeFileSync(partial, bytes!);
|
|
1586
1611
|
renameSync(partial, file);
|
|
1587
|
-
into.push({ path: file, seconds
|
|
1612
|
+
into.push({ path: file, seconds });
|
|
1588
1613
|
} catch (error) {
|
|
1589
1614
|
if (error instanceof VeoUnavailable) {
|
|
1590
1615
|
this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts"
|
|
|
10
10
|
import { USAGE as STATION_USAGE } from "./station-usage.ts";
|
|
11
11
|
import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
|
|
12
12
|
import { MUSIC_USAGE } from "./music-cli.ts";
|
|
13
|
+
import { SCHOOL_USAGE } from "./school-cli.ts";
|
|
13
14
|
import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { resolve } from "node:path";
|
|
@@ -237,6 +238,7 @@ keeps serving its browser remote. One per user.
|
|
|
237
238
|
nixamp admin who is connected, and re-stream to them
|
|
238
239
|
nixamp station make a folder into a station others can join (see: nixamp station help)
|
|
239
240
|
nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
|
|
241
|
+
nixamp school classes on backtoschool.help: teach a station live, check, prune (nixamp school help)
|
|
240
242
|
|
|
241
243
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
242
244
|
`,
|
|
@@ -402,6 +404,7 @@ export function helpFor(topic: string | undefined): string {
|
|
|
402
404
|
if (topic === "station" || topic === "stations") return STATION_USAGE;
|
|
403
405
|
if (topic === "library") return LIBRARY_USAGE;
|
|
404
406
|
if (topic === "music") return MUSIC_USAGE;
|
|
407
|
+
if (topic === "school") return SCHOOL_USAGE;
|
|
405
408
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
406
409
|
}
|
|
407
410
|
|
|
@@ -566,6 +569,11 @@ export async function main(): Promise<void> {
|
|
|
566
569
|
process.exitCode = await music(rest);
|
|
567
570
|
return;
|
|
568
571
|
}
|
|
572
|
+
if (first === "school" || first === "class" || first === "classes") {
|
|
573
|
+
const { school } = await import("./school-cli.ts");
|
|
574
|
+
process.exitCode = await school(rest);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
569
577
|
if (first === "station" || first === "stations") {
|
|
570
578
|
const { station } = await import("./station-cli.ts");
|
|
571
579
|
process.exitCode = await station(rest);
|
package/src/mcp.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { personaLines, readPersona, readVoices, writePersona } from "./profile.t
|
|
|
33
33
|
import { STATION_TOOLS, callStationTool, isStationTool, type StationToolOptions } from "./station-mcp.ts";
|
|
34
34
|
import { PUBLIC_TOOLS, callPublicTool, isPublicTool, type PublicToolOptions } from "./directory-mcp.ts";
|
|
35
35
|
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.ts";
|
|
36
|
+
import { SCHOOL_TOOLS, callSchoolTool, isSchoolTool } from "./school-mcp.ts";
|
|
36
37
|
|
|
37
38
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
38
39
|
|
|
@@ -271,6 +272,7 @@ export const TOOLS: ToolDefinition[] = [
|
|
|
271
272
|
...STATION_TOOLS,
|
|
272
273
|
...(PUBLIC_TOOLS as unknown as ToolDefinition[]),
|
|
273
274
|
...LIBRARY_TOOLS,
|
|
275
|
+
...SCHOOL_TOOLS,
|
|
274
276
|
];
|
|
275
277
|
|
|
276
278
|
interface TrollboxLine {
|
|
@@ -303,6 +305,8 @@ export interface McpOptions {
|
|
|
303
305
|
/** How a file is described for its record; the tests hand in a fake. */
|
|
304
306
|
describe?: (path: string) => Promise<Described>;
|
|
305
307
|
say?: (line: string) => void;
|
|
308
|
+
/** Where the class tools go; backtoschool.help (or NIXAMP_SCHOOL_URL) otherwise. */
|
|
309
|
+
school?: string;
|
|
306
310
|
}
|
|
307
311
|
|
|
308
312
|
/** A kept transcript, as a tool answers it. */
|
|
@@ -365,6 +369,14 @@ export async function callTool(name: string, args: Record<string, unknown>, opti
|
|
|
365
369
|
return failed((error as Error).message);
|
|
366
370
|
}
|
|
367
371
|
}
|
|
372
|
+
// backtoschool.help classes: the same token, sent to the school's host.
|
|
373
|
+
if (isSchoolTool(name)) {
|
|
374
|
+
return callSchoolTool(name, args, {
|
|
375
|
+
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
|
376
|
+
...(options.session !== undefined ? { token: options.session?.token ?? null } : {}),
|
|
377
|
+
...(options.school ? { site: options.school } : {}),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
368
380
|
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
369
381
|
if (isStationTool(name) || isLibraryTool(name)) {
|
|
370
382
|
const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
|
|
@@ -653,9 +665,9 @@ export async function handleMessage(message: Request, options: McpOptions = {}):
|
|
|
653
665
|
return reply({
|
|
654
666
|
protocolVersion: PROTOCOL_VERSION,
|
|
655
667
|
capabilities: { tools: { listChanged: false } },
|
|
656
|
-
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
668
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, classes, watch parties, rooms and transcripts", version: "2" },
|
|
657
669
|
instructions:
|
|
658
|
-
"Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
670
|
+
"Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Classes on backtoschool.help: classes_list and class_get for anybody; class_teach puts a station link on the air as a class in one call, classes_check finds live classes whose broadcast stopped playing. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
659
671
|
});
|
|
660
672
|
}
|
|
661
673
|
// Notifications carry no id and are answered with silence, which is what
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp school`: classes on backtoschool.help from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* A class is a live event on nixamp's accounts, so `nixamp login` is the
|
|
5
|
+
* sign-in here too. `teach` is the short path: a station link in, a class on
|
|
6
|
+
* the air out. `check` and `prune` exist because a class stays live after
|
|
7
|
+
* its broadcast is gone, and the page then shows a player that never plays.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass,
|
|
11
|
+
type SchoolClass,
|
|
12
|
+
} from "./school-client.ts";
|
|
13
|
+
|
|
14
|
+
export const SCHOOL_USAGE = `nixamp school: classes on backtoschool.help (sign in with \`nixamp login\`)
|
|
15
|
+
|
|
16
|
+
nixamp school [list] [--status live|scheduled|draft|ended] [--mine]
|
|
17
|
+
nixamp school show SLUG one class, and whether its broadcast plays
|
|
18
|
+
nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]
|
|
19
|
+
make a class of a broadcast and start it now
|
|
20
|
+
nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]
|
|
21
|
+
nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]
|
|
22
|
+
nixamp school start SLUG
|
|
23
|
+
nixamp school end SLUG
|
|
24
|
+
nixamp school delete SLUG
|
|
25
|
+
nixamp school check your live classes, and which ones no longer play
|
|
26
|
+
nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)
|
|
27
|
+
|
|
28
|
+
LINK is what plays: a station link (\`nixamp station show ID\`), a nixamp.com share link, a
|
|
29
|
+
Pairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.
|
|
30
|
+
--json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
34
|
+
const at = argv.indexOf(name);
|
|
35
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The first argument after the command that is not a flag or a flag's value. */
|
|
39
|
+
function positional(argv: string[]): string | undefined {
|
|
40
|
+
const valued = new Set(["--title", "--description", "--topic", "--starts", "--link", "--status"]);
|
|
41
|
+
for (let at = 1; at < argv.length; at += 1) {
|
|
42
|
+
const one = argv[at]!;
|
|
43
|
+
if (valued.has(one)) { at += 1; continue; }
|
|
44
|
+
if (!one.startsWith("--")) return one;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fields(argv: string[]): Record<string, unknown> {
|
|
50
|
+
const out: Record<string, unknown> = {};
|
|
51
|
+
for (const [name, key] of [["--title", "title"], ["--description", "description"], ["--topic", "topic"], ["--starts", "startsAt"], ["--link", "broadcastUrl"]] as const) {
|
|
52
|
+
const value = flag(argv, name);
|
|
53
|
+
if (value !== undefined) out[key] = value;
|
|
54
|
+
}
|
|
55
|
+
for (const visibility of ["public", "unlisted", "private"]) if (argv.includes(`--${visibility}`)) out["visibility"] = visibility;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function print(item: SchoolClass): void {
|
|
60
|
+
console.log(`${item.status.padEnd(9)} ${item.title}\n ${classUrl(item)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function school(argv: string[]): Promise<number> {
|
|
64
|
+
const [sub = "list"] = argv;
|
|
65
|
+
const json = argv.includes("--json");
|
|
66
|
+
const slug = positional(argv);
|
|
67
|
+
const out = (value: unknown, human: () => void): void => { if (json) console.log(JSON.stringify(value, null, 2)); else human(); };
|
|
68
|
+
try {
|
|
69
|
+
if (sub === "help" || argv.includes("--help")) {
|
|
70
|
+
console.log(SCHOOL_USAGE);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (sub === "list") {
|
|
74
|
+
const status = flag(argv, "--status");
|
|
75
|
+
const items = await listClasses({ ...(status ? { status } : {}), mine: argv.includes("--mine") });
|
|
76
|
+
out(items, () => { if (items.length === 0) console.log("No classes match."); for (const one of items) print(one); });
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (sub === "check" || sub === "prune") {
|
|
80
|
+
const checked = await checkClasses();
|
|
81
|
+
const broken = checked.filter((one) => one.health.ok === false);
|
|
82
|
+
const act = sub === "prune" ? (argv.includes("--delete") ? "delete" : argv.includes("--end") ? "end" : "") : "";
|
|
83
|
+
for (const { item } of act ? broken : []) {
|
|
84
|
+
if (act === "delete") await deleteClass(item.id);
|
|
85
|
+
else await moveClass(item.id, "end");
|
|
86
|
+
}
|
|
87
|
+
out(checked.map(({ item, health }) => ({ slug: item.slug, ok: health.ok, reason: health.reason, ...(act && health.ok === false ? { done: act } : {}) })), () => {
|
|
88
|
+
if (checked.length === 0) console.log("You have no live classes.");
|
|
89
|
+
for (const { item, health } of checked) console.log(`${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : " ? "} ${item.slug} (${health.reason})`);
|
|
90
|
+
if (sub === "prune" && broken.length > 0) console.log(act ? `\n${act === "delete" ? "Deleted" : "Ended"} ${broken.length}.` : `\n${broken.length} no longer play. --end or --delete them.`);
|
|
91
|
+
});
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (sub === "teach" || sub === "create") {
|
|
95
|
+
const input = fields(argv);
|
|
96
|
+
if (slug) input["broadcastUrl"] = slug;
|
|
97
|
+
if (!input["title"]) { console.error(`nixamp school ${sub} needs --title`); return 2; }
|
|
98
|
+
if (sub === "teach" && !input["broadcastUrl"]) { console.error("nixamp school teach needs the LINK that plays"); return 2; }
|
|
99
|
+
const item = sub === "teach" ? await teach(input) : await createClass(input);
|
|
100
|
+
out(item, () => { print(item); if (sub === "create") console.log(`\nnixamp school start ${item.slug} puts it on the air.`); });
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
if (!slug) { console.error(`nixamp school ${sub} needs a class (its slug or /live/ link)\n\n${SCHOOL_USAGE}`); return 2; }
|
|
104
|
+
if (sub === "show") {
|
|
105
|
+
const item = await getClass(slug);
|
|
106
|
+
const health = await broadcastHealth(item.broadcastUrl);
|
|
107
|
+
out({ ...item, broadcast: health }, () => { print(item); console.log(` broadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`); });
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (sub === "update") {
|
|
111
|
+
const item = await updateClass(slug, fields(argv));
|
|
112
|
+
out(item, () => print(item));
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (sub === "start" || sub === "end") {
|
|
116
|
+
const item = await moveClass(slug, sub);
|
|
117
|
+
out(item, () => print(item));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
if (sub === "delete" || sub === "rm") {
|
|
121
|
+
await deleteClass(slug);
|
|
122
|
+
out({ ok: true, slug }, () => console.log(`Deleted ${slug}.`));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
console.error(`nixamp school: no command "${sub}".\n\n${SCHOOL_USAGE}`);
|
|
126
|
+
return 2;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(`nixamp school: ${(error as Error).message}`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help as a client: its classes are nixamp's live events, served
|
|
3
|
+
* by the same process on another host, so one nixamp token signs in to both.
|
|
4
|
+
*
|
|
5
|
+
* The CLI (school-cli.ts) and the MCP tools (school-mcp.ts) are both this
|
|
6
|
+
* file; neither talks HTTP itself.
|
|
7
|
+
*
|
|
8
|
+
* `broadcastHealth` is the reason this exists as much as the CRUD: a class
|
|
9
|
+
* stays "live" until somebody ends it, long after the channel it points at
|
|
10
|
+
* was stopped, and the page then shows a player that never plays. It asks the
|
|
11
|
+
* broadcast itself, with only what the public link already carries.
|
|
12
|
+
*/
|
|
13
|
+
import { readSession } from "./session.ts";
|
|
14
|
+
|
|
15
|
+
export const SCHOOL_SITE = "https://backtoschool.help";
|
|
16
|
+
|
|
17
|
+
type Fetcher = typeof fetch;
|
|
18
|
+
|
|
19
|
+
export interface SchoolOptions {
|
|
20
|
+
/** backtoschool.help unless NIXAMP_SCHOOL_URL says otherwise; the tests hand in a local server. */
|
|
21
|
+
site?: string;
|
|
22
|
+
/** The session on disk (or NIXAMP_TOKEN) unless given; null means signed out. */
|
|
23
|
+
token?: string | null;
|
|
24
|
+
fetcher?: Fetcher;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SchoolClass {
|
|
28
|
+
id: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
title: string;
|
|
31
|
+
status: string;
|
|
32
|
+
visibility: string;
|
|
33
|
+
kind: string;
|
|
34
|
+
version: number;
|
|
35
|
+
ownerId: string;
|
|
36
|
+
broadcastUrl?: string | null;
|
|
37
|
+
description?: string;
|
|
38
|
+
topic?: string;
|
|
39
|
+
hostName?: string;
|
|
40
|
+
startsAt?: string | null;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SchoolError extends Error {
|
|
45
|
+
constructor(message: string, readonly status = 0) {
|
|
46
|
+
super(message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function schoolSite(options: SchoolOptions = {}): string {
|
|
51
|
+
return (options.site ?? process.env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE).replace(/\/+$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function classUrl(item: Pick<SchoolClass, "slug">, options: SchoolOptions = {}): string {
|
|
55
|
+
return `${schoolSite(options)}/live/${item.slug}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tokenOf(options: SchoolOptions): string | null {
|
|
59
|
+
if (options.token !== undefined) return options.token;
|
|
60
|
+
return readSession()?.token ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function call(path: string, init: { method?: string; body?: unknown; auth?: boolean } = {}, options: SchoolOptions = {}): Promise<Record<string, unknown>> {
|
|
64
|
+
const token = tokenOf(options);
|
|
65
|
+
if (init.auth && !token) throw new SchoolError("Not signed in. Run `nixamp login`, or set NIXAMP_TOKEN.", 401);
|
|
66
|
+
const headers: Record<string, string> = { accept: "application/json" };
|
|
67
|
+
if (token) headers["authorization"] = `Bearer ${token}`;
|
|
68
|
+
if (init.body !== undefined) headers["content-type"] = "application/json";
|
|
69
|
+
const response = await (options.fetcher ?? fetch)(`${schoolSite(options)}${path}`, {
|
|
70
|
+
method: init.method ?? "GET",
|
|
71
|
+
headers,
|
|
72
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
73
|
+
});
|
|
74
|
+
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
75
|
+
if (!response.ok) throw new SchoolError(String(body["error"] ?? `backtoschool.help answered ${response.status}`), response.status);
|
|
76
|
+
return body;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const eventOf = (body: Record<string, unknown>): SchoolClass => (body["event"] ?? body) as SchoolClass;
|
|
80
|
+
const ref = (value: string): string => encodeURIComponent(value.replace(/^.*\/live\//, "").replace(/[?#].*$/, ""));
|
|
81
|
+
|
|
82
|
+
export async function listClasses(query: { status?: string; kind?: string; mine?: boolean; limit?: number } = {}, options: SchoolOptions = {}): Promise<SchoolClass[]> {
|
|
83
|
+
const params = new URLSearchParams();
|
|
84
|
+
if (query.status) params.set("status", query.status);
|
|
85
|
+
if (query.kind) params.set("kind", query.kind);
|
|
86
|
+
if (query.mine) params.set("mine", "true");
|
|
87
|
+
params.set("limit", String(query.limit ?? 60));
|
|
88
|
+
const body = await call(`/api/v1/events?${params}`, { auth: query.mine === true }, options);
|
|
89
|
+
return (body["events"] as SchoolClass[]) ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A class by its slug, id, or the /live/<slug> link. */
|
|
93
|
+
export async function getClass(slug: string, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
94
|
+
return eventOf(await call(`/api/v1/events/${ref(slug)}`, {}, options));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function createClass(input: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
98
|
+
return eventOf(await call("/api/v1/events", { method: "POST", body: { kind: "class", visibility: "public", ...input }, auth: true }, options));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function updateClass(slug: string, patch: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
102
|
+
const current = await getClass(slug, options);
|
|
103
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}`, { method: "PATCH", body: { ...patch, version: current.version }, auth: true }, options));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
|
|
107
|
+
export async function moveClass(slug: string, action: "start" | "end" | "doors" | "encore" | "cancel" | "archive", options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
108
|
+
const current = await getClass(slug, options);
|
|
109
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}/${action}`, { method: "POST", body: { version: current.version }, auth: true }, options));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function deleteClass(slug: string, options: SchoolOptions = {}): Promise<void> {
|
|
113
|
+
await call(`/api/v1/events/${ref(slug)}`, { method: "DELETE", auth: true }, options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Make a class of a broadcast and put it on the air in one go. */
|
|
117
|
+
export async function teach(input: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
118
|
+
const made = await createClass(input, options);
|
|
119
|
+
return moveClass(made.slug, "start", options);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface Health {
|
|
123
|
+
ok: boolean | null;
|
|
124
|
+
reason: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Does this broadcast play? true and false are answers; null is "cannot tell
|
|
129
|
+
* from here" (a Pairux room), which a prune must leave alone.
|
|
130
|
+
*/
|
|
131
|
+
export async function broadcastHealth(link: string | null | undefined, fetcher: Fetcher = fetch, timeoutMs = 8000): Promise<Health> {
|
|
132
|
+
if (!link) return { ok: false, reason: "no broadcast link" };
|
|
133
|
+
let url: URL;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(link);
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, reason: "not a link" };
|
|
138
|
+
}
|
|
139
|
+
const get = (target: string, headers: Record<string, string> = {}): Promise<Response> =>
|
|
140
|
+
fetcher(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
141
|
+
try {
|
|
142
|
+
// A station page: its own token opens its state.
|
|
143
|
+
const station = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1];
|
|
144
|
+
if (station && url.hostname.endsWith(".nixamp.com")) {
|
|
145
|
+
const response = await get(`${url.origin}/api/v1/stations/${station}/state?t=${encodeURIComponent(url.searchParams.get("t") ?? "")}`);
|
|
146
|
+
if (!response.ok) return { ok: false, reason: `station ${station} answered ${response.status}` };
|
|
147
|
+
const state = (await response.json()) as { state?: string };
|
|
148
|
+
return state.state === "live" ? { ok: true, reason: `station ${station} is live` } : { ok: false, reason: `station ${station} is ${state.state ?? "not live"}` };
|
|
149
|
+
}
|
|
150
|
+
// nixamp.com/?url=<server>/view/<listen key>&play=channel:<id>: the listen key reads the server's channels.
|
|
151
|
+
if ((url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com") && url.searchParams.get("url")) {
|
|
152
|
+
const server = new URL(url.searchParams.get("url")!);
|
|
153
|
+
const key = /\/view\/([^/?#]+)/.exec(server.pathname)?.[1] ?? "";
|
|
154
|
+
const play = url.searchParams.get("play") ?? "live";
|
|
155
|
+
const response = await get(`${server.origin}/api/streams?k=${encodeURIComponent(key)}`);
|
|
156
|
+
if (!response.ok) return { ok: false, reason: `${server.host} answered ${response.status}` };
|
|
157
|
+
const streams = (await response.json()) as { server?: { live?: boolean }; channels?: Array<{ id: string }> };
|
|
158
|
+
if (play.startsWith("channel:")) {
|
|
159
|
+
const id = play.slice("channel:".length);
|
|
160
|
+
return (streams.channels ?? []).some((one) => one.id === id)
|
|
161
|
+
? { ok: true, reason: `channel ${id} is on ${server.hostname}` }
|
|
162
|
+
: { ok: false, reason: `channel ${id} is no longer on ${server.hostname}` };
|
|
163
|
+
}
|
|
164
|
+
if (play === "live") return streams.server?.live ? { ok: true, reason: `${server.hostname} is live` } : { ok: false, reason: `${server.hostname} is not live` };
|
|
165
|
+
return { ok: true, reason: `${server.hostname} is up` };
|
|
166
|
+
}
|
|
167
|
+
if (url.hostname === "pairux.com" || url.hostname === "www.pairux.com") return { ok: null, reason: "a Pairux room; cannot tell from here" };
|
|
168
|
+
// Direct media: one byte is enough to know it is there.
|
|
169
|
+
const response = await get(url.href, { range: "bytes=0-0" });
|
|
170
|
+
return response.ok ? { ok: true, reason: `${url.hostname} serves it` } : { ok: false, reason: `${url.hostname} answered ${response.status}` };
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { ok: false, reason: `unreachable: ${(error as Error).message}` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Your live classes, each with whether its broadcast plays. */
|
|
177
|
+
export async function checkClasses(options: SchoolOptions = {}, status = "live"): Promise<Array<{ item: SchoolClass; health: Health }>> {
|
|
178
|
+
const items = await listClasses({ mine: true, status }, options);
|
|
179
|
+
return Promise.all(items.map(async (item) => ({ item, health: await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch) })));
|
|
180
|
+
}
|