Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
90 changes: 83 additions & 7 deletions src/library-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }>;

/**
* 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<Record<string, unknown>[] | string> {
const ready = (items: Record<string, unknown>[]): Record<string, unknown>[] => 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<string, unknown> | undefined));
const found = ready(items.filter((one): one is Record<string, unknown> => 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<string, unknown>[] | 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<string, unknown>[] | 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];
Expand All @@ -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);
Expand Down Expand Up @@ -162,13 +197,54 @@ export async function libraryTool(argv: string[]): Promise<number> {
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<string, unknown>[] = [];
for (const item of items) {
const id = String(item["id"]);
let answer: { status: number; body: Record<string, unknown> };
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<string, unknown>)["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<string, unknown> };
} 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) => {
Expand Down
6 changes: 5 additions & 1 deletion src/library-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/** 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<string>;
}
Expand Down Expand Up @@ -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") {
Expand Down
5 changes: 3 additions & 2 deletions src/library-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
40 changes: 40 additions & 0 deletions test/library-publish.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
4 changes: 4 additions & 0 deletions test/library-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).item["playAddress"], undefined);
assert.equal(((await (await owner(`/api/v1/library/items/${anItem}`)).json()) as { item: Record<string, unknown> }).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[] };
Expand Down
1 change: 1 addition & 0 deletions web/src/station.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
8 changes: 8 additions & 0 deletions web/vod.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ <h1>On demand</h1>
<p class="station-about">Published files: play from the start, seek anywhere, share the link.</p>
<ul id="list" class="vod-list"></ul>
<p id="none" class="station-small" hidden>Nothing has been published here yet.</p>
<details class="vod-how">
<summary>Publish yours</summary>
<p class="station-about">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.</p>
<ol class="station-about">
<li>In the browser: open your server in <a href="https://nixamp.com">nixamp.com</a> signed in, play the file, then <strong>Publish on live.streaming</strong>.</li>
<li>From the server, a whole folder at once:<br /><code>nixamp library publish ~/Videos/my-films --basis owned</code><br /><span class="station-small">One file: its id from <code>nixamp library search WORDS</code>. Add <code>--dry-run</code> to see what it picks first; <code>nixamp library vods</code> lists the links.</span></li>
</ol>
</details>
</section>
<p class="station-small"><a href="/vod">All videos</a> · <a href="/">On the air now</a> · Played by <a href="https://nixamp.com">nixamp</a>.</p>
<div id="announce" class="sr-only" aria-live="assertive"></div>
Expand Down
Loading