nixamp 0.47.1 → 0.48.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 (53) hide show
  1. package/dist/guide-vods.d.ts +89 -0
  2. package/dist/guide-vods.js +262 -0
  3. package/dist/library-cli.js +24 -0
  4. package/dist/library-routes.d.ts +2 -0
  5. package/dist/library-routes.js +7 -2
  6. package/dist/library-usage.d.ts +1 -1
  7. package/dist/library-usage.js +6 -1
  8. package/dist/server.d.ts +6 -0
  9. package/dist/server.js +127 -38
  10. package/dist/station-guide.d.ts +2 -2
  11. package/dist/station-guide.js +6 -4
  12. package/package.json +1 -1
  13. package/src/guide-vods.ts +302 -0
  14. package/src/library-cli.ts +19 -0
  15. package/src/library-routes.ts +9 -2
  16. package/src/library-usage.ts +6 -1
  17. package/src/server.ts +140 -43
  18. package/src/station-guide.ts +8 -6
  19. package/web/dist/assets/{ar-BRH59tp-.js → ar-ropwGSSV.js} +1 -1
  20. package/web/dist/assets/chunk-Q2XQMA4B-CDsJ8fBU.js +1 -0
  21. package/web/dist/assets/{cs-CLBz8_mD.js → cs-Bi7uRYjd.js} +1 -1
  22. package/web/dist/assets/{da-DVl87YMx.js → da-B8OELPch.js} +1 -1
  23. package/web/dist/assets/{de-CkkF68l3.js → de-DJOTeXET.js} +1 -1
  24. package/web/dist/assets/{es-B_q-D-Sm.js → es-BIupzuzS.js} +1 -1
  25. package/web/dist/assets/{fi-Da05__2a.js → fi-A6MoWkzZ.js} +1 -1
  26. package/web/dist/assets/{fr-swwKqDQU.js → fr-DerIDIwl.js} +1 -1
  27. package/web/dist/assets/{hi-CPzREAUI.js → hi-CyUtRBH4.js} +1 -1
  28. package/web/dist/assets/{hls-OR5FY6YP-jwHO4SPh.js → hls-OR5FY6YP-bdUZBB11.js} +1 -1
  29. package/web/dist/assets/{hu-YLyCLxzo.js → hu-DD1vrIhY.js} +1 -1
  30. package/web/dist/assets/{id-B0LMxfwA.js → id-C6OAMzii.js} +1 -1
  31. package/web/dist/assets/{it-CeqYcwZW.js → it-Xy86vmU7.js} +1 -1
  32. package/web/dist/assets/main-CuG8-ADK.js +22 -0
  33. package/web/dist/assets/{mpegts-7CN75AI2-BtVIb2A1.js → mpegts-7CN75AI2-iUctLWip.js} +1 -1
  34. package/web/dist/assets/{mpegts-arZIPCfq.js → mpegts-DeEBZUXP.js} +1 -1
  35. package/web/dist/assets/{nl-BDFCAT58.js → nl-4Q-K2Jf1.js} +1 -1
  36. package/web/dist/assets/{radio-Bmt6N6Tf.js → radio-DI0H8flN.js} +1 -1
  37. package/web/dist/assets/{ru-CVyq7zo9.js → ru-CpxwOhC4.js} +1 -1
  38. package/web/dist/assets/{station-BwDP2cuf.css → station-BKmoJ0Sk.css} +1 -1
  39. package/web/dist/assets/{station-BhCxDaWb.js → station-X8pffqCW.js} +1 -1
  40. package/web/dist/assets/{sv-DBpGBxH3.js → sv-aRvu7MWa.js} +1 -1
  41. package/web/dist/assets/{uk-DBTg28H-.js → uk-BCwwfngg.js} +1 -1
  42. package/web/dist/assets/{vi-u-33JvNh.js → vi-tvWPiAH_.js} +1 -1
  43. package/web/dist/assets/vod-CdfPMa1u.js +1 -0
  44. package/web/dist/assets/{watch-BxAnF-yA.js → watch-hdfnhm3C.js} +1 -1
  45. package/web/dist/assets/{zh-BeiDong6.js → zh-Bp2aLEyX.js} +1 -1
  46. package/web/dist/index.html +3 -3
  47. package/web/dist/radio.html +2 -2
  48. package/web/dist/station.html +3 -3
  49. package/web/dist/sw.js +29 -28
  50. package/web/dist/vod.html +39 -0
  51. package/web/dist/watch.html +3 -3
  52. package/web/dist/assets/chunk-Q2XQMA4B-xxJBbNwd.js +0 -1
  53. package/web/dist/assets/main-CoVyWk7k.js +0 -22
@@ -0,0 +1,89 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type RoomBasis } from "./guide-rooms.ts";
3
+ import type { GuideEntry } from "./station-guide.ts";
4
+ export interface GuideVod {
5
+ id: string;
6
+ /** The file's play address, with its listen key. */
7
+ play: string;
8
+ name: string;
9
+ kind: "audio" | "video";
10
+ /** Seconds; 0 when its server did not know. */
11
+ duration: number;
12
+ basis: RoomBasis;
13
+ note: string;
14
+ /** The nixamp.com account that published it ("" for this server's owner). */
15
+ by: string;
16
+ publishedAt: number;
17
+ }
18
+ /** Files one member may have on the guide at once, and files on it in all. */
19
+ export declare const VODS_PER_MEMBER = 25;
20
+ export declare const VODS_MAX = 1000;
21
+ /**
22
+ * A file's play address taken apart, or null when it is not one: https (http
23
+ * only for a loopback address, for tests), a library item, a key, nothing else.
24
+ */
25
+ export declare function vodAddress(input: string): {
26
+ origin: string;
27
+ item: string;
28
+ key: string;
29
+ play: string;
30
+ } | null;
31
+ export declare const vodPage: (base: string, id: string) => string;
32
+ export declare class VodStore {
33
+ private readonly file;
34
+ private vods;
35
+ constructor(file: string);
36
+ static fileFor(stateDir: string, port: number): string;
37
+ /** Newest first, the order the guide shows them. */
38
+ list(): GuideVod[];
39
+ get(id: string): GuideVod | undefined;
40
+ /** Publishes a file, or refreshes the one already published at that address (its link stays). */
41
+ put(vod: Omit<GuideVod, "id" | "publishedAt">): GuideVod;
42
+ remove(id: string): boolean;
43
+ private save;
44
+ }
45
+ export interface VodState {
46
+ name: string;
47
+ kind: "audio" | "video";
48
+ duration: number;
49
+ artist: string;
50
+ }
51
+ /** Asks a file's own server what it is: its library item, read with the same key. */
52
+ export declare class VodProbe {
53
+ private readonly fetcher;
54
+ private readonly refuse;
55
+ private cache;
56
+ /** `refuse` names why an address may not be fetched from this machine, or "" (see RoomProbe). */
57
+ constructor(fetcher?: typeof fetch, refuse?: (link: string) => Promise<string>);
58
+ state(play: string, fresh?: boolean): Promise<VodState | null>;
59
+ private ask;
60
+ }
61
+ /** "1:02:03" or "4:05". */
62
+ export declare function clock(seconds: number): string;
63
+ /** The published files as guide cards, newest first; one whose server no longer has it says so. */
64
+ export declare function vodEntries(store: VodStore, probe: VodProbe, base: string): Promise<GuideEntry[]>;
65
+ /** One file as its page reads it: never who published it, always where it plays. */
66
+ export declare function vodView(vod: GuideVod, base: string, state: VodState | null): Record<string, unknown>;
67
+ export interface VodContext {
68
+ store: VodStore;
69
+ probe: VodProbe;
70
+ /** The page base for a file's link. */
71
+ base: string;
72
+ /** Whether the request holds this server's control key (or is its signed-in owner). */
73
+ canControl: boolean;
74
+ /** The nixamp.com account behind the request's session, or "". */
75
+ member: () => Promise<string>;
76
+ /** This server's own play address for one of its library items, for the owner publishing by id; "" when it has none. */
77
+ ownPlay?: (item: string) => string;
78
+ /** What this server's own library says of one of its items, so publishing it never asks itself over the network. */
79
+ ownState?: (item: string) => VodState | null;
80
+ json: (response: ServerResponse, status: number, body: unknown) => void;
81
+ /** The request body as text (server.ts readBody). */
82
+ readBody: (request: IncomingMessage) => Promise<unknown>;
83
+ /** Refused by the caller's rate limit, or null. */
84
+ throttle?: () => {
85
+ retryAfter: number;
86
+ } | null;
87
+ }
88
+ /** Answers /api/v1/guide/vods; false when the path is not one. */
89
+ export declare function handleVods(request: IncomingMessage, response: ServerResponse, path: string, ctx: VodContext): Promise<boolean>;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * On demand: published files on the station guide.
3
+ *
4
+ * A live room plays wherever the room is now; a video on demand plays from
5
+ * its start, and a listener may seek. It is one library item of any nixamp
6
+ * server, at the address `https://host/api/v1/library/items/<id>/play?k=KEY`
7
+ * (library-routes.ts: the file itself when a browser takes it, else an MP4
8
+ * made on the way). A nixamp.com member publishes one with a rights basis, as
9
+ * a live room is published; the guide lists it under "On demand", and its
10
+ * short link, <watchBase>/vod/<id>, is the thing to share.
11
+ *
12
+ * Nothing here plays anything: the file's own server does, and this only asks
13
+ * it, now and then, whether the file is still there and what it is.
14
+ */
15
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { randomBytes } from "node:crypto";
18
+ import { ROOM_BASES } from "./guide-rooms.js";
19
+ /** Files one member may have on the guide at once, and files on it in all. */
20
+ export const VODS_PER_MEMBER = 25;
21
+ export const VODS_MAX = 1000;
22
+ const STATE_TTL_MS = 60_000;
23
+ const PROBE_TIMEOUT_MS = 4_000;
24
+ /**
25
+ * A file's play address taken apart, or null when it is not one: https (http
26
+ * only for a loopback address, for tests), a library item, a key, nothing else.
27
+ */
28
+ export function vodAddress(input) {
29
+ let url;
30
+ try {
31
+ url = new URL(input.trim());
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
37
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
38
+ return null;
39
+ if (url.username || url.password || url.hash)
40
+ return null;
41
+ const item = /^\/api\/v1\/library\/items\/([0-9a-f]{16})\/(?:play|stream)\/?$/.exec(url.pathname);
42
+ if (!item)
43
+ return null;
44
+ const key = url.searchParams.get("k") ?? "";
45
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key) || [...url.searchParams.keys()].some((name) => name !== "k"))
46
+ return null;
47
+ return { origin: url.origin, item: item[1], key, play: `${url.origin}/api/v1/library/items/${item[1]}/play?k=${key}` };
48
+ }
49
+ export const vodPage = (base, id) => `${base}/vod/${id}`;
50
+ export class VodStore {
51
+ file;
52
+ vods = [];
53
+ constructor(file) {
54
+ this.file = file;
55
+ try {
56
+ if (existsSync(file)) {
57
+ const saved = JSON.parse(readFileSync(file, "utf8"));
58
+ this.vods = (saved.vods ?? []).filter((one) => vodAddress(one.play) !== null);
59
+ }
60
+ }
61
+ catch {
62
+ // Unreadable is empty; the next write replaces it.
63
+ }
64
+ }
65
+ static fileFor(stateDir, port) {
66
+ return join(stateDir, `guide-vods-${port}.json`);
67
+ }
68
+ /** Newest first, the order the guide shows them. */
69
+ list() {
70
+ return [...this.vods].sort((a, b) => b.publishedAt - a.publishedAt);
71
+ }
72
+ get(id) {
73
+ return this.vods.find((one) => one.id === id);
74
+ }
75
+ /** Publishes a file, or refreshes the one already published at that address (its link stays). */
76
+ put(vod) {
77
+ const existing = this.vods.find((one) => one.play === vod.play);
78
+ const saved = { ...vod, id: existing?.id ?? randomBytes(6).toString("base64url"), publishedAt: existing?.publishedAt ?? Date.now() };
79
+ this.vods = [...this.vods.filter((one) => one.play !== vod.play), saved];
80
+ this.save();
81
+ return saved;
82
+ }
83
+ remove(id) {
84
+ const before = this.vods.length;
85
+ this.vods = this.vods.filter((one) => one.id !== id);
86
+ if (this.vods.length === before)
87
+ return false;
88
+ this.save();
89
+ return true;
90
+ }
91
+ save() {
92
+ writeFileSync(`${this.file}.part`, `${JSON.stringify({ version: 1, vods: this.vods }, null, 2)}\n`);
93
+ renameSync(`${this.file}.part`, this.file);
94
+ }
95
+ }
96
+ /** Asks a file's own server what it is: its library item, read with the same key. */
97
+ export class VodProbe {
98
+ fetcher;
99
+ refuse;
100
+ cache = new Map();
101
+ /** `refuse` names why an address may not be fetched from this machine, or "" (see RoomProbe). */
102
+ constructor(fetcher = fetch, refuse = async () => "") {
103
+ this.fetcher = fetcher;
104
+ this.refuse = refuse;
105
+ }
106
+ async state(play, fresh = false) {
107
+ const cached = this.cache.get(play);
108
+ if (!fresh && cached && Date.now() - cached.at < STATE_TTL_MS)
109
+ return cached.state;
110
+ const state = await this.ask(play).catch(() => null);
111
+ this.cache.set(play, { at: Date.now(), state });
112
+ if (this.cache.size > 2000)
113
+ this.cache.delete(this.cache.keys().next().value);
114
+ return state;
115
+ }
116
+ async ask(play) {
117
+ const address = vodAddress(play);
118
+ if (!address || (await this.refuse(address.play)) !== "")
119
+ return null;
120
+ const answer = await this.fetcher(`${address.origin}/api/v1/library/items/${address.item}?k=${address.key}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), redirect: "error" });
121
+ if (!answer.ok)
122
+ return null;
123
+ const item = (await answer.json()).item;
124
+ if (!item || item["state"] !== "ready")
125
+ return null;
126
+ const rel = String(item["rel"] ?? "");
127
+ const fallback = rel.split("/").pop()?.replace(/\.[^.]+$/, "") ?? address.item;
128
+ return {
129
+ name: String(item["title"] || fallback).slice(0, 160),
130
+ kind: item["video"] === true ? "video" : "audio",
131
+ duration: Number(item["duration"]) || 0,
132
+ artist: String(item["artist"] ?? "").slice(0, 160),
133
+ };
134
+ }
135
+ }
136
+ /** "1:02:03" or "4:05". */
137
+ export function clock(seconds) {
138
+ const s = Math.max(0, Math.round(seconds));
139
+ const h = Math.floor(s / 3600);
140
+ const m = Math.floor((s % 3600) / 60);
141
+ const pad = (n) => String(n).padStart(2, "0");
142
+ return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`;
143
+ }
144
+ /** The published files as guide cards, newest first; one whose server no longer has it says so. */
145
+ export async function vodEntries(store, probe, base) {
146
+ return Promise.all(store.list().map(async (vod) => {
147
+ const state = await probe.state(vod.play);
148
+ return {
149
+ id: vod.id,
150
+ name: vod.name,
151
+ kind: state?.kind ?? vod.kind,
152
+ group: "vod",
153
+ state: state ? "vod" : "offline",
154
+ page: vodPage(base, vod.id),
155
+ nowPlaying: { title: clock(state?.duration || vod.duration), artist: state?.artist ?? "" },
156
+ };
157
+ }));
158
+ }
159
+ /** One file as its page reads it: never who published it, always where it plays. */
160
+ export function vodView(vod, base, state) {
161
+ return {
162
+ id: vod.id,
163
+ name: vod.name,
164
+ kind: state?.kind ?? vod.kind,
165
+ duration: state?.duration || vod.duration,
166
+ artist: state?.artist ?? "",
167
+ note: vod.note,
168
+ basis: vod.basis,
169
+ publishedAt: vod.publishedAt,
170
+ available: state !== null,
171
+ play: vod.play,
172
+ page: vodPage(base, vod.id),
173
+ };
174
+ }
175
+ /** Answers /api/v1/guide/vods; false when the path is not one. */
176
+ export async function handleVods(request, response, path, ctx) {
177
+ if (path !== "/api/v1/guide/vods" && !path.startsWith("/api/v1/guide/vods/"))
178
+ return false;
179
+ const method = request.method ?? "GET";
180
+ const one = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})$/.exec(path);
181
+ if (method === "GET" || method === "HEAD") {
182
+ if (path === "/api/v1/guide/vods") {
183
+ ctx.json(response, 200, { vods: await vodEntries(ctx.store, ctx.probe, ctx.base) });
184
+ return true;
185
+ }
186
+ const vod = one ? ctx.store.get(one[1]) : undefined;
187
+ if (!vod) {
188
+ ctx.json(response, 404, { error: "no such video on this guide", code: "not_found" });
189
+ return true;
190
+ }
191
+ ctx.json(response, 200, { vod: vodView(vod, ctx.base, await ctx.probe.state(vod.play)) });
192
+ return true;
193
+ }
194
+ const by = ctx.canControl ? "" : await ctx.member();
195
+ if (!ctx.canControl && by === "") {
196
+ ctx.json(response, 401, { error: "sign in to nixamp.com to publish a video", code: "sign_in" });
197
+ return true;
198
+ }
199
+ const refused = ctx.throttle?.();
200
+ if (refused) {
201
+ response.setHeader("retry-after", String(refused.retryAfter));
202
+ ctx.json(response, 429, { error: "too many changes; wait a moment", code: "throttled" });
203
+ return true;
204
+ }
205
+ if (path === "/api/v1/guide/vods" && method === "POST") {
206
+ const raw = await ctx.readBody(request).catch(() => null);
207
+ const body = (typeof raw === "string" ? (() => { try {
208
+ return JSON.parse(raw);
209
+ }
210
+ catch {
211
+ return null;
212
+ } })() : raw);
213
+ // The owner may name one of this server's own items; everybody else hands over the address.
214
+ const own = ctx.canControl && typeof body?.item === "string" && /^[0-9a-f]{16}$/.test(body.item) && ctx.ownPlay ? ctx.ownPlay(body.item) : "";
215
+ const address = vodAddress(own || (typeof body?.play === "string" ? body.play : ""));
216
+ if (!address) {
217
+ ctx.json(response, 400, { error: "play must be a nixamp file's address: https://host/api/v1/library/items/<id>/play?k=KEY", code: "bad_address" });
218
+ return true;
219
+ }
220
+ const basis = ROOM_BASES.find((b) => b === body?.basis);
221
+ if (!basis || body?.agree !== true) {
222
+ ctx.json(response, 400, { error: "say why it may be shared (owned, permission, licensed, creative-commons or public-domain) and agree", code: "basis_required" });
223
+ return true;
224
+ }
225
+ const state = (own && ctx.ownState ? ctx.ownState(String(body?.item)) : null) ?? await ctx.probe.state(address.play, true);
226
+ if (!state) {
227
+ ctx.json(response, 404, { error: "that file is not in its server's library, or the key does not open it", code: "not_found" });
228
+ return true;
229
+ }
230
+ const all = ctx.store.list();
231
+ const taken = all.find((v) => v.play === address.play);
232
+ if (taken && taken.by !== by && !ctx.canControl) {
233
+ ctx.json(response, 409, { error: "somebody else published that file", code: "taken" });
234
+ return true;
235
+ }
236
+ if (by !== "" && all.filter((v) => v.by === by && v.play !== address.play).length >= VODS_PER_MEMBER) {
237
+ ctx.json(response, 409, { error: `a member may have ${VODS_PER_MEMBER} videos on the guide; take one off first`, code: "too_many" });
238
+ return true;
239
+ }
240
+ if (!taken && all.length >= VODS_MAX) {
241
+ ctx.json(response, 409, { error: "the guide is full", code: "full" });
242
+ return true;
243
+ }
244
+ const title = typeof body?.title === "string" ? body.title.trim().slice(0, 160) : "";
245
+ const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
246
+ const vod = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, duration: state.duration, basis, note, by: taken?.by ?? by });
247
+ ctx.json(response, 200, { vod: vodView(vod, ctx.base, state), page: vodPage(ctx.base, vod.id) });
248
+ return true;
249
+ }
250
+ if (one && method === "DELETE") {
251
+ const vod = ctx.store.get(one[1]);
252
+ if (!vod || (!ctx.canControl && vod.by !== by)) {
253
+ ctx.json(response, 404, { error: "no such video of yours on the guide", code: "not_found" });
254
+ return true;
255
+ }
256
+ ctx.store.remove(vod.id);
257
+ ctx.json(response, 200, { removed: vod.id });
258
+ return true;
259
+ }
260
+ ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
261
+ return true;
262
+ }
@@ -183,6 +183,30 @@ export async function libraryTool(argv) {
183
183
  console.error("nixamp library playlist new NAME [ITEM...] | show ID | add ID ITEM... | delete ID");
184
184
  return 2;
185
185
  }
186
+ case "publish": {
187
+ // On demand on this server's guide (guide-vods.ts): the server builds the
188
+ // file's public address from its own public URL and listen key.
189
+ const [item] = rest;
190
+ const basis = flag(argv, "--basis");
191
+ if (!item || !basis) {
192
+ console.error("nixamp library publish needs an item id and --basis owned|permission|licensed|creative-commons|public-domain");
193
+ return 2;
194
+ }
195
+ 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"])}`);
196
+ }
197
+ case "vods":
198
+ return out(await call("GET", "/api/v1/guide/vods"), (body) => {
199
+ const vods = body["vods"] ?? [];
200
+ return vods.length === 0 ? "nothing published on demand" : vods.map((one) => `${String(one["id"])} ${String(one["name"])} ${String(one["page"])}${one["state"] === "offline" ? " [not answering]" : ""}`).join("\n");
201
+ });
202
+ case "unpublish": {
203
+ const [vod] = rest;
204
+ if (!vod) {
205
+ console.error("nixamp library unpublish needs a video id (see nixamp library vods)");
206
+ return 2;
207
+ }
208
+ return out(await call("DELETE", `/api/v1/guide/vods/${vod}`), (body) => `removed ${String(body["removed"])}`);
209
+ }
186
210
  default:
187
211
  console.log(LIBRARY_USAGE);
188
212
  return 2;
@@ -26,6 +26,8 @@ export interface LibraryContext {
26
26
  /** Tell the player about tags that changed. */
27
27
  retag: (tracks: Track[]) => void;
28
28
  sendFile: (request: IncomingMessage, response: ServerResponse, file: string, cache: string) => void;
29
+ /** The file as a browser plays it (server.ts playFile): the bytes when it can, MP4 or MP3 when not. */
30
+ play?: (request: IncomingMessage, response: ServerResponse, file: string) => Promise<void>;
29
31
  json: (response: ServerResponse, status: number, body: unknown) => void;
30
32
  readBody: (request: IncomingMessage) => Promise<string>;
31
33
  }
@@ -214,12 +214,12 @@ export async function handleLibrary(request, response, path, url, ctx) {
214
214
  ctx.json(response, 200, index.browse(kind, { q: text("q"), limit: number("limit"), offset: number("offset") }));
215
215
  return true;
216
216
  }
217
- const itemMatch = /^\/api\/v1\/library\/items\/([0-9a-f]{16})(?:\/(stream))?$/.exec(path);
217
+ const itemMatch = /^\/api\/v1\/library\/items\/([0-9a-f]{16})(?:\/(stream|play))?$/.exec(path);
218
218
  if (itemMatch) {
219
219
  const item = index.item(itemMatch[1]);
220
220
  if (!item)
221
221
  return fail(404, "not_found", "no such item");
222
- if (itemMatch[2] === "stream") {
222
+ if (itemMatch[2] === "stream" || itemMatch[2] === "play") {
223
223
  if (method !== "GET" && method !== "HEAD")
224
224
  return fail(405, "method_not_allowed", "GET");
225
225
  if (item.state !== "ready")
@@ -227,6 +227,11 @@ export async function handleLibrary(request, response, path, url, ctx) {
227
227
  const source = index.source(item.sourceId);
228
228
  if (!source?.enabled)
229
229
  return fail(409, "source_disabled", "that item's source is turned off");
230
+ // On demand: what a VOD link plays, from the start and seekable when the browser takes the file as it is.
231
+ if (itemMatch[2] === "play" && ctx.play) {
232
+ await ctx.play(request, response, item.path);
233
+ return true;
234
+ }
230
235
  // Private: a browser may keep it, a shared cache may not.
231
236
  ctx.sendFile(request, response, item.path, "private, max-age=3600");
232
237
  return true;
@@ -1,3 +1,3 @@
1
1
  /** `nixamp library` subcommands and usage, apart so help does not load the client. */
2
2
  export declare const LIBRARY_COMMANDS: Set<string>;
3
- export declare const LIBRARY_USAGE = "nixamp library \u2014 what this server has, indexed\n\n nixamp library PATH the folder the daemon serves (set once)\n nixamp library status items by state, sources, last scans\n nixamp library sources\n nixamp library add PATH index another folder (inside your home, not hidden)\n nixamp library scan [SOURCE] [--force] [--wait]\n nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]\n nixamp library show ITEM\n nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]\n nixamp library remove SOURCE forget a folder; its files are not touched\n nixamp library playlists\n nixamp library playlist new NAME [ITEM...]\n nixamp library playlist show|delete PLAYLIST\n nixamp library playlist add PLAYLIST ITEM...\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`";
3
+ export declare const LIBRARY_USAGE = "nixamp library \u2014 what this server has, indexed\n\n nixamp library PATH the folder the daemon serves (set once)\n nixamp library status items by state, sources, last scans\n nixamp library sources\n nixamp library add PATH index another folder (inside your home, not hidden)\n nixamp library scan [SOURCE] [--force] [--wait]\n nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]\n nixamp library show ITEM\n nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]\n nixamp library remove SOURCE forget a folder; its files are not touched\n nixamp library playlists\n nixamp library playlist new NAME [ITEM...]\n nixamp library playlist show|delete PLAYLIST\n nixamp library playlist add PLAYLIST ITEM...\n nixamp library publish ITEM --basis B [--title T] [--note N]\n on demand on live.streaming: a link that plays it from the start\n (B: owned, permission, licensed, creative-commons, public-domain)\n nixamp library vods what is published on demand, with each link\n nixamp library unpublish VOD take one off\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`";
@@ -1,5 +1,5 @@
1
1
  /** `nixamp library` subcommands and usage, apart so help does not load the client. */
2
- export const LIBRARY_COMMANDS = new Set(["status", "sources", "add", "scan", "search", "show", "fix", "remove", "playlists", "playlist"]);
2
+ export const LIBRARY_COMMANDS = new Set(["status", "sources", "add", "scan", "search", "show", "fix", "remove", "playlists", "playlist", "publish", "vods", "unpublish"]);
3
3
  export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
4
4
 
5
5
  nixamp library PATH the folder the daemon serves (set once)
@@ -15,6 +15,11 @@ export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
15
15
  nixamp library playlist new NAME [ITEM...]
16
16
  nixamp library playlist show|delete PLAYLIST
17
17
  nixamp library playlist add PLAYLIST ITEM...
18
+ nixamp library publish ITEM --basis B [--title T] [--note N]
19
+ on demand on live.streaming: a link that plays it from the start
20
+ (B: owned, permission, licensed, creative-commons, public-domain)
21
+ nixamp library vods what is published on demand, with each link
22
+ nixamp library unpublish VOD take one off
18
23
 
19
24
  --json machine-readable output
20
25
  --url, --key another server, as with \`nixamp admin\``;
package/dist/server.d.ts CHANGED
@@ -28,6 +28,7 @@ import { AiRadio } from "./ai-radio.ts";
28
28
  import { ListenerShares } from "./listener-shares.ts";
29
29
  import { Support, SupportSubscriptions } from "./support.ts";
30
30
  import { RoomProbe, RoomStore } from "./guide-rooms.ts";
31
+ import { VodProbe, VodStore } from "./guide-vods.ts";
31
32
  import { PeerGuides } from "./station-guide.ts";
32
33
  import { StationRunner } from "./station-runner.ts";
33
34
  import { StationStore, type Station } from "./stations.ts";
@@ -622,6 +623,11 @@ export interface HandlerOptions {
622
623
  store: RoomStore;
623
624
  probe: RoomProbe;
624
625
  };
626
+ /** Files members published to this guide to play on demand, and how to ask a file's server about it. */
627
+ vods?: {
628
+ store: VodStore;
629
+ probe: VodProbe;
630
+ };
625
631
  };
626
632
  web: string | null;
627
633
  /** Branded clients on this same backend, keyed by an explicitly configured host. */