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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.68.1",
3
+ "version": "0.69.0",
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",
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 8 s Veo shots of the band playing (never the singer) a single is cut from. */
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 8 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
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
- * How many shots a single needs so that its footage covers the whole song with
812
- * nothing shown twice (8 s a shot, the singer's close-ups whole, the rest cut in
813
- * 4 s halves): about a quarter the singer, a fifth the band, the rest the story.
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(seconds: number, singing: boolean): { story: number; band: number; singer: number } {
816
- const total = Math.ceil(Math.max(30, seconds) / 8) + 1;
817
- const singer = singing ? Math.max(2, Math.round(total / 4)) : 0;
818
- const band = Math.max(2, Math.round(total / 5));
819
- return { story: Math.max(2, total - singer - band), band, singer };
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
- rmSync(shots, { recursive: true, force: true });
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: 8 }); continue; }
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: 8, references: refs }); break; } catch (error) {
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: 8 });
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
+ }