nixamp 0.47.1 → 0.48.1
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.
- package/dist/guide-vods.d.ts +89 -0
- package/dist/guide-vods.js +262 -0
- package/dist/library-cli.js +24 -0
- package/dist/library-routes.d.ts +2 -0
- package/dist/library-routes.js +7 -2
- package/dist/library-usage.d.ts +1 -1
- package/dist/library-usage.js +6 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +127 -38
- package/dist/station-guide.d.ts +2 -2
- package/dist/station-guide.js +9 -6
- package/dist/station-metering.js +2 -2
- package/package.json +1 -1
- package/src/guide-vods.ts +302 -0
- package/src/library-cli.ts +19 -0
- package/src/library-routes.ts +9 -2
- package/src/library-usage.ts +6 -1
- package/src/server.ts +140 -43
- package/src/station-guide.ts +11 -8
- package/src/station-metering.ts +2 -2
- package/web/dist/assets/{ar-BRH59tp-.js → ar-BvPTr_2x.js} +1 -1
- package/web/dist/assets/chunk-Q2XQMA4B-CDsJ8fBU.js +1 -0
- package/web/dist/assets/{cs-CLBz8_mD.js → cs-g4ii7Tkl.js} +1 -1
- package/web/dist/assets/{da-DVl87YMx.js → da-D9vYCFH7.js} +1 -1
- package/web/dist/assets/{de-CkkF68l3.js → de-lYElbebQ.js} +1 -1
- package/web/dist/assets/{es-B_q-D-Sm.js → es-CiDbfxFy.js} +1 -1
- package/web/dist/assets/{fi-Da05__2a.js → fi-BWlqMMWx.js} +1 -1
- package/web/dist/assets/{fr-swwKqDQU.js → fr-D2n5nXQv.js} +1 -1
- package/web/dist/assets/{hi-CPzREAUI.js → hi-DM9Nh04K.js} +1 -1
- package/web/dist/assets/{hls-OR5FY6YP-jwHO4SPh.js → hls-OR5FY6YP-bdUZBB11.js} +1 -1
- package/web/dist/assets/{hu-YLyCLxzo.js → hu-Bzb0X4rk.js} +1 -1
- package/web/dist/assets/{id-B0LMxfwA.js → id-j0QazySW.js} +1 -1
- package/web/dist/assets/{it-CeqYcwZW.js → it-BVvva3a6.js} +1 -1
- package/web/dist/assets/main-DPfBhzsz.js +22 -0
- package/web/dist/assets/{mpegts-7CN75AI2-BtVIb2A1.js → mpegts-7CN75AI2-iUctLWip.js} +1 -1
- package/web/dist/assets/{mpegts-arZIPCfq.js → mpegts-DeEBZUXP.js} +1 -1
- package/web/dist/assets/{nl-BDFCAT58.js → nl-CrJQ6obW.js} +1 -1
- package/web/dist/assets/{radio-Bmt6N6Tf.js → radio-DI0H8flN.js} +1 -1
- package/web/dist/assets/{ru-CVyq7zo9.js → ru-DFIjjKvG.js} +1 -1
- package/web/dist/assets/{station-BwDP2cuf.css → station-BKmoJ0Sk.css} +1 -1
- package/web/dist/assets/{station-BhCxDaWb.js → station-X8pffqCW.js} +1 -1
- package/web/dist/assets/{sv-DBpGBxH3.js → sv-BO9QPL4K.js} +1 -1
- package/web/dist/assets/{uk-DBTg28H-.js → uk-DwmG3Owv.js} +1 -1
- package/web/dist/assets/{vi-u-33JvNh.js → vi-C-2EzgMe.js} +1 -1
- package/web/dist/assets/vod-CdfPMa1u.js +1 -0
- package/web/dist/assets/{watch-BxAnF-yA.js → watch-hdfnhm3C.js} +1 -1
- package/web/dist/assets/{zh-BeiDong6.js → zh-BlnCenyZ.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/radio.html +2 -2
- package/web/dist/station.html +3 -3
- package/web/dist/sw.js +29 -28
- package/web/dist/vod.html +39 -0
- package/web/dist/watch.html +3 -3
- package/web/dist/assets/chunk-Q2XQMA4B-xxJBbNwd.js +0 -1
- 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
|
+
}
|
package/dist/library-cli.js
CHANGED
|
@@ -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;
|
package/dist/library-routes.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/library-routes.js
CHANGED
|
@@ -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;
|
package/dist/library-usage.d.ts
CHANGED
|
@@ -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`";
|
package/dist/library-usage.js
CHANGED
|
@@ -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. */
|