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.71.0",
"version": "0.72.0",
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
"license": "MIT",
"type": "module",
Expand Down
59 changes: 57 additions & 2 deletions src/guide-vods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
*
* Nothing here plays anything: the file's own server does, and this only asks
* it, now and then, whether the file is still there and what it is.
*
* The guide's owner may put a price on one of its own (owned or public
* domain): watching stays free, and the page offers the file as a download,
* bought on nixamp.com (music-store.ts, kind "vod"). The grant nixamp.com
* signs opens /api/v1/guide/vods/<id>/download, which sends the buyer on to
* the file's server with `download=1`.
*/
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
Expand All @@ -32,8 +38,15 @@ export interface GuideVod {
/** The nixamp.com account that published it ("" for this server's owner). */
by: string;
publishedAt: number;
/** What its download costs on nixamp.com, in cents; absent or 0 when it is not sold. */
priceCents?: number;
}

/** The bases a price may be put on: what the owner may sell, not just share. */
export const SELLABLE_BASES: readonly RoomBasis[] = ["owned", "public-domain"];
/** $100: a price above that is a typo. */
export const VOD_PRICE_MAX_CENTS = 10_000;

/** Files one member may have on the guide at once, and files on it in all. */
export const VODS_PER_MEMBER = 25;
export const VODS_MAX = 1000;
Expand Down Expand Up @@ -191,6 +204,7 @@ export function vodView(vod: GuideVod, base: string, state: VodState | null): Re
note: vod.note,
basis: vod.basis,
publishedAt: vod.publishedAt,
...(vod.priceCents ? { priceCents: vod.priceCents } : {}),
available: state !== null,
play: vod.play,
page: vodPage(base, vod.id),
Expand All @@ -215,13 +229,33 @@ export interface VodContext {
readBody: (request: IncomingMessage) => Promise<unknown>;
/** Refused by the caller's rate limit, or null. */
throttle?: () => { retryAfter: number } | null;
/** nixamp.com's store grants (music-store.ts GrantVerifier), for a bought download. */
grants?: { check: (grant: string) => Promise<{ kind: string; artist: string; server: string } | null> };
}

/** Answers /api/v1/guide/vods; false when the path is not one. */
export async function handleVods(request: IncomingMessage, response: ServerResponse, path: string, ctx: VodContext): Promise<boolean> {
if (path !== "/api/v1/guide/vods" && !path.startsWith("/api/v1/guide/vods/")) return false;
const method = request.method ?? "GET";
const one = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})$/.exec(path);
const download = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})\/download$/.exec(path);
if (download && (method === "GET" || method === "HEAD")) {
const grant = new URL(request.url ?? "/", "http://x").searchParams.get("grant") ?? "";
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
const vod = ctx.store.get(download[1]!);
if (!scope || scope.kind !== "vod" || scope.artist !== download[1] || scope.server !== new URL(ctx.base).origin) {
ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
return true;
}
if (!vod) {
ctx.json(response, 404, { error: "that video has been taken off this guide", code: "not_found" });
return true;
}
// The file's own server sends it, as an attachment.
response.writeHead(302, { location: `${vod.play}&download=1`, "cache-control": "private, no-store", "referrer-policy": "no-referrer" });
response.end();
return true;
}
if (method === "GET" || method === "HEAD") {
if (path === "/api/v1/guide/vods") {
ctx.json(response, 200, { vods: await vodEntries(ctx.store, ctx.probe, ctx.base) });
Expand Down Expand Up @@ -249,7 +283,7 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
if (path === "/api/v1/guide/vods" && method === "POST") {
const raw = await ctx.readBody(request).catch(() => null);
const body = (typeof raw === "string" ? (() => { try { return JSON.parse(raw) as unknown; } catch { return null; } })() : raw) as
{ play?: unknown; item?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown } | null;
{ play?: unknown; item?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown; price?: unknown } | null;
// The owner may name one of this server's own items; everybody else hands over the address.
const own = ctx.canControl && typeof body?.item === "string" && /^[0-9a-f]{16}$/.test(body.item) && ctx.ownPlay ? ctx.ownPlay(body.item) : "";
const address = vodAddress(own || (typeof body?.play === "string" ? body.play : ""));
Expand Down Expand Up @@ -283,7 +317,28 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
}
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 160) : "";
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
const vod = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, duration: state.duration, basis, note, by: taken?.by ?? by });
const owner = taken?.by ?? by;
// A price, in dollars: only the owner, only on its own, only what it may sell. Left out, the old one stays.
let priceCents = taken?.priceCents ?? 0;
if (body?.price !== undefined && body.price !== null) {
const cents = Math.round(Number(body.price) * 100);
if (!ctx.canControl || owner !== "") {
ctx.json(response, 403, { error: "only the guide's owner sells downloads, of what it published itself", code: "not_owner" });
return true;
}
if (!Number.isFinite(cents) || cents < 0 || cents > VOD_PRICE_MAX_CENTS) {
ctx.json(response, 400, { error: `price is in dollars, 0 (not sold) to ${VOD_PRICE_MAX_CENTS / 100}`, code: "bad_price" });
return true;
}
if (cents > 0 && !SELLABLE_BASES.includes(basis)) {
ctx.json(response, 400, { error: `a download may be sold only of what you own or what is in the public domain, not ${basis}`, code: "not_sellable" });
return true;
}
priceCents = cents;
}
if (priceCents > 0 && !SELLABLE_BASES.includes(basis)) priceCents = 0;
// Published again without a title or a note keeps the ones it had (a price added later changes nothing else).
const vod = ctx.store.put({ play: address.play, name: title || taken?.name || state.name, kind: state.kind, duration: state.duration, basis, note: note || taken?.note || "", by: owner, ...(priceCents > 0 ? { priceCents } : {}) });
ctx.json(response, 200, { vod: vodView(vod, ctx.base, state), page: vodPage(ctx.base, vod.id) });
return true;
}
Expand Down
4 changes: 2 additions & 2 deletions src/label-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ export async function handleLabelRoutes(
const grant = url.searchParams.get("grant") ?? "";
if (path === "/api/v1/label/zip") {
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
if (!scope) {
if (!scope || scope.kind === "vod") {
ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
return true;
}
const zip = label.zipFor(scope);
const zip = label.zipFor({ ...scope, kind: scope.kind as Exclude<typeof scope.kind, "vod"> });
if (!zip) {
ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
return true;
Expand Down
8 changes: 6 additions & 2 deletions src/library-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ export async function libraryTool(argv: string[]): Promise<number> {
if (typeof items === "string") { console.error(`nixamp: ${items}`); return 1; }
const title = items.length === 1 ? flag(argv, "--title") ?? "" : "";
const note = flag(argv, "--note") ?? "";
// Sold as a download on nixamp.com (the guide's owner only, owned or public domain); 0 stops selling it.
const priceFlag = flag(argv, "--price");
const price = priceFlag === undefined ? {} : { price: Number(priceFlag.replace(/^\$/, "")) };
if ("price" in price && !Number.isFinite(price.price)) { console.error("nixamp: --price is in dollars, like 1 or 2.50"); return 2; }
if (argv.includes("--dry-run")) {
console.log(`${items.map(itemLine).join("\n")}\n${items.length} to publish (--dry-run: nothing sent)`);
return 0;
Expand All @@ -220,7 +224,7 @@ export async function libraryTool(argv: string[]): Promise<number> {
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 });
if (here) answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note, ...price });
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" } };
Expand All @@ -229,7 +233,7 @@ export async function libraryTool(argv: string[]): Promise<number> {
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 }),
body: JSON.stringify({ play, basis, agree: true, title, note, ...price }),
});
answer = { status: response.status, body: (await response.json().catch(() => ({}))) as Record<string, unknown> };
} catch (error) {
Expand Down
8 changes: 6 additions & 2 deletions src/library-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface LibraryContext {
retag: (tracks: Track[]) => void;
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>;
play?: (request: IncomingMessage, response: ServerResponse, file: string, download?: 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;
Expand Down Expand Up @@ -261,7 +261,11 @@ export async function handleLibrary(
if (!source?.enabled) return fail(409, "source_disabled", "that item's source is turned off");
// On demand: what a VOD link plays, from the start and seekable when the browser takes the file as it is.
if (itemMatch[2] === "play" && ctx.play) {
await ctx.play(request, response, item.path);
// `download=1`: the same file as an attachment, named for what it is (a bought VOD download).
const named = url.searchParams.get("download") === "1"
? (item.override.title || item.title || item.path.split("/").pop()!.replace(/\.[^.]+$/, "")).replace(/[\\/:*?"<>|\x00-\x1f]/g, "").slice(0, 160) || item.id
: "";
await ctx.play(request, response, item.path, named || undefined);
return true;
}
// Private: a browser may keep it, a shared cache may not.
Expand Down
2 changes: 2 additions & 0 deletions src/library-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
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)
--price USD: sell it as a download on nixamp.com (guide owner, owned or
public-domain only; publish again with --price 0 to stop)
nixamp library vods what is published on demand, with each link
nixamp library unpublish VOD take one off

Expand Down
2 changes: 1 addition & 1 deletion src/music-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface CatalogueArtist { key: string; name: string; genre: string; bio
export interface Catalogue { holder: string; server?: string; genres: Array<{ genre: string; station: string; artists: CatalogueArtist[] }> }

export interface Bought {
id: string; kind: "song" | "album" | "artist"; title: string; artistName: string; server: string;
id: string; kind: "song" | "album" | "artist" | "vod"; title: string; artistName: string; server: string;
status: string; paidAt: number; amountUsd: number; zip?: string; link?: string;
}

Expand Down
Loading
Loading