From a4441f949e2f7191a593fa5041828402c1732a30 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 22 Oct 2026 22:52:53 +0000 Subject: [PATCH] feat(vod): sell a video on demand as a download on nixamp.com, like the label's music (0.72.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide's owner puts a price on one of its own on-demand files (owned or public-domain only, `nixamp library publish ... --price 1`). /vod/ then shows Download · $1, which opens nixamp.com/buy (kind vod) and pays through CoinPay like a song or an album. The receipt and My music link to the guide's /api/v1/guide/vods//download, which checks the store grant and sends the buyer on to the file's server with download=1 (an attachment, named for what is sent). Watching stays free. Re-publishing keeps an old title and note. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/guide-vods.ts | 59 ++++++++++++++++++++++++++++++++++++++-- src/label-routes.ts | 4 +-- src/library-cli.ts | 8 ++++-- src/library-routes.ts | 8 ++++-- src/library-usage.ts | 2 ++ src/music-client.ts | 2 +- src/music-store.ts | 54 ++++++++++++++++++++++++++++++------ src/server.ts | 11 +++++++- src/store-routes.ts | 22 ++++++++++++++- test/guide-vods.test.ts | 37 +++++++++++++++++++++++++ test/music-store.test.ts | 41 ++++++++++++++++++++++++++++ web/src/radio.ts | 13 ++++++++- web/src/vod.ts | 12 +++++++- web/vod.html | 1 + 15 files changed, 254 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 82a2f19..9441417 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/guide-vods.ts b/src/guide-vods.ts index 0479b99..21632a0 100644 --- a/src/guide-vods.ts +++ b/src/guide-vods.ts @@ -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//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"; @@ -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; @@ -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), @@ -215,6 +229,8 @@ export interface VodContext { readBody: (request: IncomingMessage) => Promise; /** 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. */ @@ -222,6 +238,24 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo 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) }); @@ -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 : "")); @@ -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; } diff --git a/src/label-routes.ts b/src/label-routes.ts index 77a8928..14fdd15 100644 --- a/src/label-routes.ts +++ b/src/label-routes.ts @@ -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 }); if (!zip) { ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" }); return true; diff --git a/src/library-cli.ts b/src/library-cli.ts index 4e86893..a4aa096 100644 --- a/src/library-cli.ts +++ b/src/library-cli.ts @@ -207,6 +207,10 @@ export async function libraryTool(argv: string[]): Promise { 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; @@ -220,7 +224,7 @@ export async function libraryTool(argv: string[]): Promise { 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 }); + 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)["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" } }; @@ -229,7 +233,7 @@ export async function libraryTool(argv: string[]): Promise { 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 }; } catch (error) { diff --git a/src/library-routes.ts b/src/library-routes.ts index 925ad77..754b10a 100644 --- a/src/library-routes.ts +++ b/src/library-routes.ts @@ -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; + play?: (request: IncomingMessage, response: ServerResponse, file: string, download?: 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; @@ -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. diff --git a/src/library-usage.ts b/src/library-usage.ts index 7663ca7..000ac53 100644 --- a/src/library-usage.ts +++ b/src/library-usage.ts @@ -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 diff --git a/src/music-client.ts b/src/music-client.ts index b3629c3..ab58e21 100644 --- a/src/music-client.ts +++ b/src/music-client.ts @@ -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; } diff --git a/src/music-store.ts b/src/music-store.ts index 10104e8..3168b10 100644 --- a/src/music-store.ts +++ b/src/music-store.ts @@ -14,15 +14,27 @@ * nixamp.com never holds the music. It signs a grant -- what was bought, from * which server, until when -- and the server asks nixamp.com whether a grant * is good before it plays or zips anything (/api/v1/store/grant). + * + * The same checkout sells a film or a recording on demand (kind "vod"): one + * published file of a station guide, at the price the guide's owner set on + * it. Its grant names the guide and the video's short id, and the guide's + * /api/v1/guide/vods//download hands over the file once it checks it. */ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import type { Db, Support } from "./support.ts"; import { SupportError, coinOf } from "./support.ts"; -export type Kind = "song" | "album" | "artist"; +export type Kind = "song" | "album" | "artist" | "vod"; + +/** In cents. A video on demand carries its own price, set where it is published. */ +export const PRICES: Record, number> = { song: 10, album: 100, artist: 200 }; -/** In cents. */ -export const PRICES: Record = { song: 10, album: 100, artist: 200 }; +const VOD_ID = /^[A-Za-z0-9_-]{1,32}$/; + +/** The guides whose videos may be sold here besides the known servers: live.streaming and NIXAMP_VOD_GUIDES. */ +export function vodGuides(env: Record = process.env): string[] { + return ["https://live.streaming.pit.world", ...(env["NIXAMP_VOD_GUIDES"] ?? "").split(",")].map(serverBase).filter(Boolean); +} const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i; const KEY = /^[a-f0-9]{10}$/; @@ -90,13 +102,13 @@ export function readGrant(secret: string, grant: string, now = Date.now()): (Sco const exp = Number(parts[1]); if (!Number.isFinite(exp) || exp < now) return null; const kind = parts[2] as Kind; - if (!(kind in PRICES)) return null; + if (!(kind in PRICES) && kind !== "vod") return null; return { kind, artist: parts[3]!, album: parts[4] === "-" ? "" : parts[4]!, n: Number(parts[5]), server: Buffer.from(parts[6]!, "base64url").toString("utf8"), exp }; } /** Whether a grant for `scope` covers this file of the catalogue. */ export function covers(scope: Pick, artist: string, album: string, n?: number): boolean { - if (scope.artist !== artist) return false; + if (scope.kind === "vod" || scope.artist !== artist) return false; if (scope.kind === "artist") return true; if (scope.album !== album) return false; if (scope.kind === "album") return true; @@ -190,8 +202,25 @@ export class MusicStore { return await response.json() as LabelView; } + /** A video on demand of a guide, at the price its owner set; refused when it is not for sale. */ + private async describeVod(input: Record): Promise { + const server = serverBase(input["server"]); + const known = server !== "" && (new URL(server).hostname.endsWith(".nixamp.com") || vodGuides().includes(server) || this.options.servers().includes(server)); + if (!known) throw new SupportError("That guide does not sell videos here.", 404, "not_found"); + const id = String(input["vod"] ?? input["artist"] ?? ""); + if (!VOD_ID.test(id)) throw new SupportError("No such video.", 404, "not_found"); + const response = await (this.options.fetcher ?? fetch)(`${server}/api/v1/guide/vods/${id}`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } }); + if (response.status === 404) throw new SupportError("No such video.", 404, "not_found"); + if (!response.ok) throw new SupportError("That guide could not be read. Try again.", 502, "catalogue"); + const vod = ((await response.json()) as { vod?: { name?: string; artist?: string; priceCents?: number } }).vod; + const cents = Number(vod?.priceCents ?? 0); + if (!vod || !Number.isInteger(cents) || cents <= 0) throw new SupportError("That video is free to watch and not sold as a download.", 404, "not_for_sale"); + return { server, kind: "vod", artist: id, album: "", n: 0, title: String(vod.name ?? id).slice(0, 160), artistName: String(vod.artist || "On demand").slice(0, 160), amountCents: cents, songs: 1 }; + } + /** What is for sale at this address, with its name and price; refused when there is no such thing. */ async describe(input: Record): Promise { + if (input["kind"] === "vod") return this.describeVod(input); // An opaque server id (music-hub.ts), or an https origin. const asked = String(input["server"] ?? ""); const server = /^[a-f0-9]{10}$/.test(asked) ? (this.options.resolveServer?.(asked) ?? "") : serverBase(asked); @@ -199,7 +228,7 @@ export class MusicStore { // and certificates nixamp.com issues): the directory empties on every restart of nixamp.com. const named = server ? new URL(server).hostname.endsWith(".nixamp.com") : false; if (!server || !(named || this.options.servers().includes(server))) throw new SupportError("That server does not sell music here.", 404, "not_found"); - const kind = String(input["kind"] ?? "") as Kind; + const kind = String(input["kind"] ?? "") as Exclude; if (!(kind in PRICES)) throw new SupportError("Buy a song, an album or an artist.", 400, "invalid_kind"); const artistKey = String(input["artist"] ?? ""); const albumKey = kind === "artist" ? "" : String(input["album"] ?? ""); @@ -228,7 +257,7 @@ export class MusicStore { email, accountId: account?.id || email, order: `music-${id}`, - description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`, + description: item.kind === "vod" ? `${item.title} (download)` : `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`, metadata: { product: "music", purchase: id, kind: item.kind }, redirect: this.link(id, token), }); @@ -278,6 +307,14 @@ export class MusicStore { return again ? row(again) : null; } const purchase = row(updated); + if (purchase.kind === "vod") { + await this.mail(purchase.email, `Your download: ${purchase.title}`, [ + `Thank you. "${purchase.title}" is yours to download and keep.`, + `Download it, now or any time: ${this.link(purchase.id, purchase.token)}`, + `Sign in to nixamp.com with ${purchase.email} and everything you have bought is under My music on ${this.options.site}/music/me.`, + ]); + return purchase; + } await this.mail(purchase.email, `Your music: ${purchase.title}`, [ `Thank you. "${purchase.title}" by ${purchase.artistName} is yours: MP3s with the cover art and the lyrics, in one zip.`, `Download it, now or any time: ${this.link(purchase.id, purchase.token)}`, @@ -304,8 +341,9 @@ export class MusicStore { return readGrant(this.options.secret, grant, this.now()); } - /** Where the zip of a purchase is made: on the server that holds the music. */ + /** Where the zip of a purchase is made: on the server that holds the music (a video: its guide hands over the file). */ zipUrl(purchase: Purchase): string { + if (purchase.kind === "vod") return `${purchase.server}/api/v1/guide/vods/${purchase.artist}/download?grant=${encodeURIComponent(this.grant(purchase))}`; return `${purchase.server}/api/v1/label/zip?grant=${encodeURIComponent(this.grant(purchase))}`; } diff --git a/src/server.ts b/src/server.ts index 8b59427..52da9b4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3407,6 +3407,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) { }, json, readBody, + grants: storeGrants, throttle: () => { const verdict = guard.check(`vods:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT); return verdict.ok ? null : { retryAfter: verdict.retryAfter }; @@ -3469,7 +3470,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) { probe: options.library.probe, retag: options.library.retag, sendFile, - play: (req, res, file) => playFile(req, res, file, url, options), + play: (req, res, file, download) => playFile(req, res, file, url, options, undefined, download), playAddress: (id) => { const publicBase = (options.stations?.publicBase?.() ?? "").replace(/\/+$/, ""); return publicBase && listenKey ? `${publicBase}/api/v1/library/items/${id}/play?k=${listenKey}` : ""; @@ -7332,7 +7333,12 @@ async function liveAudio( async function playFile( request: IncomingMessage, response: ServerResponse, file: string, url: URL, options: Pick, onPicture: () => void = () => undefined, + /** Sent as an attachment under this name (no extension: it depends on what is sent), for a bought download. */ + download = "", ): Promise { + const attach = (ext: string): void => { + if (download) response.setHeader("content-disposition", `attachment; filename*=UTF-8''${encodeURIComponent(`${download}${ext}`)}`); + }; // A browser asks for every track here, and a matroska or an avi handed // to it raw is bytes it cannot play. Seeking is what this route is for // and transcoding gives it up, but an unseekable film beats a silent @@ -7349,6 +7355,7 @@ async function playFile( const allowHevc = url.searchParams.get("hevc") === "1"; if (playsInBrowser(file) && capKbps === 0) { + attach(extname(file)); sendFile(request, response, file); return; } @@ -7365,6 +7372,7 @@ async function playFile( // added by an older nixamp goes to an audio element for ever, and // the only cure is noticing and adding it again. onPicture(); + attach(".mp4"); pipeFfmpeg( request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps, { allowHevc }), "video/mp4", @@ -7373,6 +7381,7 @@ async function playFile( return; } } + attach(".mp3"); transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]); } diff --git a/src/store-routes.ts b/src/store-routes.ts index e930ce4..cf89959 100644 --- a/src/store-routes.ts +++ b/src/store-routes.ts @@ -2,6 +2,7 @@ * The music store's pages and API on nixamp.com (music-store.ts). * * GET /buy?server=&kind=&artist=&album=&n= what it is, the price, email + coin, Pay + * GET /buy?server=&kind=vod&vod= the same for a video on demand of a station guide * GET /purchases/:id?t= the receipt: waiting, or Download the zip * GET /api/v1/store/coins the coins nixamp's CoinPay business takes * POST /api/v1/store/checkout {server, kind, artist, album?, n?, email, coin} -> {url} @@ -113,6 +114,21 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv return true; } const me = await ctx.account().catch(() => null); + if (item.kind === "vod") { + html(response, 200, page(`Download ${item.title}`, `
+

${escape(item.title)}

${escape(item.artistName)}

+
${price(item.amountCents)}
+
  • The whole file, to keep and play offline
  • Download again any time from your receipt or My music
+
+ + + +
+

+Payments go through CoinPay to nixamp.com: pick any coin, or PayPal, on CoinPay's page. Watching it on demand stays free: back to the video.${me ? "" : ` Sign in with the same email to keep everything under My music.`} +
`, JSON.stringify({ server: item.server, kind: "vod", vod: item.artist })), method); + return true; + } const what = item.kind === "song" ? "One song" : item.kind === "album" ? `The album, ${item.songs} songs` : `Everything by ${escape(item.artistName)}, ${item.songs} songs so far`; const cover = item.kind === "artist" ? "" : ``; html(response, 200, page(`Download ${item.title}`, `
${cover} @@ -136,7 +152,11 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv html(response, 404, page("No such purchase", `

No such purchase

That link is not one of ours.

`), method); return true; } - const body = purchase.status === "paid" + const body = purchase.status === "paid" && purchase.kind === "vod" + ? `

${escape(purchase.title)}

${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}

+Download the video +Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under My music.
` + : purchase.status === "paid" ? `

${escape(purchase.title)}

${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}

Download the zip MP3s with the cover art and lyrics, plus each song's .lyrics.txt, cover.jpg and album.json. Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under My music, to play or download.
` diff --git a/test/guide-vods.test.ts b/test/guide-vods.test.ts index 45ef020..6e27fd8 100644 --- a/test/guide-vods.test.ts +++ b/test/guide-vods.test.ts @@ -137,3 +137,40 @@ test("nobody signed in publishes nothing; the owner may name an item of its own; member.close(); } }); + +test("the owner prices its own sellable file; a bought grant redirects to the file as a download", async () => { + const dir = mkdtempSync(join(tmpdir(), "vods-")); + const store = new VodStore(join(dir, "vods.json")); + const probe = new VodProbe(fakeServer(ITEM)); + const good = { kind: "vod", artist: "", server: "https://live.streaming.pit.world" }; + const grants = { check: async (g: string) => (g === "good" ? good : g === "other" ? { ...good, artist: "zzzzzzzz" } : null) }; + let control = true; + let member = ""; + const { base, close } = await serve({ store, probe, base: "https://live.streaming.pit.world", get canControl() { return control; }, member: async () => member, grants }); + try { + const post = (body: unknown) => fetch(`${base}/api/v1/guide/vods`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + assert.equal((await post({ play: PLAY, basis: "licensed", agree: true, price: 2 })).status, 400, "licensed is shareable, not sellable"); + assert.equal((await post({ play: PLAY, basis: "public-domain", agree: true, price: 500 })).status, 400, "a typo of a price"); + const made = await post({ play: PLAY, basis: "public-domain", agree: true, note: "PD", price: 1.5 }); + assert.equal(made.status, 200); + const vod = ((await made.json()) as { vod: { id: string; priceCents: number } }).vod; + assert.equal(vod.priceCents, 150); + // Published again with nothing new keeps its price, title and note. + const again = ((await (await post({ play: PLAY, basis: "public-domain", agree: true })).json()) as { vod: { id: string; priceCents: number; note: string } }).vod; + assert.deepEqual([again.id, again.priceCents, again.note], [vod.id, 150, "PD"]); + good.artist = vod.id; + const download = (g: string) => fetch(`${base}/api/v1/guide/vods/${vod.id}/download?grant=${g}`, { redirect: "manual" }); + const ok = await download("good"); + assert.equal(ok.status, 302); + assert.equal(ok.headers.get("location"), `${PLAY}&download=1`); + assert.equal((await download("other")).status, 403, "a grant for another video"); + assert.equal((await download("bad")).status, 403); + // A member may not put a price on anything. + control = false; + member = "acct-1"; + assert.equal((await post({ play: PLAY.replace("0123456789abcdef", "0123456789abcdef"), basis: "owned", agree: true, price: 1 })).status, 409, "the owner's file is not the member's to touch"); + } finally { + close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/music-store.test.ts b/test/music-store.test.ts index a3adcf1..d03c376 100644 --- a/test/music-store.test.ts +++ b/test/music-store.test.ts @@ -103,3 +103,44 @@ test("buy: 10 cents a song, $1 an album, $2 an artist; paid once, mailed once, t assert.equal(db.rows.find((one) => one["id"] === artist.id)!["user_id"], "user-1"); assert.deepEqual(await store.settle("pay_unknown"), { settled: false }); }); + +test("a video on demand sells at the price its guide set, and its grant opens only its download", async () => { + const db = fakeDb(); + const paid = new Set(); + const payments: Array> = []; + const support = { + async pay(input: Record) { + payments.push(input); + return { url: "https://coinpayportal.com/pay/pay_v1", paymentId: "pay_v1" }; + }, + async isPaid(id: string) { return paid.has(id); }, + } as unknown as Support; + const vods: Record = { + hnhX1chd: { name: "House on Haunted Hill (1959)", artist: "William Castle", priceCents: 150 }, + free1234: { name: "Free one", artist: "" }, + }; + const fetcher = (async (url: string) => { + const one = /^https:\/\/live\.streaming\.pit\.world\/api\/v1\/guide\/vods\/(.+)$/.exec(url); + if (one && vods[one[1]!]) return new Response(JSON.stringify({ vod: vods[one[1]!] }), { status: 200 }); + if (url.includes("resend")) return new Response("{}", { status: 200 }); + return new Response("{}", { status: 404 }); + }) as typeof fetch; + const store = new MusicStore({ db, support, mail: null, site: "https://nixamp.com", secret: "s3cret", servers: () => [], fetcher }); + const item = await store.describe({ server: "https://live.streaming.pit.world", kind: "vod", vod: "hnhX1chd" }); + assert.deepEqual([item.kind, item.artist, item.title, item.artistName, item.amountCents], ["vod", "hnhX1chd", "House on Haunted Hill (1959)", "William Castle", 150]); + await assert.rejects(store.describe({ server: "https://live.streaming.pit.world", kind: "vod", vod: "free1234" }), /not sold/); + await assert.rejects(store.describe({ server: "https://evil.example", kind: "vod", vod: "hnhX1chd" }), /does not sell/); + await assert.rejects(store.describe({ server: "https://live.streaming.pit.world", kind: "vod", vod: "nope9999" }), /No such video/); + + const made = await store.checkout({ server: "https://live.streaming.pit.world", kind: "vod", vod: "hnhX1chd", email: "a@b.co" }, null); + assert.equal(payments[0]!["amountUsd"], 1.5); + paid.add("pay_v1"); + const purchase = (await store.get(made.id, made.token))!; + assert.equal(purchase.status, "paid"); + const zip = store.zipUrl(purchase); + assert.match(zip, /^https:\/\/live\.streaming\.pit\.world\/api\/v1\/guide\/vods\/hnhX1chd\/download\?grant=g1\./); + const scope = readGrant("s3cret", decodeURIComponent(zip.split("grant=")[1]!))!; + assert.deepEqual([scope.kind, scope.artist, scope.server], ["vod", "hnhX1chd", "https://live.streaming.pit.world"]); + // A video's grant never opens the label's music. + assert.equal(covers(scope, "hnhX1chd", ""), false); +}); diff --git a/web/src/radio.ts b/web/src/radio.ts index 2a6a895..fbf327d 100644 --- a/web/src/radio.ts +++ b/web/src/radio.ts @@ -751,7 +751,7 @@ async function loadOnAir(): Promise { /* ---- My music (bought on nixamp.com) -------------------------------------- */ 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; artist: string; album: string; n: number; paidAt: number; grant?: string; zip?: string; link?: string; } interface Catalogue { genres: Array<{ artists: Array<{ key: string; name: string; albums: Array<{ key: string; title: string; songs: Array<{ n: number; title: string }> }> }> }> } @@ -801,6 +801,17 @@ async function loadMusic(): Promise { : "Nothing bought yet. Every AI label song is 10¢, an album $1, an artist's whole catalogue $2: look for Download on a station's now playing."; const list = $("#music-list"); list.replaceChildren(...bought.map((one) => { + // A bought video plays on its own page; the download is the file itself, not a zip. + if (one.kind === "vod") { + return el("article", { class: "station", "aria-label": one.title }, + el("div", { class: "station-head" }, + el("span", { class: "state state-live" }, "Video"), + el("h3", {}, one.title), + el("span", { class: "muted" }, `${one.artistName} · ${new Date(one.paidAt).toLocaleDateString()}`), + ), + el("div", { class: "actions" }, el("a", { href: `${one.server}/vod/${one.artist}`, target: "_blank", rel: "noopener" }, "Watch"), one.zip ? el("a", { href: one.zip, class: "ghost" }, "Download") : null, one.link ? el("a", { href: one.link, target: "_blank", rel: "noopener" }, "Receipt") : null), + ); + } const play = el("button", { type: "button" }, "Play"); play.addEventListener("click", () => void act("Play", async () => { musicQueue = await songsOf(one); diff --git a/web/src/vod.ts b/web/src/vod.ts index 5f1e1c7..5dc2b51 100644 --- a/web/src/vod.ts +++ b/web/src/vod.ts @@ -11,8 +11,11 @@ import "./station.css"; interface Vod { id: string; name: string; kind: "audio" | "video"; duration: number; artist: string; - note: string; available: boolean; play: string; page: string; + note: string; available: boolean; play: string; page: string; priceCents?: number; } + +/** "$1", "$2.50", "50¢": as nixamp.com's store writes a price. */ +const price = (cents: number): string => (cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`); interface Card { id?: string; name: string; kind: string; state: string; page: string; nowPlaying: { title: string; artist: string } | null } const $ = (selector: string): T => document.querySelector(selector)!; @@ -42,6 +45,13 @@ async function one(): Promise { $("#by").textContent = vod.artist; $("#length").textContent = vod.duration > 0 ? formatTime(vod.duration) : ""; if (vod.note) { $("#note").textContent = vod.note; $("#note").hidden = false; } + // Sold as a download on nixamp.com, as the label's music is; watching stays free. + if (vod.priceCents && vod.priceCents > 0) { + const buy = $("#buy-link"); + buy.href = `https://nixamp.com/buy?${new URLSearchParams({ server: new URL(vod.page).origin, kind: "vod", vod: vod.id })}`; + buy.textContent = `Download · ${price(vod.priceCents)}`; + $("#buy").hidden = false; + } // The short link, not the file's address: it outlives a change of server key. const link = $("#link"); link.value = vod.page; diff --git a/web/vod.html b/web/vod.html index c3ddc05..2fb04f0 100644 --- a/web/vod.html +++ b/web/vod.html @@ -15,6 +15,7 @@

 

+