From 7810a81176cd85c883a282d748e2b21ac9c69254 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 22 Oct 2026 14:34:43 +0000 Subject: [PATCH] feat(vod): publish a folder on live.streaming from any server, and say how on /vod (0.71.0) `nixamp library publish` posted to the guide on the same daemon, so on any server but server1 a "published" file landed on that server's own private guide, never on live.streaming.pit.world, and it took one 16-hex item id at a time. - `nixamp library publish ITEM... | FOLDER | WORDS --basis B`: picks every ready file (ids as they are, a folder by its source + relative path, else search words) and posts each file's public play address to live.streaming.pit.world with this machine's nixamp.com session, as the nixamp.com button does. `--dry-run` shows the picks, `--guide URL` another guide, `--here` the old own-guide path. - Library item GET gives the owner `playAddress` (publicBase + listen key). - /vod: a "Publish yours" panel with the browser and the CLI ways. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/library-cli.ts | 90 +++++++++++++++++++++++++++++++++--- src/library-routes.ts | 6 ++- src/library-usage.ts | 5 +- src/server.ts | 4 ++ test/library-publish.test.ts | 40 ++++++++++++++++ test/library-routes.test.ts | 4 ++ web/src/station.css | 1 + web/vod.html | 8 ++++ 9 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 test/library-publish.test.ts diff --git a/package.json b/package.json index 3305ae1..82a2f19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nixamp", - "version": "0.70.2", + "version": "0.71.0", "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.", "license": "MIT", "type": "module", diff --git a/src/library-cli.ts b/src/library-cli.ts index 7880f2c..4e86893 100644 --- a/src/library-cli.ts +++ b/src/library-cli.ts @@ -10,9 +10,44 @@ import { resolveTarget } from "./admin.ts"; import { KEY_HEADER } from "./share.ts"; import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts"; +import { readSession } from "./session.ts"; export { LIBRARY_COMMANDS, LIBRARY_USAGE }; +/** The public guide a published file goes on, unless --guide or --here says otherwise. */ +export const STREAMING_GUIDE = "https://live.streaming.pit.world"; + +type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: Record }>; + +/** + * The ready files `publish` means: item ids as they are, a folder on this + * machine as everything under it, anything else as search words. A message + * when nothing matches. + */ +export async function publishPicks(targets: string[], call: Call): Promise[] | string> { + const ready = (items: Record[]): Record[] => items.filter((one) => one["state"] === "ready"); + if (targets.every((one) => /^[0-9a-f]{16}$/.test(one))) { + const items = await Promise.all(targets.map(async (id) => (await call("GET", `/api/v1/library/items/${id}`)).body["item"] as Record | undefined)); + const found = ready(items.filter((one): one is Record => Boolean(one))); + return found.length === targets.length ? found : "not every item is in the library and ready (nixamp library show ITEM)"; + } + const { existsSync, statSync } = await import("node:fs"); + const { resolve, relative, isAbsolute } = await import("node:path"); + const { homedir } = await import("node:os"); + const path = resolve(targets.join(" ").replace(/^~(?=$|\/)/, homedir())); + const query = new URLSearchParams({ limit: "500", sort: "path" }); + if (existsSync(path) && statSync(path).isDirectory()) { + const sources = ((await call("GET", "/api/v1/library/sources")).body["sources"] as Record[] | undefined) ?? []; + const source = sources.find((one) => { const rel = relative(String(one["root"] ?? ""), path); return !rel.startsWith("..") && !isAbsolute(rel); }); + if (!source) return `${path} is not inside the library (nixamp library sources)`; + query.set("source", String(source["id"])); + const folder = relative(String(source["root"]), path); + if (folder) query.set("folder", folder); + } else query.set("q", targets.join(" ")); + const found = ready(((await call("GET", `/api/v1/library/items?${query}`)).body["items"] as Record[] | undefined) ?? []); + return found.length ? found : "nothing ready in the library matches that (nixamp library search WORDS)"; +} + function flag(argv: string[], name: string): string | undefined { const at = argv.indexOf(name); return at === -1 ? undefined : argv[at + 1]; @@ -24,7 +59,7 @@ function words(argv: string[]): string[] { for (let at = 0; at < argv.length; at++) { const one = argv[at]!; if (one.startsWith("--")) { - if (!["--json", "--force", "--wait"].includes(one)) at += 1; + if (!["--json", "--force", "--wait", "--dry-run", "--here"].includes(one)) at += 1; continue; } out.push(one); @@ -162,13 +197,54 @@ export async function libraryTool(argv: string[]): Promise { return 2; } case "publish": { - // On demand on this server's guide (guide-vods.ts): the server builds the - // file's public address from its own public URL and listen key. - const [item] = rest; + // On demand on live.streaming (guide-vods.ts). ITEM, a folder or search + // words pick the files; each goes to the guide as its public play + // address, under this machine's nixamp.com session, so a file on any + // server lands on the one guide. --here publishes on this server's own. const basis = flag(argv, "--basis"); - if (!item || !basis) { console.error("nixamp library publish needs an item id and --basis owned|permission|licensed|creative-commons|public-domain"); return 2; } - return out(await call("POST", "/api/v1/guide/vods", { item, basis, agree: true, title: flag(argv, "--title") ?? "", note: flag(argv, "--note") ?? "" }), - (body) => `on demand: ${String(body["page"])}`); + if (!rest.length || !basis) { console.error("nixamp library publish needs ITEM, a folder or search words, and --basis owned|permission|licensed|creative-commons|public-domain"); return 2; } + const items = await publishPicks(rest, call); + if (typeof items === "string") { console.error(`nixamp: ${items}`); return 1; } + const title = items.length === 1 ? flag(argv, "--title") ?? "" : ""; + const note = flag(argv, "--note") ?? ""; + if (argv.includes("--dry-run")) { + console.log(`${items.map(itemLine).join("\n")}\n${items.length} to publish (--dry-run: nothing sent)`); + return 0; + } + const here = argv.includes("--here"); + const guide = (flag(argv, "--guide") ?? STREAMING_GUIDE).replace(/\/+$/, ""); + const session = here ? null : readSession(); + if (!here && !session?.token) { console.error("nixamp: publishing on live.streaming needs a nixamp.com account on this machine: run nixamp login (or pass --here)"); return 1; } + let failed = 0; + const done: Record[] = []; + for (const item of items) { + const id = String(item["id"]); + let answer: { status: number; body: Record }; + if (here) answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note }); + else { + const play = String((((await call("GET", `/api/v1/library/items/${id}`)).body["item"] ?? {}) as Record)["playAddress"] ?? ""); + if (!play) answer = { status: 409, body: { error: "this server has no public address and listen key to play it from (set one with --public-url)", code: "no_address" } }; + else { + try { + const response = await fetch(`${guide}/api/v1/guide/vods`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${session!.token}` }, + body: JSON.stringify({ play, basis, agree: true, title, note }), + }); + answer = { status: response.status, body: (await response.json().catch(() => ({}))) as Record }; + } catch (error) { + answer = { status: 503, body: { error: `could not reach ${guide}: ${(error as Error).message}`, code: "unreachable" } }; + } + } + } + done.push({ item: id, status: answer.status, ...answer.body }); + if (answer.status >= 400) { + failed += 1; + if (!json) console.error(`nixamp: ${itemLine(item)}: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`); + } else if (!json) console.log(`on demand: ${String(answer.body["page"])} ${String(item["title"] || item["rel"] || id)}`); + } + if (json) console.log(JSON.stringify({ published: done }, null, 2)); + return failed ? 1 : 0; } case "vods": return out(await call("GET", "/api/v1/guide/vods"), (body) => { diff --git a/src/library-routes.ts b/src/library-routes.ts index 6bb89c1..925ad77 100644 --- a/src/library-routes.ts +++ b/src/library-routes.ts @@ -32,6 +32,8 @@ export interface LibraryContext { sendFile: (request: IncomingMessage, response: ServerResponse, file: string, cache: string) => void; /** The file as a browser plays it (server.ts playFile): the bytes when it can, MP4 or MP3 when not. */ play?: (request: IncomingMessage, response: ServerResponse, file: string) => Promise; + /** The item's public play address with the listen key, what a guide publishes on demand; "" when this server has none. */ + playAddress?: (id: string) => string; json: (response: ServerResponse, status: number, body: unknown) => void; readBody: (request: IncomingMessage) => Promise; } @@ -267,7 +269,9 @@ export async function handleLibrary( return true; } if (method === "GET") { - ctx.json(response, 200, { item: itemView(item, ctx.canWrite) }); + // The owner also gets where it plays publicly, for publishing it on another server's guide. + const play = ctx.canWrite && ctx.playAddress ? ctx.playAddress(item.id) : ""; + ctx.json(response, 200, { item: { ...itemView(item, ctx.canWrite), ...(play ? { playAddress: play } : {}) } }); return true; } if (method === "PATCH") { diff --git a/src/library-usage.ts b/src/library-usage.ts index bb518b7..7663ca7 100644 --- a/src/library-usage.ts +++ b/src/library-usage.ts @@ -16,8 +16,9 @@ export const LIBRARY_USAGE = `nixamp library — what this server has, indexed nixamp library playlist new NAME [ITEM...] nixamp library playlist show|delete PLAYLIST nixamp library playlist add PLAYLIST ITEM... - nixamp library publish ITEM --basis B [--title T] [--note N] - on demand on live.streaming: a link that plays it from the start + nixamp library publish ITEM... | FOLDER | WORDS --basis B [--title T] [--note N] [--dry-run] + on demand on live.streaming: a link that plays each from the start, + from any of your servers (needs nixamp login); --here for this server's own guide (B: owned, permission, licensed, creative-commons, public-domain) nixamp library vods what is published on demand, with each link nixamp library unpublish VOD take one off diff --git a/src/server.ts b/src/server.ts index 20c8691..8b59427 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3470,6 +3470,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) { retag: options.library.retag, sendFile, play: (req, res, file) => playFile(req, res, file, url, options), + playAddress: (id) => { + const publicBase = (options.stations?.publicBase?.() ?? "").replace(/\/+$/, ""); + return publicBase && listenKey ? `${publicBase}/api/v1/library/items/${id}/play?k=${listenKey}` : ""; + }, json, readBody, }); diff --git a/test/library-publish.test.ts b/test/library-publish.test.ts new file mode 100644 index 0000000..66f1535 --- /dev/null +++ b/test/library-publish.test.ts @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { publishPicks } from "../src/library-cli.ts"; + +const film = (id: string, rel: string, state = "ready") => ({ id, rel, state, title: rel.split("/").pop() }); + +test("library publish picks: ids as they are, a folder as everything under it, else search words", async () => { + const top = mkdtempSync(join(tmpdir(), "nixamp-pick-")); + mkdirSync(join(top, "films", "cuts"), { recursive: true }); + const asked: string[] = []; + const items = [film("aaaaaaaaaaaaaaaa", "films/cuts/One.mp4"), film("bbbbbbbbbbbbbbbb", "films/cuts/Two.mp4"), film("cccccccccccccccc", "films/cuts/Three.mkv", "failed")]; + const call = async (_method: string, path: string) => { + asked.push(path); + if (path === "/api/v1/library/sources") return { status: 200, body: { sources: [{ id: "src1", root: top }] } }; + if (path.startsWith("/api/v1/library/items?")) return { status: 200, body: { items } }; + const one = items.find((item) => path === `/api/v1/library/items/${item.id}`); + return one ? { status: 200, body: { item: one } } : { status: 404, body: { error: "no such item" } }; + }; + try { + // A folder: its source and the folder inside it; only ready files. + const folder = await publishPicks([join(top, "films", "cuts")], call); + assert.deepEqual((folder as Array<{ id: string }>).map((one) => one.id), ["aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb"]); + const query = new URLSearchParams(asked.at(-1)!.split("?")[1]); + assert.equal(query.get("source"), "src1"); + assert.equal(query.get("folder"), join("films", "cuts")); + // Ids, every one of them ready, or a reason. + assert.equal((await publishPicks(["aaaaaaaaaaaaaaaa"], call) as unknown[]).length, 1); + assert.equal(typeof await publishPicks(["aaaaaaaaaaaaaaaa", "cccccccccccccccc"], call), "string"); + // Words search. + await publishPicks(["fan", "cut"], call); + assert.equal(new URLSearchParams(asked.at(-1)!.split("?")[1]).get("q"), "fan cut"); + // A folder outside every source is named, not searched. + assert.match(String(await publishPicks([tmpdir()], call)), /not inside the library/); + } finally { + rmSync(top, { recursive: true, force: true }); + } +}); diff --git a/test/library-routes.test.ts b/test/library-routes.test.ts index d4c8d93..8d6204e 100644 --- a/test/library-routes.test.ts +++ b/test/library-routes.test.ts @@ -58,6 +58,10 @@ test("library over HTTP: the owner manages, the listen link reads without paths" assert.equal(found.items[0]!["rel"], "Band/LP/01 Song.mp3"); assert.equal(JSON.stringify(found).includes(top), false); assert.equal(found.items[0]!["override"], undefined); + // Where it plays publicly is the owner's, and only with a public address to build it on. + const anItem = String(found.items[0]!["id"]); + assert.equal(((await (await listener(`/api/v1/library/items/${anItem}`)).json()) as { item: Record }).item["playAddress"], undefined); + assert.equal(((await (await owner(`/api/v1/library/items/${anItem}`)).json()) as { item: Record }).item["playAddress"], undefined); const mine = await (await owner("/api/v1/library/items?q=song")).json() as { items: Array<{ path: string; id: string }> }; assert.equal(mine.items[0]!.path, join(root, "Band", "LP", "01 Song.mp3")); const albums = await (await listener("/api/v1/library/browse/albums")).json() as { groups: unknown[] }; diff --git a/web/src/station.css b/web/src/station.css index 27b3ff3..58d469d 100644 --- a/web/src/station.css +++ b/web/src/station.css @@ -46,6 +46,7 @@ .vod-list a{display:block;padding:16px;border:1px solid var(--line,#1f2a22);border-radius:14px;color:inherit;text-decoration:none;background:var(--panel,#0e1410)} .vod-list a:hover,.vod-list a:focus-visible{border-color:#3ddc84} .vod-list .vod-name{font-weight:700;overflow-wrap:anywhere}.vod-list .vod-meta{opacity:.7;font-size:.9rem;margin-top:4px} +.vod-how{margin-top:20px;padding:12px 16px;border:1px solid var(--line,#1f2a22);border-radius:14px}.vod-how summary{cursor:pointer;font-weight:700}.vod-how ol{padding-left:1.2em}.vod-how li{margin:8px 0}.vod-how code{overflow-wrap:anywhere;font-size:.9em} .station-buy{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0 0} .station-buy[hidden]{display:none} .station-buy a{display:inline-block;padding:8px 14px;border-radius:999px;border:1px solid #3ddc84;color:#3ddc84;font-weight:700;text-decoration:none} diff --git a/web/vod.html b/web/vod.html index ec3110c..c3ddc05 100644 --- a/web/vod.html +++ b/web/vod.html @@ -27,6 +27,14 @@

On demand

Published files: play from the start, seek anywhere, share the link.

    +
    + Publish yours +

    Anything on your nixamp server that you may share (your own work, with permission, licensed, Creative Commons or public domain) can go here, with a link of its own.

    +
      +
    1. In the browser: open your server in nixamp.com signed in, play the file, then Publish on live.streaming.
    2. +
    3. From the server, a whole folder at once:
      nixamp library publish ~/Videos/my-films --basis owned
      One file: its id from nixamp library search WORDS. Add --dry-run to see what it picks first; nixamp library vods lists the links.
    4. +
    +

    All videos · On the air now · Played by nixamp.