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.
Files changed (44) hide show
  1. package/dist/ai-label.d.ts +24 -6
  2. package/dist/ai-label.js +40 -17
  3. package/dist/main.js +9 -0
  4. package/dist/mcp.d.ts +2 -0
  5. package/dist/mcp.js +12 -2
  6. package/dist/school-cli.d.ts +2 -0
  7. package/dist/school-cli.js +151 -0
  8. package/dist/school-client.d.ts +61 -0
  9. package/dist/school-client.js +148 -0
  10. package/dist/school-discovery.d.ts +32 -0
  11. package/dist/school-discovery.js +204 -0
  12. package/dist/school-mcp.d.ts +180 -0
  13. package/dist/school-mcp.js +143 -0
  14. package/dist/server.js +10 -0
  15. package/dist/station-mcp.d.ts +6 -6
  16. package/package.json +1 -1
  17. package/src/ai-label.ts +44 -19
  18. package/src/main.ts +8 -0
  19. package/src/mcp.ts +14 -2
  20. package/src/school-cli.ts +131 -0
  21. package/src/school-client.ts +180 -0
  22. package/src/school-discovery.ts +212 -0
  23. package/src/school-mcp.ts +153 -0
  24. package/src/server.ts +9 -0
  25. package/web/dist/assets/{ar-BoE6ZQzu.js → ar-BAUYJ7CG.js} +1 -1
  26. package/web/dist/assets/{cs-CpOvjCA6.js → cs-D5Njj-lD.js} +1 -1
  27. package/web/dist/assets/{da-Ca2nMBY9.js → da-CQ52TUpt.js} +1 -1
  28. package/web/dist/assets/{de-4PaFSCuO.js → de-slj7FWP_.js} +1 -1
  29. package/web/dist/assets/{es-Gzfinq23.js → es-wy-duArI.js} +1 -1
  30. package/web/dist/assets/{fi-Drs-Eq9w.js → fi-D8tuiwIa.js} +1 -1
  31. package/web/dist/assets/{fr-CDmr_xoz.js → fr-AlVe-2YY.js} +1 -1
  32. package/web/dist/assets/{hi-C_zNwFOG.js → hi-BkrehL5x.js} +1 -1
  33. package/web/dist/assets/{hu-DOkQNFUb.js → hu-_3tyj6FI.js} +1 -1
  34. package/web/dist/assets/{id-lm2zyqUT.js → id-O2_s9_ZD.js} +1 -1
  35. package/web/dist/assets/{it-CprqdlrZ.js → it-D6kuTye8.js} +1 -1
  36. package/web/dist/assets/{main-yosVjxZk.js → main-Bh0dWH3R.js} +2 -2
  37. package/web/dist/assets/{nl-CK0Rwzja.js → nl-DOdu_ltu.js} +1 -1
  38. package/web/dist/assets/{ru-CKPEz548.js → ru-B39HLLYh.js} +1 -1
  39. package/web/dist/assets/{sv-B1LZvRp0.js → sv-DTDAE9DG.js} +1 -1
  40. package/web/dist/assets/{uk-CFGtoIfZ.js → uk-DmM6P2_W.js} +1 -1
  41. package/web/dist/assets/{vi-CeMrBzLP.js → vi-8EoMeh4v.js} +1 -1
  42. package/web/dist/assets/{zh-CkWv_swk.js → zh-DkGyIChT.js} +1 -1
  43. package/web/dist/index.html +1 -1
  44. package/web/dist/sw.js +19 -19
@@ -96,9 +96,9 @@ export interface LabelSettings {
96
96
  videos: boolean;
97
97
  /** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
98
98
  veo: boolean;
99
- /** How many 8 s Veo shots of the band playing (never the singer) a single is cut from. */
99
+ /** How many 4 s Veo shots of the band playing (never the singer) a single is cut from. */
100
100
  veoShots: number;
101
- /** How many 8 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
101
+ /** How many 4 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
102
102
  storyShots: number;
103
103
  /** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
104
104
  lipsyncShots: number;
@@ -107,6 +107,8 @@ export interface LabelSettings {
107
107
  * length (shotPlan) and the three above are not used. Setting any of them turns this off.
108
108
  */
109
109
  coverSong: boolean;
110
+ /** With coverSong: what one music video may cost in dollars, the shots planned to fit (shotPlan). */
111
+ videoUsd: number;
110
112
  /** Public, listed and owned, so the genre stations are on the guide. */
111
113
  publish: boolean;
112
114
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -208,16 +210,32 @@ export declare function renderStoryVideo(ffmpeg: string[], input: {
208
210
  seconds: number;
209
211
  }>;
210
212
  }): Promise<void>;
213
+ /** What a second of Veo and of lip sync is counted at on nixamp.com (veo-studio.ts), and the shots' lengths. */
214
+ export declare const VEO_USD_PER_SECOND = 0.15;
215
+ export declare const LIPSYNC_USD_PER_SECOND = 0.1;
216
+ /** 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. */
217
+ export declare const SHOT_SECONDS = 4;
218
+ /** The singer's close-ups stay 8 s: each is lip-synced to 8 s of the song. */
219
+ export declare const SINGER_SECONDS = 8;
220
+ /** What one music video may cost (Anthony, 2026-10-21: "$5-10"); videoUsd in the settings. */
221
+ export declare const VIDEO_USD = 8;
211
222
  /**
212
- * How many shots a single needs so that its footage covers the whole song with
213
- * nothing shown twice (8 s a shot, the singer's close-ups whole, the rest cut in
214
- * 4 s halves): about a quarter the singer, a fifth the band, the rest the story.
223
+ * The shots a single gets for its budget: two lip-synced close-ups of the singer,
224
+ * then as many 4 s shots as the rest buys, a third the band and the rest the
225
+ * story. Footage shorter than the song is cut in turn and shown again.
226
+ * $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
215
227
  */
216
- export declare function shotPlan(seconds: number, singing: boolean): {
228
+ export declare function shotPlan(_seconds: number, singing: boolean, usd?: number): {
217
229
  story: number;
218
230
  band: number;
219
231
  singer: number;
220
232
  };
233
+ /** What a plan costs on nixamp.com, in dollars. */
234
+ export declare function planUsd(plan: {
235
+ story: number;
236
+ band: number;
237
+ singer: number;
238
+ }): number;
221
239
  /**
222
240
  * Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
223
241
  * nearest a quarter and three quarters in (one: a third in), each `length`
package/dist/ai-label.js CHANGED
@@ -277,6 +277,7 @@ export const DEFAULTS = {
277
277
  storyShots: 4,
278
278
  lipsyncShots: 2,
279
279
  coverSong: true,
280
+ videoUsd: 8, // VIDEO_USD
280
281
  parallel: 1,
281
282
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
282
283
  };
@@ -655,16 +656,33 @@ export function renderStoryVideo(ffmpeg, input) {
655
656
  "-movflags", "+faststart+use_metadata_tags", input.out];
656
657
  return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
657
658
  }
659
+ /** What a second of Veo and of lip sync is counted at on nixamp.com (veo-studio.ts), and the shots' lengths. */
660
+ export const VEO_USD_PER_SECOND = 0.15;
661
+ export const LIPSYNC_USD_PER_SECOND = 0.1;
662
+ /** 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. */
663
+ export const SHOT_SECONDS = 4;
664
+ /** The singer's close-ups stay 8 s: each is lip-synced to 8 s of the song. */
665
+ export const SINGER_SECONDS = 8;
666
+ /** What one music video may cost (Anthony, 2026-10-21: "$5-10"); videoUsd in the settings. */
667
+ export const VIDEO_USD = 8;
658
668
  /**
659
- * How many shots a single needs so that its footage covers the whole song with
660
- * nothing shown twice (8 s a shot, the singer's close-ups whole, the rest cut in
661
- * 4 s halves): about a quarter the singer, a fifth the band, the rest the story.
669
+ * The shots a single gets for its budget: two lip-synced close-ups of the singer,
670
+ * then as many 4 s shots as the rest buys, a third the band and the rest the
671
+ * story. Footage shorter than the song is cut in turn and shown again.
672
+ * $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
662
673
  */
663
- export function shotPlan(seconds, singing) {
664
- const total = Math.ceil(Math.max(30, seconds) / 8) + 1;
665
- const singer = singing ? Math.max(2, Math.round(total / 4)) : 0;
666
- const band = Math.max(2, Math.round(total / 5));
667
- return { story: Math.max(2, total - singer - band), band, singer };
674
+ export function shotPlan(_seconds, singing, usd = VIDEO_USD) {
675
+ const closeUp = SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND), shot = SHOT_SECONDS * VEO_USD_PER_SECOND;
676
+ // Two close-ups when they leave room for at least two shots; one when the budget is tight.
677
+ const singer = !singing ? 0 : usd >= 2 * closeUp + 2 * shot ? 2 : 1;
678
+ const left = usd - singer * SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND);
679
+ const shots = Math.max(2, Math.floor(left / (SHOT_SECONDS * VEO_USD_PER_SECOND) + 1e-9));
680
+ const band = Math.max(1, Math.round(shots / 3));
681
+ return { story: Math.max(1, shots - band), band, singer };
682
+ }
683
+ /** What a plan costs on nixamp.com, in dollars. */
684
+ export function planUsd(plan) {
685
+ return (plan.story + plan.band) * SHOT_SECONDS * VEO_USD_PER_SECOND + plan.singer * SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND);
668
686
  }
669
687
  /**
670
688
  * Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
@@ -1017,6 +1035,9 @@ export class AiLabel {
1017
1035
  this.settings.coverSong = false;
1018
1036
  if (input["coverSong"] !== undefined)
1019
1037
  this.settings.coverSong = input["coverSong"] === true;
1038
+ const usd = Number(input["videoUsd"]);
1039
+ if (input["videoUsd"] !== undefined && Number.isFinite(usd))
1040
+ this.settings.videoUsd = Math.min(40, Math.max(2, usd));
1020
1041
  const perDay = whole("albumsPerDay", 0, 24);
1021
1042
  if (perDay !== undefined)
1022
1043
  this.settings.albumsPerDay = perDay;
@@ -1287,7 +1308,7 @@ export class AiLabel {
1287
1308
  // Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
1288
1309
  const shots = join(_album.folder, `.veo-${song.n}`);
1289
1310
  const plan = this.settings.coverSong
1290
- ? shotPlan(seconds, Boolean(this.deps.lipsync))
1311
+ ? shotPlan(seconds, Boolean(this.deps.lipsync), this.settings.videoUsd ?? VIDEO_USD)
1291
1312
  : { story: this.settings.storyShots, band: this.settings.veoShots, singer: this.settings.lipsyncShots };
1292
1313
  const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes, plan);
1293
1314
  const wanted = plan.band + plan.story;
@@ -1299,7 +1320,9 @@ export class AiLabel {
1299
1320
  throw new Error("the singer's close-ups are not filmed yet");
1300
1321
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1301
1322
  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 } : {}) });
1302
- rmSync(shots, { recursive: true, force: true });
1323
+ // Paid shots are kept until the video is whole (every close-up synced): a re-cut films only what is missing.
1324
+ if (!singing || pinned.length >= plan.singer)
1325
+ rmSync(shots, { recursive: true, force: true });
1303
1326
  return { version: FILMED, ...(singing ? { singer: pinned.length ? "synced" : "unsynced" } : {}) };
1304
1327
  }
1305
1328
  /**
@@ -1422,14 +1445,14 @@ export class AiLabel {
1422
1445
  // the portraits: they would pull the whole band into every scene.
1423
1446
  // The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
1424
1447
  const plan = [
1425
- ...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
1426
- ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] })),
1427
- ...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1448
+ ...(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 })) : []),
1449
+ ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [], seconds: SHOT_SECONDS })),
1450
+ ...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs, seconds: SHOT_SECONDS })),
1428
1451
  ];
1429
- for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1452
+ for (const [i, { prompt, file, into, refs, seconds }] of plan.entries()) {
1430
1453
  try {
1431
1454
  if (statSync(file).size > 10_000) {
1432
- into.push({ path: file, seconds: 8 });
1455
+ into.push({ path: file, seconds });
1433
1456
  continue;
1434
1457
  }
1435
1458
  }
@@ -1441,7 +1464,7 @@ export class AiLabel {
1441
1464
  let bytes = null;
1442
1465
  for (let attempt = 0;; attempt++) {
1443
1466
  try {
1444
- bytes = await veo({ prompt, seconds: 8, references: refs });
1467
+ bytes = await veo({ prompt, seconds, references: refs });
1445
1468
  break;
1446
1469
  }
1447
1470
  catch (error) {
@@ -1453,7 +1476,7 @@ export class AiLabel {
1453
1476
  const partial = `${file}.part`;
1454
1477
  writeFileSync(partial, bytes);
1455
1478
  renameSync(partial, file);
1456
- into.push({ path: file, seconds: 8 });
1479
+ into.push({ path: file, seconds });
1457
1480
  }
1458
1481
  catch (error) {
1459
1482
  if (error instanceof VeoUnavailable) {
package/dist/main.js CHANGED
@@ -10,6 +10,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
10
10
  import { USAGE as STATION_USAGE } from "./station-usage.js";
11
11
  import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.js";
12
12
  import { MUSIC_USAGE } from "./music-cli.js";
13
+ import { SCHOOL_USAGE } from "./school-cli.js";
13
14
  import { createApp, themes } from "@profullstack/hqtui";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { resolve } from "node:path";
@@ -211,6 +212,7 @@ keeps serving its browser remote. One per user.
211
212
  nixamp admin who is connected, and re-stream to them
212
213
  nixamp station make a folder into a station others can join (see: nixamp station help)
213
214
  nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
215
+ nixamp school classes on backtoschool.help: teach a station live, check, prune (nixamp school help)
214
216
 
215
217
  From inside the player, d hands the music to a daemon without stopping it.
216
218
  `,
@@ -378,6 +380,8 @@ export function helpFor(topic) {
378
380
  return LIBRARY_USAGE;
379
381
  if (topic === "music")
380
382
  return MUSIC_USAGE;
383
+ if (topic === "school")
384
+ return SCHOOL_USAGE;
381
385
  return (topic ? TOPICS[topic] : undefined) ?? HELP;
382
386
  }
383
387
  /**
@@ -540,6 +544,11 @@ export async function main() {
540
544
  process.exitCode = await music(rest);
541
545
  return;
542
546
  }
547
+ if (first === "school" || first === "class" || first === "classes") {
548
+ const { school } = await import("./school-cli.js");
549
+ process.exitCode = await school(rest);
550
+ return;
551
+ }
543
552
  if (first === "station" || first === "stations") {
544
553
  const { station } = await import("./station-cli.js");
545
554
  process.exitCode = await station(rest);
package/dist/mcp.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface McpOptions {
37
37
  /** How a file is described for its record; the tests hand in a fake. */
38
38
  describe?: (path: string) => Promise<Described>;
39
39
  say?: (line: string) => void;
40
+ /** Where the class tools go; backtoschool.help (or NIXAMP_SCHOOL_URL) otherwise. */
41
+ school?: string;
40
42
  }
41
43
  /** A tool answer, in the shape MCP wants: content blocks, and a flag for failure. */
42
44
  export interface ToolResult {
package/dist/mcp.js CHANGED
@@ -33,6 +33,7 @@ import { personaLines, readPersona, readVoices, writePersona } from "./profile.j
33
33
  import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
34
34
  import { PUBLIC_TOOLS, callPublicTool, isPublicTool } from "./directory-mcp.js";
35
35
  import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.js";
36
+ import { SCHOOL_TOOLS, callSchoolTool, isSchoolTool } from "./school-mcp.js";
36
37
  export const PROTOCOL_VERSION = "2025-06-18";
37
38
  const STRING = { type: "string" };
38
39
  export const TOOLS = [
@@ -244,6 +245,7 @@ export const TOOLS = [
244
245
  ...STATION_TOOLS,
245
246
  ...PUBLIC_TOOLS,
246
247
  ...LIBRARY_TOOLS,
248
+ ...SCHOOL_TOOLS,
247
249
  ];
248
250
  function said(line) {
249
251
  return `${line.createdAt} ${line.handle}: ${line.body}`;
@@ -302,6 +304,14 @@ export async function callTool(name, args, options = {}) {
302
304
  return failed(error.message);
303
305
  }
304
306
  }
307
+ // backtoschool.help classes: the same token, sent to the school's host.
308
+ if (isSchoolTool(name)) {
309
+ return callSchoolTool(name, args, {
310
+ ...(options.fetcher ? { fetcher: options.fetcher } : {}),
311
+ ...(options.session !== undefined ? { token: options.session?.token ?? null } : {}),
312
+ ...(options.school ? { site: options.school } : {}),
313
+ });
314
+ }
305
315
  // Stations first: on this machine they need the daemon, not a sign-in.
306
316
  if (isStationTool(name) || isLibraryTool(name)) {
307
317
  const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
@@ -612,8 +622,8 @@ export async function handleMessage(message, options = {}) {
612
622
  return reply({
613
623
  protocolVersion: PROTOCOL_VERSION,
614
624
  capabilities: { tools: { listChanged: false } },
615
- serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
616
- instructions: "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.",
625
+ serverInfo: { name: "nixamp", title: "nixamp: stations, classes, watch parties, rooms and transcripts", version: "2" },
626
+ instructions: "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.",
617
627
  });
618
628
  }
619
629
  // Notifications carry no id and are answered with silence, which is what
@@ -0,0 +1,2 @@
1
+ export declare const SCHOOL_USAGE = "nixamp school: classes on backtoschool.help (sign in with `nixamp login`)\n\n nixamp school [list] [--status live|scheduled|draft|ended] [--mine]\n nixamp school show SLUG one class, and whether its broadcast plays\n nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]\n make a class of a broadcast and start it now\n nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]\n nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]\n nixamp school start SLUG\n nixamp school end SLUG\n nixamp school delete SLUG\n nixamp school check your live classes, and which ones no longer play\n nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)\n\nLINK is what plays: a station link (`nixamp station show ID`), a nixamp.com share link, a\nPairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.\n --json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)\n";
2
+ export declare function school(argv: string[]): Promise<number>;
@@ -0,0 +1,151 @@
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 { broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass, } from "./school-client.js";
10
+ export const SCHOOL_USAGE = `nixamp school: classes on backtoschool.help (sign in with \`nixamp login\`)
11
+
12
+ nixamp school [list] [--status live|scheduled|draft|ended] [--mine]
13
+ nixamp school show SLUG one class, and whether its broadcast plays
14
+ nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]
15
+ make a class of a broadcast and start it now
16
+ nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]
17
+ nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]
18
+ nixamp school start SLUG
19
+ nixamp school end SLUG
20
+ nixamp school delete SLUG
21
+ nixamp school check your live classes, and which ones no longer play
22
+ nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)
23
+
24
+ LINK is what plays: a station link (\`nixamp station show ID\`), a nixamp.com share link, a
25
+ Pairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.
26
+ --json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)
27
+ `;
28
+ function flag(argv, name) {
29
+ const at = argv.indexOf(name);
30
+ return at === -1 ? undefined : argv[at + 1];
31
+ }
32
+ /** The first argument after the command that is not a flag or a flag's value. */
33
+ function positional(argv) {
34
+ const valued = new Set(["--title", "--description", "--topic", "--starts", "--link", "--status"]);
35
+ for (let at = 1; at < argv.length; at += 1) {
36
+ const one = argv[at];
37
+ if (valued.has(one)) {
38
+ at += 1;
39
+ continue;
40
+ }
41
+ if (!one.startsWith("--"))
42
+ return one;
43
+ }
44
+ return undefined;
45
+ }
46
+ function fields(argv) {
47
+ const out = {};
48
+ for (const [name, key] of [["--title", "title"], ["--description", "description"], ["--topic", "topic"], ["--starts", "startsAt"], ["--link", "broadcastUrl"]]) {
49
+ const value = flag(argv, name);
50
+ if (value !== undefined)
51
+ out[key] = value;
52
+ }
53
+ for (const visibility of ["public", "unlisted", "private"])
54
+ if (argv.includes(`--${visibility}`))
55
+ out["visibility"] = visibility;
56
+ return out;
57
+ }
58
+ function print(item) {
59
+ console.log(`${item.status.padEnd(9)} ${item.title}\n ${classUrl(item)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`);
60
+ }
61
+ export async function school(argv) {
62
+ const [sub = "list"] = argv;
63
+ const json = argv.includes("--json");
64
+ const slug = positional(argv);
65
+ const out = (value, human) => { if (json)
66
+ console.log(JSON.stringify(value, null, 2));
67
+ else
68
+ human(); };
69
+ try {
70
+ if (sub === "help" || argv.includes("--help")) {
71
+ console.log(SCHOOL_USAGE);
72
+ return 0;
73
+ }
74
+ if (sub === "list") {
75
+ const status = flag(argv, "--status");
76
+ const items = await listClasses({ ...(status ? { status } : {}), mine: argv.includes("--mine") });
77
+ out(items, () => { if (items.length === 0)
78
+ console.log("No classes match."); for (const one of items)
79
+ print(one); });
80
+ return 0;
81
+ }
82
+ if (sub === "check" || sub === "prune") {
83
+ const checked = await checkClasses();
84
+ const broken = checked.filter((one) => one.health.ok === false);
85
+ const act = sub === "prune" ? (argv.includes("--delete") ? "delete" : argv.includes("--end") ? "end" : "") : "";
86
+ for (const { item } of act ? broken : []) {
87
+ if (act === "delete")
88
+ await deleteClass(item.id);
89
+ else
90
+ await moveClass(item.id, "end");
91
+ }
92
+ out(checked.map(({ item, health }) => ({ slug: item.slug, ok: health.ok, reason: health.reason, ...(act && health.ok === false ? { done: act } : {}) })), () => {
93
+ if (checked.length === 0)
94
+ console.log("You have no live classes.");
95
+ for (const { item, health } of checked)
96
+ console.log(`${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : " ? "} ${item.slug} (${health.reason})`);
97
+ if (sub === "prune" && broken.length > 0)
98
+ console.log(act ? `\n${act === "delete" ? "Deleted" : "Ended"} ${broken.length}.` : `\n${broken.length} no longer play. --end or --delete them.`);
99
+ });
100
+ return 0;
101
+ }
102
+ if (sub === "teach" || sub === "create") {
103
+ const input = fields(argv);
104
+ if (slug)
105
+ input["broadcastUrl"] = slug;
106
+ if (!input["title"]) {
107
+ console.error(`nixamp school ${sub} needs --title`);
108
+ return 2;
109
+ }
110
+ if (sub === "teach" && !input["broadcastUrl"]) {
111
+ console.error("nixamp school teach needs the LINK that plays");
112
+ return 2;
113
+ }
114
+ const item = sub === "teach" ? await teach(input) : await createClass(input);
115
+ out(item, () => { print(item); if (sub === "create")
116
+ console.log(`\nnixamp school start ${item.slug} puts it on the air.`); });
117
+ return 0;
118
+ }
119
+ if (!slug) {
120
+ console.error(`nixamp school ${sub} needs a class (its slug or /live/ link)\n\n${SCHOOL_USAGE}`);
121
+ return 2;
122
+ }
123
+ if (sub === "show") {
124
+ const item = await getClass(slug);
125
+ const health = await broadcastHealth(item.broadcastUrl);
126
+ out({ ...item, broadcast: health }, () => { print(item); console.log(` broadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`); });
127
+ return 0;
128
+ }
129
+ if (sub === "update") {
130
+ const item = await updateClass(slug, fields(argv));
131
+ out(item, () => print(item));
132
+ return 0;
133
+ }
134
+ if (sub === "start" || sub === "end") {
135
+ const item = await moveClass(slug, sub);
136
+ out(item, () => print(item));
137
+ return 0;
138
+ }
139
+ if (sub === "delete" || sub === "rm") {
140
+ await deleteClass(slug);
141
+ out({ ok: true, slug }, () => console.log(`Deleted ${slug}.`));
142
+ return 0;
143
+ }
144
+ console.error(`nixamp school: no command "${sub}".\n\n${SCHOOL_USAGE}`);
145
+ return 2;
146
+ }
147
+ catch (error) {
148
+ console.error(`nixamp school: ${error.message}`);
149
+ return 1;
150
+ }
151
+ }
@@ -0,0 +1,61 @@
1
+ export declare const SCHOOL_SITE = "https://backtoschool.help";
2
+ type Fetcher = typeof fetch;
3
+ export interface SchoolOptions {
4
+ /** backtoschool.help unless NIXAMP_SCHOOL_URL says otherwise; the tests hand in a local server. */
5
+ site?: string;
6
+ /** The session on disk (or NIXAMP_TOKEN) unless given; null means signed out. */
7
+ token?: string | null;
8
+ fetcher?: Fetcher;
9
+ }
10
+ export interface SchoolClass {
11
+ id: string;
12
+ slug: string;
13
+ title: string;
14
+ status: string;
15
+ visibility: string;
16
+ kind: string;
17
+ version: number;
18
+ ownerId: string;
19
+ broadcastUrl?: string | null;
20
+ description?: string;
21
+ topic?: string;
22
+ hostName?: string;
23
+ startsAt?: string | null;
24
+ [key: string]: unknown;
25
+ }
26
+ export declare class SchoolError extends Error {
27
+ readonly status: number;
28
+ constructor(message: string, status?: number);
29
+ }
30
+ export declare function schoolSite(options?: SchoolOptions): string;
31
+ export declare function classUrl(item: Pick<SchoolClass, "slug">, options?: SchoolOptions): string;
32
+ export declare function listClasses(query?: {
33
+ status?: string;
34
+ kind?: string;
35
+ mine?: boolean;
36
+ limit?: number;
37
+ }, options?: SchoolOptions): Promise<SchoolClass[]>;
38
+ /** A class by its slug, id, or the /live/<slug> link. */
39
+ export declare function getClass(slug: string, options?: SchoolOptions): Promise<SchoolClass>;
40
+ export declare function createClass(input: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
41
+ export declare function updateClass(slug: string, patch: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
42
+ /** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
43
+ export declare function moveClass(slug: string, action: "start" | "end" | "doors" | "encore" | "cancel" | "archive", options?: SchoolOptions): Promise<SchoolClass>;
44
+ export declare function deleteClass(slug: string, options?: SchoolOptions): Promise<void>;
45
+ /** Make a class of a broadcast and put it on the air in one go. */
46
+ export declare function teach(input: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
47
+ export interface Health {
48
+ ok: boolean | null;
49
+ reason: string;
50
+ }
51
+ /**
52
+ * Does this broadcast play? true and false are answers; null is "cannot tell
53
+ * from here" (a Pairux room), which a prune must leave alone.
54
+ */
55
+ export declare function broadcastHealth(link: string | null | undefined, fetcher?: Fetcher, timeoutMs?: number): Promise<Health>;
56
+ /** Your live classes, each with whether its broadcast plays. */
57
+ export declare function checkClasses(options?: SchoolOptions, status?: string): Promise<Array<{
58
+ item: SchoolClass;
59
+ health: Health;
60
+ }>>;
61
+ export {};
@@ -0,0 +1,148 @@
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.js";
14
+ export const SCHOOL_SITE = "https://backtoschool.help";
15
+ export class SchoolError extends Error {
16
+ status;
17
+ constructor(message, status = 0) {
18
+ super(message);
19
+ this.status = status;
20
+ }
21
+ }
22
+ export function schoolSite(options = {}) {
23
+ return (options.site ?? process.env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE).replace(/\/+$/, "");
24
+ }
25
+ export function classUrl(item, options = {}) {
26
+ return `${schoolSite(options)}/live/${item.slug}`;
27
+ }
28
+ function tokenOf(options) {
29
+ if (options.token !== undefined)
30
+ return options.token;
31
+ return readSession()?.token ?? null;
32
+ }
33
+ async function call(path, init = {}, options = {}) {
34
+ const token = tokenOf(options);
35
+ if (init.auth && !token)
36
+ throw new SchoolError("Not signed in. Run `nixamp login`, or set NIXAMP_TOKEN.", 401);
37
+ const headers = { accept: "application/json" };
38
+ if (token)
39
+ headers["authorization"] = `Bearer ${token}`;
40
+ if (init.body !== undefined)
41
+ headers["content-type"] = "application/json";
42
+ const response = await (options.fetcher ?? fetch)(`${schoolSite(options)}${path}`, {
43
+ method: init.method ?? "GET",
44
+ headers,
45
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
46
+ });
47
+ const body = (await response.json().catch(() => ({})));
48
+ if (!response.ok)
49
+ throw new SchoolError(String(body["error"] ?? `backtoschool.help answered ${response.status}`), response.status);
50
+ return body;
51
+ }
52
+ const eventOf = (body) => (body["event"] ?? body);
53
+ const ref = (value) => encodeURIComponent(value.replace(/^.*\/live\//, "").replace(/[?#].*$/, ""));
54
+ export async function listClasses(query = {}, options = {}) {
55
+ const params = new URLSearchParams();
56
+ if (query.status)
57
+ params.set("status", query.status);
58
+ if (query.kind)
59
+ params.set("kind", query.kind);
60
+ if (query.mine)
61
+ params.set("mine", "true");
62
+ params.set("limit", String(query.limit ?? 60));
63
+ const body = await call(`/api/v1/events?${params}`, { auth: query.mine === true }, options);
64
+ return body["events"] ?? [];
65
+ }
66
+ /** A class by its slug, id, or the /live/<slug> link. */
67
+ export async function getClass(slug, options = {}) {
68
+ return eventOf(await call(`/api/v1/events/${ref(slug)}`, {}, options));
69
+ }
70
+ export async function createClass(input, options = {}) {
71
+ return eventOf(await call("/api/v1/events", { method: "POST", body: { kind: "class", visibility: "public", ...input }, auth: true }, options));
72
+ }
73
+ export async function updateClass(slug, patch, options = {}) {
74
+ const current = await getClass(slug, options);
75
+ return eventOf(await call(`/api/v1/events/${ref(current.id)}`, { method: "PATCH", body: { ...patch, version: current.version }, auth: true }, options));
76
+ }
77
+ /** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
78
+ export async function moveClass(slug, action, options = {}) {
79
+ const current = await getClass(slug, options);
80
+ return eventOf(await call(`/api/v1/events/${ref(current.id)}/${action}`, { method: "POST", body: { version: current.version }, auth: true }, options));
81
+ }
82
+ export async function deleteClass(slug, options = {}) {
83
+ await call(`/api/v1/events/${ref(slug)}`, { method: "DELETE", auth: true }, options);
84
+ }
85
+ /** Make a class of a broadcast and put it on the air in one go. */
86
+ export async function teach(input, options = {}) {
87
+ const made = await createClass(input, options);
88
+ return moveClass(made.slug, "start", options);
89
+ }
90
+ /**
91
+ * Does this broadcast play? true and false are answers; null is "cannot tell
92
+ * from here" (a Pairux room), which a prune must leave alone.
93
+ */
94
+ export async function broadcastHealth(link, fetcher = fetch, timeoutMs = 8000) {
95
+ if (!link)
96
+ return { ok: false, reason: "no broadcast link" };
97
+ let url;
98
+ try {
99
+ url = new URL(link);
100
+ }
101
+ catch {
102
+ return { ok: false, reason: "not a link" };
103
+ }
104
+ const get = (target, headers = {}) => fetcher(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
105
+ try {
106
+ // A station page: its own token opens its state.
107
+ const station = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1];
108
+ if (station && url.hostname.endsWith(".nixamp.com")) {
109
+ const response = await get(`${url.origin}/api/v1/stations/${station}/state?t=${encodeURIComponent(url.searchParams.get("t") ?? "")}`);
110
+ if (!response.ok)
111
+ return { ok: false, reason: `station ${station} answered ${response.status}` };
112
+ const state = (await response.json());
113
+ return state.state === "live" ? { ok: true, reason: `station ${station} is live` } : { ok: false, reason: `station ${station} is ${state.state ?? "not live"}` };
114
+ }
115
+ // nixamp.com/?url=<server>/view/<listen key>&play=channel:<id>: the listen key reads the server's channels.
116
+ if ((url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com") && url.searchParams.get("url")) {
117
+ const server = new URL(url.searchParams.get("url"));
118
+ const key = /\/view\/([^/?#]+)/.exec(server.pathname)?.[1] ?? "";
119
+ const play = url.searchParams.get("play") ?? "live";
120
+ const response = await get(`${server.origin}/api/streams?k=${encodeURIComponent(key)}`);
121
+ if (!response.ok)
122
+ return { ok: false, reason: `${server.host} answered ${response.status}` };
123
+ const streams = (await response.json());
124
+ if (play.startsWith("channel:")) {
125
+ const id = play.slice("channel:".length);
126
+ return (streams.channels ?? []).some((one) => one.id === id)
127
+ ? { ok: true, reason: `channel ${id} is on ${server.hostname}` }
128
+ : { ok: false, reason: `channel ${id} is no longer on ${server.hostname}` };
129
+ }
130
+ if (play === "live")
131
+ return streams.server?.live ? { ok: true, reason: `${server.hostname} is live` } : { ok: false, reason: `${server.hostname} is not live` };
132
+ return { ok: true, reason: `${server.hostname} is up` };
133
+ }
134
+ if (url.hostname === "pairux.com" || url.hostname === "www.pairux.com")
135
+ return { ok: null, reason: "a Pairux room; cannot tell from here" };
136
+ // Direct media: one byte is enough to know it is there.
137
+ const response = await get(url.href, { range: "bytes=0-0" });
138
+ return response.ok ? { ok: true, reason: `${url.hostname} serves it` } : { ok: false, reason: `${url.hostname} answered ${response.status}` };
139
+ }
140
+ catch (error) {
141
+ return { ok: false, reason: `unreachable: ${error.message}` };
142
+ }
143
+ }
144
+ /** Your live classes, each with whether its broadcast plays. */
145
+ export async function checkClasses(options = {}, status = "live") {
146
+ const items = await listClasses({ mine: true, status }, options);
147
+ return Promise.all(items.map(async (item) => ({ item, health: await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch) })));
148
+ }