nixamp 0.47.0 → 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.
- package/dist/broadcast.js +3 -1
- 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/playlist-input.js +4 -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 +6 -4
- package/package.json +1 -1
- package/src/broadcast.ts +3 -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/playlist-input.ts +4 -1
- package/src/server.ts +140 -43
- package/src/station-guide.ts +8 -6
- package/web/dist/assets/{ar-BOy98BLI.js → ar-ropwGSSV.js} +1 -1
- package/web/dist/assets/chunk-Q2XQMA4B-CDsJ8fBU.js +1 -0
- package/web/dist/assets/{cs-BbbskosY.js → cs-Bi7uRYjd.js} +1 -1
- package/web/dist/assets/{da-DHrF2OuF.js → da-B8OELPch.js} +1 -1
- package/web/dist/assets/{de-Dx2W0spi.js → de-DJOTeXET.js} +1 -1
- package/web/dist/assets/{es-qnp4IRsp.js → es-BIupzuzS.js} +1 -1
- package/web/dist/assets/{fi-CfPSgEs-.js → fi-A6MoWkzZ.js} +1 -1
- package/web/dist/assets/{fr-LKBu7Tgy.js → fr-DerIDIwl.js} +1 -1
- package/web/dist/assets/{hi-BzyRQPpa.js → hi-CyUtRBH4.js} +1 -1
- package/web/dist/assets/{hls-OR5FY6YP-jwHO4SPh.js → hls-OR5FY6YP-bdUZBB11.js} +1 -1
- package/web/dist/assets/{hu-AmxPDNs7.js → hu-DD1vrIhY.js} +1 -1
- package/web/dist/assets/{id-CxY-xwNQ.js → id-C6OAMzii.js} +1 -1
- package/web/dist/assets/{it-eoKuPzEe.js → it-Xy86vmU7.js} +1 -1
- package/web/dist/assets/main-CuG8-ADK.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-Ceg4F0rC.js → nl-4Q-K2Jf1.js} +1 -1
- package/web/dist/assets/{radio-Bmt6N6Tf.js → radio-DI0H8flN.js} +1 -1
- package/web/dist/assets/{ru-BRNZUbKs.js → ru-CpxwOhC4.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-k-A6hu8-.js → sv-aRvu7MWa.js} +1 -1
- package/web/dist/assets/{uk-C9owZ3Lc.js → uk-BCwwfngg.js} +1 -1
- package/web/dist/assets/{vi-CtcFEtmC.js → vi-tvWPiAH_.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-DQrNE8sv.js → zh-Bp2aLEyX.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-DYATKV1O.js +0 -22
package/dist/server.js
CHANGED
|
@@ -70,6 +70,7 @@ import { LyricsPublisher } from "./lyrics-publish.js";
|
|
|
70
70
|
import { aiKey } from "./ai-keys.js";
|
|
71
71
|
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.js";
|
|
72
72
|
import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.js";
|
|
73
|
+
import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.js";
|
|
73
74
|
import { withPitFooter } from "./pit-footer.js";
|
|
74
75
|
import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
|
|
75
76
|
import { StationRunner } from "./station-runner.js";
|
|
@@ -2186,6 +2187,8 @@ export function createHandler(engine, options) {
|
|
|
2186
2187
|
!(path === "/watch" && Boolean(options.stations?.rooms)) &&
|
|
2187
2188
|
// Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
|
|
2188
2189
|
!((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
|
|
2190
|
+
// On demand: a file's page and the guide's list of them (guide-vods.ts); the file's own key travels in its play address.
|
|
2191
|
+
!((path === "/vod" || /^\/vod\/[A-Za-z0-9_-]{1,32}$/.test(path) || path === "/api/v1/guide/vods" || path.startsWith("/api/v1/guide/vods/")) && Boolean(options.stations?.vods)) &&
|
|
2189
2192
|
!STATION_PAGE.test(path) &&
|
|
2190
2193
|
!isPublicAsset &&
|
|
2191
2194
|
!isGuidePage &&
|
|
@@ -2397,6 +2400,61 @@ export function createHandler(engine, options) {
|
|
|
2397
2400
|
response.end(request.method === "HEAD" ? undefined : built);
|
|
2398
2401
|
return;
|
|
2399
2402
|
}
|
|
2403
|
+
// A file on demand: /vod (all of them) and /vod/<id> (one), the page a VOD link opens.
|
|
2404
|
+
const vodPath = /^\/vod(?:\/([A-Za-z0-9_-]{1,32}))?$/.exec(path);
|
|
2405
|
+
if (options.stations?.vods && vodPath && (request.method === "GET" || request.method === "HEAD")) {
|
|
2406
|
+
const built = web !== null && isFile(join(web, "vod.html")) ? readFileSync(join(web, "vod.html"), "utf8") : null;
|
|
2407
|
+
if (!built) {
|
|
2408
|
+
json(response, 404, { error: "this server was started without its web pages" });
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
const vods = options.stations.vods;
|
|
2412
|
+
const one = vodPath[1] ? vods.store.get(vodPath[1]) : undefined;
|
|
2413
|
+
const base = watchBase(options.stations.publicBase?.() || `https://${request.headers.host ?? "localhost"}`);
|
|
2414
|
+
const view = one ? vodView(one, base, null) : null;
|
|
2415
|
+
// A link preview reads the page, not its script: the file's name goes in the head.
|
|
2416
|
+
const escape = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2417
|
+
const head = view
|
|
2418
|
+
? `<title>${escape(String(view.name))} · on demand</title>\n <meta property="og:title" content="${escape(String(view.name))}" />\n <meta property="og:type" content="${view.kind === "video" ? "video.other" : "music.song"}" />\n <meta property="og:url" content="${escape(String(view.page))}" />\n <meta property="og:description" content="On demand: play it from the start, seek anywhere." />`
|
|
2419
|
+
: "<title>On demand · nixamp</title>";
|
|
2420
|
+
response.writeHead(vodPath[1] && !one ? 404 : 200, {
|
|
2421
|
+
"content-type": "text/html; charset=utf-8",
|
|
2422
|
+
"cache-control": "no-cache",
|
|
2423
|
+
"content-security-policy": WATCH_APP_CSP,
|
|
2424
|
+
"referrer-policy": "no-referrer",
|
|
2425
|
+
"x-content-type-options": "nosniff",
|
|
2426
|
+
});
|
|
2427
|
+
response.end(request.method === "HEAD" ? undefined : built.replace(/<title>[^<]*<\/title>/, head));
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
if (options.stations?.vods && (path === "/api/v1/guide/vods" || path.startsWith("/api/v1/guide/vods/"))) {
|
|
2431
|
+
const control = await controlOf();
|
|
2432
|
+
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
2433
|
+
const vods = options.stations.vods;
|
|
2434
|
+
const publicBase = (options.stations.publicBase?.() ?? "").replace(/\/+$/, "");
|
|
2435
|
+
await handleVods(request, response, path, {
|
|
2436
|
+
store: vods.store,
|
|
2437
|
+
probe: vods.probe,
|
|
2438
|
+
base: watchBase(publicBase || `https://${request.headers.host ?? "localhost"}`),
|
|
2439
|
+
canControl: control.allowed,
|
|
2440
|
+
member: async () => (session && options.owner ? (await options.owner.accountFor(session)) || "" : ""),
|
|
2441
|
+
ownPlay: (item) => (publicBase && listenKey && options.library?.index.item(item) ? `${publicBase}/api/v1/library/items/${item}/play?k=${listenKey}` : ""),
|
|
2442
|
+
ownState: (id) => {
|
|
2443
|
+
const item = options.library?.index.item(id);
|
|
2444
|
+
if (!item || item.state !== "ready")
|
|
2445
|
+
return null;
|
|
2446
|
+
const title = item.override.title || item.title || item.rel.split("/").pop()?.replace(/\.[^.]+$/, "") || id;
|
|
2447
|
+
return { name: title.slice(0, 160), kind: item.video ? "video" : "audio", duration: item.duration, artist: (item.override.artist || item.artist).slice(0, 160) };
|
|
2448
|
+
},
|
|
2449
|
+
json,
|
|
2450
|
+
readBody,
|
|
2451
|
+
throttle: () => {
|
|
2452
|
+
const verdict = guard.check(`vods:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2453
|
+
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
2454
|
+
},
|
|
2455
|
+
});
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2400
2458
|
if (options.stations?.rooms && (path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/"))) {
|
|
2401
2459
|
const control = await controlOf();
|
|
2402
2460
|
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
@@ -2448,6 +2506,7 @@ export function createHandler(engine, options) {
|
|
|
2448
2506
|
probe: options.library.probe,
|
|
2449
2507
|
retag: options.library.retag,
|
|
2450
2508
|
sendFile,
|
|
2509
|
+
play: (req, res, file) => playFile(req, res, file, url, options),
|
|
2451
2510
|
json,
|
|
2452
2511
|
readBody,
|
|
2453
2512
|
});
|
|
@@ -2533,8 +2592,16 @@ export function createHandler(engine, options) {
|
|
|
2533
2592
|
...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
|
|
2534
2593
|
passes: listenPasses,
|
|
2535
2594
|
...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
|
|
2536
|
-
...(options.stations.rooms ? {
|
|
2537
|
-
rooms: () =>
|
|
2595
|
+
...(options.stations.rooms || options.stations.vods ? {
|
|
2596
|
+
rooms: async () => {
|
|
2597
|
+
const base = watchBase(options.stations.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`);
|
|
2598
|
+
const { rooms, vods } = options.stations;
|
|
2599
|
+
const [live, onDemand] = await Promise.all([
|
|
2600
|
+
rooms ? roomEntries(rooms.store, rooms.probe, base) : [],
|
|
2601
|
+
vods ? vodEntries(vods.store, vods.probe, base) : [],
|
|
2602
|
+
]);
|
|
2603
|
+
return [...live, ...onDemand];
|
|
2604
|
+
},
|
|
2538
2605
|
} : {}),
|
|
2539
2606
|
throttle: () => {
|
|
2540
2607
|
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
@@ -5898,6 +5965,18 @@ export function createHandler(engine, options) {
|
|
|
5898
5965
|
handleStaticRelay(request, response, compression, file, engine.snapshot().tracks?.[index]?.title ?? file);
|
|
5899
5966
|
return;
|
|
5900
5967
|
}
|
|
5968
|
+
// Which library item a playlist track is, for publishing it on demand (guide-vods.ts).
|
|
5969
|
+
const mediaItem = /^\/api\/media\/(\d+)\/item$/.exec(path);
|
|
5970
|
+
if (mediaItem) {
|
|
5971
|
+
const file = engine.trackPath(Number(mediaItem[1]));
|
|
5972
|
+
const item = file === undefined ? undefined : options.library?.index.itemAt(file);
|
|
5973
|
+
if (!item) {
|
|
5974
|
+
json(response, 404, { error: "that track is not in this server's library", code: "not_in_library" });
|
|
5975
|
+
return;
|
|
5976
|
+
}
|
|
5977
|
+
json(response, 200, { id: item.id, video: item.video, title: item.override.title || item.title, duration: item.duration });
|
|
5978
|
+
return;
|
|
5979
|
+
}
|
|
5901
5980
|
if (path.startsWith("/api/media/")) {
|
|
5902
5981
|
if (!options.media) {
|
|
5903
5982
|
json(response, 403, { error: "media streaming is off" });
|
|
@@ -5910,42 +5989,7 @@ export function createHandler(engine, options) {
|
|
|
5910
5989
|
return;
|
|
5911
5990
|
}
|
|
5912
5991
|
watch(request, response, "media", engine.snapshot().tracks?.[index]?.title ?? file);
|
|
5913
|
-
|
|
5914
|
-
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
5915
|
-
// and transcoding gives it up, but an unseekable film beats a silent
|
|
5916
|
-
// one -- and the seekable formats are untouched.
|
|
5917
|
-
// A ceiling the caller asked for, because only the caller knows what its
|
|
5918
|
-
// link can carry. Capped at both ends: nothing below 200k is watchable,
|
|
5919
|
-
// and above 20 megabits the original was always the better answer.
|
|
5920
|
-
const asked = Number(url.searchParams.get("kbps") ?? "");
|
|
5921
|
-
const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
|
|
5922
|
-
// Whether this browser decodes H.265, which only it can know: Safari and
|
|
5923
|
-
// televisions do, Chrome on a desktop does not and says nothing when
|
|
5924
|
-
// handed it. Copying a 4K HEVC film is free; re-encoding one does not
|
|
5925
|
-
// keep up with playing it, so the answer is worth carrying in the URL.
|
|
5926
|
-
const allowHevc = url.searchParams.get("hevc") === "1";
|
|
5927
|
-
if (playsInBrowser(file) && capKbps === 0) {
|
|
5928
|
-
sendFile(request, response, file);
|
|
5929
|
-
return;
|
|
5930
|
-
}
|
|
5931
|
-
// A film, or something whose name refuses to say. A live channel at
|
|
5932
|
-
// .../301 used to fall through to the audio branch and arrive as MP3
|
|
5933
|
-
// with `-vn` -- a match you could only listen to.
|
|
5934
|
-
if (hasPicture(file) || nameSaysNothing(file)) {
|
|
5935
|
-
// What ffprobe finds inside decides how little work it takes to keep
|
|
5936
|
-
// the picture, and whether there is a picture to keep at all.
|
|
5937
|
-
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
5938
|
-
if (codecs.video !== "") {
|
|
5939
|
-
// Told back to the playlist, so an entry that was added before this
|
|
5940
|
-
// could be asked stops claiming to be a song. Without it a track
|
|
5941
|
-
// added by an older nixamp goes to an audio element for ever, and
|
|
5942
|
-
// the only cure is noticing and adding it again.
|
|
5943
|
-
engine.sawPicture(index);
|
|
5944
|
-
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps, { allowHevc }), "video/mp4", transportInputArgs(file, codecs.container));
|
|
5945
|
-
return;
|
|
5946
|
-
}
|
|
5947
|
-
}
|
|
5948
|
-
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
5992
|
+
await playFile(request, response, file, url, options, () => engine.sawPicture(index));
|
|
5949
5993
|
return;
|
|
5950
5994
|
}
|
|
5951
5995
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
|
@@ -6273,6 +6317,49 @@ async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
|
|
|
6273
6317
|
* for everything else.
|
|
6274
6318
|
*/
|
|
6275
6319
|
/** Audio, from whatever this is: the shape every non-browser source took. */
|
|
6320
|
+
/**
|
|
6321
|
+
* A file as a browser plays it: the bytes themselves, seekable, when the
|
|
6322
|
+
* browser can; otherwise an MP4 or MP3 made on the way. `onPicture` hears
|
|
6323
|
+
* that ffprobe found a picture in it.
|
|
6324
|
+
*/
|
|
6325
|
+
async function playFile(request, response, file, url, options, onPicture = () => undefined) {
|
|
6326
|
+
// A browser asks for every track here, and a matroska or an avi handed
|
|
6327
|
+
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
6328
|
+
// and transcoding gives it up, but an unseekable film beats a silent
|
|
6329
|
+
// one -- and the seekable formats are untouched.
|
|
6330
|
+
// A ceiling the caller asked for, because only the caller knows what its
|
|
6331
|
+
// link can carry. Capped at both ends: nothing below 200k is watchable,
|
|
6332
|
+
// and above 20 megabits the original was always the better answer.
|
|
6333
|
+
const asked = Number(url.searchParams.get("kbps") ?? "");
|
|
6334
|
+
const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
|
|
6335
|
+
// Whether this browser decodes H.265, which only it can know: Safari and
|
|
6336
|
+
// televisions do, Chrome on a desktop does not and says nothing when
|
|
6337
|
+
// handed it. Copying a 4K HEVC film is free; re-encoding one does not
|
|
6338
|
+
// keep up with playing it, so the answer is worth carrying in the URL.
|
|
6339
|
+
const allowHevc = url.searchParams.get("hevc") === "1";
|
|
6340
|
+
if (playsInBrowser(file) && capKbps === 0) {
|
|
6341
|
+
sendFile(request, response, file);
|
|
6342
|
+
return;
|
|
6343
|
+
}
|
|
6344
|
+
// A film, or something whose name refuses to say. A live channel at
|
|
6345
|
+
// .../301 used to fall through to the audio branch and arrive as MP3
|
|
6346
|
+
// with `-vn` -- a match you could only listen to.
|
|
6347
|
+
if (hasPicture(file) || nameSaysNothing(file)) {
|
|
6348
|
+
// What ffprobe finds inside decides how little work it takes to keep
|
|
6349
|
+
// the picture, and whether there is a picture to keep at all.
|
|
6350
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
6351
|
+
if (codecs.video !== "") {
|
|
6352
|
+
// Told back to the playlist, so an entry that was added before this
|
|
6353
|
+
// could be asked stops claiming to be a song. Without it a track
|
|
6354
|
+
// added by an older nixamp goes to an audio element for ever, and
|
|
6355
|
+
// the only cure is noticing and adding it again.
|
|
6356
|
+
onPicture();
|
|
6357
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps, { allowHevc }), "video/mp4", transportInputArgs(file, codecs.container));
|
|
6358
|
+
return;
|
|
6359
|
+
}
|
|
6360
|
+
}
|
|
6361
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
6362
|
+
}
|
|
6276
6363
|
function transcode(request, response, source, ffmpeg) {
|
|
6277
6364
|
pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
|
|
6278
6365
|
}
|
|
@@ -6661,6 +6748,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6661
6748
|
}),
|
|
6662
6749
|
// Live rooms published to this guide; the probe never reaches this machine's own network.
|
|
6663
6750
|
rooms: { store: new RoomStore(RoomStore.fileFor(stateDir(), options.port)), probe: new RoomProbe(fetch, (link) => refusedDestination(link)) },
|
|
6751
|
+
// Files published to play on demand; the same guard on whose network the probe reaches.
|
|
6752
|
+
vods: { store: new VodStore(VodStore.fileFor(stateDir(), options.port)), probe: new VodProbe(fetch, (link) => refusedDestination(link)) },
|
|
6664
6753
|
};
|
|
6665
6754
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6666
6755
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
package/dist/station-guide.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export interface GuideEntry {
|
|
|
11
11
|
id?: string;
|
|
12
12
|
name: string;
|
|
13
13
|
kind: "audio" | "video";
|
|
14
|
-
/** rooms (live rooms, src/guide-rooms.ts), ai, shared, radio or tv; from kind when absent. */
|
|
15
|
-
group?: "rooms" | "ai" | "shared" | "radio" | "tv";
|
|
14
|
+
/** rooms (live rooms, src/guide-rooms.ts), vod (on demand, src/guide-vods.ts), ai, shared, radio or tv; from kind when absent. */
|
|
15
|
+
group?: "rooms" | "vod" | "ai" | "shared" | "radio" | "tv";
|
|
16
16
|
state: string;
|
|
17
17
|
page: string;
|
|
18
18
|
nowPlaying: {
|
package/dist/station-guide.js
CHANGED
|
@@ -65,7 +65,7 @@ const SCRIPT = `
|
|
|
65
65
|
} catch { button.textContent = "Could not report; try again"; button.disabled = false; }
|
|
66
66
|
};
|
|
67
67
|
const draw = (stations) => {
|
|
68
|
-
for (const group of ["rooms", "ai", "shared", "radio", "tv"]) {
|
|
68
|
+
for (const group of ["rooms", "vod", "ai", "shared", "radio", "tv"]) {
|
|
69
69
|
const grid = document.querySelector("#" + group);
|
|
70
70
|
grid.replaceChildren();
|
|
71
71
|
const list = stations.filter((s) => (s.group || (s.kind === "video" ? "tv" : "radio")) === group);
|
|
@@ -74,7 +74,7 @@ const SCRIPT = `
|
|
|
74
74
|
const card = el("div", "card");
|
|
75
75
|
const a = el("a", "station");
|
|
76
76
|
a.href = s.page;
|
|
77
|
-
a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
|
|
77
|
+
a.append(el("div", "live", s.state === "live" ? "● LIVE" : s.state === "vod" ? "▶ ON DEMAND" : "off the air"), el("div", "name", s.name),
|
|
78
78
|
el("div", "now", nowLine(s.nowPlaying)));
|
|
79
79
|
card.append(a);
|
|
80
80
|
if (group === "shared") {
|
|
@@ -144,7 +144,7 @@ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base
|
|
|
144
144
|
function escape(text) {
|
|
145
145
|
return text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
146
146
|
}
|
|
147
|
-
const GROUPS = ["rooms", "ai", "shared", "radio", "tv"];
|
|
147
|
+
const GROUPS = ["rooms", "vod", "ai", "shared", "radio", "tv"];
|
|
148
148
|
/**
|
|
149
149
|
* The guide with its stations already in it, so it reads the same through a
|
|
150
150
|
* gateway that cannot run its script against this server (the Moshpit's
|
|
@@ -154,7 +154,7 @@ export function renderGuide(entries, apiBase = "", ads = {}) {
|
|
|
154
154
|
const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
|
|
155
155
|
const cards = (group) => entries
|
|
156
156
|
.filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
|
|
157
|
-
.map((one) => `<div class="card"><a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${escape(nowLine(one.nowPlaying))}</div></a></div>`)
|
|
157
|
+
.map((one) => `<div class="card"><a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : one.state === "vod" ? "▶ ON DEMAND" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${escape(nowLine(one.nowPlaying))}</div></a></div>`)
|
|
158
158
|
.join("");
|
|
159
159
|
let page = GUIDE_HTML;
|
|
160
160
|
for (const group of GROUPS) {
|
|
@@ -190,6 +190,8 @@ export const GUIDE_HTML = `<!doctype html>
|
|
|
190
190
|
<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
|
|
191
191
|
<h2 id="rooms-title" hidden>Live rooms</h2>
|
|
192
192
|
<div id="rooms" class="grid" aria-live="polite"></div>
|
|
193
|
+
<h2 id="vod-title" hidden>On demand</h2>
|
|
194
|
+
<div id="vod" class="grid" aria-live="polite"></div>
|
|
193
195
|
<h2 id="ai-title" hidden>AI Radio</h2>
|
|
194
196
|
<div id="ai" class="grid" aria-live="polite"></div>
|
|
195
197
|
<h2 id="shared-title" hidden>Shared by listeners</h2>
|
package/package.json
CHANGED
package/src/broadcast.ts
CHANGED
|
@@ -129,7 +129,9 @@ export function buildBroadcastArgs(plan: BroadcastPlan): string[] {
|
|
|
129
129
|
"-bufsize", `${plan.settings.videoBitrate * 2}k`,
|
|
130
130
|
// A strict constant frame rate. A source that only produces frames when
|
|
131
131
|
// something changes reads to YouTube as a stream that is falling behind.
|
|
132
|
-
|
|
132
|
+
// Letterboxed, never stretched, and square pixels first so an anamorphic
|
|
133
|
+
// source keeps its shape.
|
|
134
|
+
"-vf", `scale='trunc(iw*sar/2)*2':ih,setsar=1,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${plan.settings.framerate}`,
|
|
133
135
|
"-pix_fmt", "yuv420p",
|
|
134
136
|
"-g", String(gop),
|
|
135
137
|
"-c:a", "aac",
|
|
@@ -0,0 +1,302 @@
|
|
|
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 type { IncomingMessage, ServerResponse } from "node:http";
|
|
19
|
+
import { ROOM_BASES, type RoomBasis } from "./guide-rooms.ts";
|
|
20
|
+
import type { GuideEntry } from "./station-guide.ts";
|
|
21
|
+
|
|
22
|
+
export interface GuideVod {
|
|
23
|
+
id: string;
|
|
24
|
+
/** The file's play address, with its listen key. */
|
|
25
|
+
play: string;
|
|
26
|
+
name: string;
|
|
27
|
+
kind: "audio" | "video";
|
|
28
|
+
/** Seconds; 0 when its server did not know. */
|
|
29
|
+
duration: number;
|
|
30
|
+
basis: RoomBasis;
|
|
31
|
+
note: string;
|
|
32
|
+
/** The nixamp.com account that published it ("" for this server's owner). */
|
|
33
|
+
by: string;
|
|
34
|
+
publishedAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Files one member may have on the guide at once, and files on it in all. */
|
|
38
|
+
export const VODS_PER_MEMBER = 25;
|
|
39
|
+
export const VODS_MAX = 1000;
|
|
40
|
+
const STATE_TTL_MS = 60_000;
|
|
41
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A file's play address taken apart, or null when it is not one: https (http
|
|
45
|
+
* only for a loopback address, for tests), a library item, a key, nothing else.
|
|
46
|
+
*/
|
|
47
|
+
export function vodAddress(input: string): { origin: string; item: string; key: string; play: string } | null {
|
|
48
|
+
let url: URL;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(input.trim());
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
|
|
55
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null;
|
|
56
|
+
if (url.username || url.password || url.hash) return null;
|
|
57
|
+
const item = /^\/api\/v1\/library\/items\/([0-9a-f]{16})\/(?:play|stream)\/?$/.exec(url.pathname);
|
|
58
|
+
if (!item) return null;
|
|
59
|
+
const key = url.searchParams.get("k") ?? "";
|
|
60
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/.test(key) || [...url.searchParams.keys()].some((name) => name !== "k")) return null;
|
|
61
|
+
return { origin: url.origin, item: item[1]!, key, play: `${url.origin}/api/v1/library/items/${item[1]}/play?k=${key}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const vodPage = (base: string, id: string): string => `${base}/vod/${id}`;
|
|
65
|
+
|
|
66
|
+
export class VodStore {
|
|
67
|
+
private vods: GuideVod[] = [];
|
|
68
|
+
constructor(private readonly file: string) {
|
|
69
|
+
try {
|
|
70
|
+
if (existsSync(file)) {
|
|
71
|
+
const saved = JSON.parse(readFileSync(file, "utf8")) as { vods?: GuideVod[] };
|
|
72
|
+
this.vods = (saved.vods ?? []).filter((one) => vodAddress(one.play) !== null);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Unreadable is empty; the next write replaces it.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static fileFor(stateDir: string, port: number): string {
|
|
80
|
+
return join(stateDir, `guide-vods-${port}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Newest first, the order the guide shows them. */
|
|
84
|
+
list(): GuideVod[] {
|
|
85
|
+
return [...this.vods].sort((a, b) => b.publishedAt - a.publishedAt);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get(id: string): GuideVod | undefined {
|
|
89
|
+
return this.vods.find((one) => one.id === id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Publishes a file, or refreshes the one already published at that address (its link stays). */
|
|
93
|
+
put(vod: Omit<GuideVod, "id" | "publishedAt">): GuideVod {
|
|
94
|
+
const existing = this.vods.find((one) => one.play === vod.play);
|
|
95
|
+
const saved: GuideVod = { ...vod, id: existing?.id ?? randomBytes(6).toString("base64url"), publishedAt: existing?.publishedAt ?? Date.now() };
|
|
96
|
+
this.vods = [...this.vods.filter((one) => one.play !== vod.play), saved];
|
|
97
|
+
this.save();
|
|
98
|
+
return saved;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
remove(id: string): boolean {
|
|
102
|
+
const before = this.vods.length;
|
|
103
|
+
this.vods = this.vods.filter((one) => one.id !== id);
|
|
104
|
+
if (this.vods.length === before) return false;
|
|
105
|
+
this.save();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private save(): void {
|
|
110
|
+
writeFileSync(`${this.file}.part`, `${JSON.stringify({ version: 1, vods: this.vods }, null, 2)}\n`);
|
|
111
|
+
renameSync(`${this.file}.part`, this.file);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface VodState {
|
|
116
|
+
name: string;
|
|
117
|
+
kind: "audio" | "video";
|
|
118
|
+
duration: number;
|
|
119
|
+
artist: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Asks a file's own server what it is: its library item, read with the same key. */
|
|
123
|
+
export class VodProbe {
|
|
124
|
+
private cache = new Map<string, { at: number; state: VodState | null }>();
|
|
125
|
+
/** `refuse` names why an address may not be fetched from this machine, or "" (see RoomProbe). */
|
|
126
|
+
constructor(
|
|
127
|
+
private readonly fetcher: typeof fetch = fetch,
|
|
128
|
+
private readonly refuse: (link: string) => Promise<string> = async () => "",
|
|
129
|
+
) {}
|
|
130
|
+
|
|
131
|
+
async state(play: string, fresh = false): Promise<VodState | null> {
|
|
132
|
+
const cached = this.cache.get(play);
|
|
133
|
+
if (!fresh && cached && Date.now() - cached.at < STATE_TTL_MS) return cached.state;
|
|
134
|
+
const state = await this.ask(play).catch(() => null);
|
|
135
|
+
this.cache.set(play, { at: Date.now(), state });
|
|
136
|
+
if (this.cache.size > 2000) this.cache.delete(this.cache.keys().next().value!);
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async ask(play: string): Promise<VodState | null> {
|
|
141
|
+
const address = vodAddress(play);
|
|
142
|
+
if (!address || (await this.refuse(address.play)) !== "") return null;
|
|
143
|
+
const answer = await this.fetcher(`${address.origin}/api/v1/library/items/${address.item}?k=${address.key}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), redirect: "error" });
|
|
144
|
+
if (!answer.ok) return null;
|
|
145
|
+
const item = ((await answer.json()) as { item?: Record<string, unknown> }).item;
|
|
146
|
+
if (!item || item["state"] !== "ready") return null;
|
|
147
|
+
const rel = String(item["rel"] ?? "");
|
|
148
|
+
const fallback = rel.split("/").pop()?.replace(/\.[^.]+$/, "") ?? address.item;
|
|
149
|
+
return {
|
|
150
|
+
name: String(item["title"] || fallback).slice(0, 160),
|
|
151
|
+
kind: item["video"] === true ? "video" : "audio",
|
|
152
|
+
duration: Number(item["duration"]) || 0,
|
|
153
|
+
artist: String(item["artist"] ?? "").slice(0, 160),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** "1:02:03" or "4:05". */
|
|
159
|
+
export function clock(seconds: number): string {
|
|
160
|
+
const s = Math.max(0, Math.round(seconds));
|
|
161
|
+
const h = Math.floor(s / 3600);
|
|
162
|
+
const m = Math.floor((s % 3600) / 60);
|
|
163
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
164
|
+
return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The published files as guide cards, newest first; one whose server no longer has it says so. */
|
|
168
|
+
export async function vodEntries(store: VodStore, probe: VodProbe, base: string): Promise<GuideEntry[]> {
|
|
169
|
+
return Promise.all(store.list().map(async (vod) => {
|
|
170
|
+
const state = await probe.state(vod.play);
|
|
171
|
+
return {
|
|
172
|
+
id: vod.id,
|
|
173
|
+
name: vod.name,
|
|
174
|
+
kind: state?.kind ?? vod.kind,
|
|
175
|
+
group: "vod" as const,
|
|
176
|
+
state: state ? "vod" : "offline",
|
|
177
|
+
page: vodPage(base, vod.id),
|
|
178
|
+
nowPlaying: { title: clock(state?.duration || vod.duration), artist: state?.artist ?? "" },
|
|
179
|
+
};
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One file as its page reads it: never who published it, always where it plays. */
|
|
184
|
+
export function vodView(vod: GuideVod, base: string, state: VodState | null): Record<string, unknown> {
|
|
185
|
+
return {
|
|
186
|
+
id: vod.id,
|
|
187
|
+
name: vod.name,
|
|
188
|
+
kind: state?.kind ?? vod.kind,
|
|
189
|
+
duration: state?.duration || vod.duration,
|
|
190
|
+
artist: state?.artist ?? "",
|
|
191
|
+
note: vod.note,
|
|
192
|
+
basis: vod.basis,
|
|
193
|
+
publishedAt: vod.publishedAt,
|
|
194
|
+
available: state !== null,
|
|
195
|
+
play: vod.play,
|
|
196
|
+
page: vodPage(base, vod.id),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface VodContext {
|
|
201
|
+
store: VodStore;
|
|
202
|
+
probe: VodProbe;
|
|
203
|
+
/** The page base for a file's link. */
|
|
204
|
+
base: string;
|
|
205
|
+
/** Whether the request holds this server's control key (or is its signed-in owner). */
|
|
206
|
+
canControl: boolean;
|
|
207
|
+
/** The nixamp.com account behind the request's session, or "". */
|
|
208
|
+
member: () => Promise<string>;
|
|
209
|
+
/** This server's own play address for one of its library items, for the owner publishing by id; "" when it has none. */
|
|
210
|
+
ownPlay?: (item: string) => string;
|
|
211
|
+
/** What this server's own library says of one of its items, so publishing it never asks itself over the network. */
|
|
212
|
+
ownState?: (item: string) => VodState | null;
|
|
213
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
214
|
+
/** The request body as text (server.ts readBody). */
|
|
215
|
+
readBody: (request: IncomingMessage) => Promise<unknown>;
|
|
216
|
+
/** Refused by the caller's rate limit, or null. */
|
|
217
|
+
throttle?: () => { retryAfter: number } | null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Answers /api/v1/guide/vods; false when the path is not one. */
|
|
221
|
+
export async function handleVods(request: IncomingMessage, response: ServerResponse, path: string, ctx: VodContext): Promise<boolean> {
|
|
222
|
+
if (path !== "/api/v1/guide/vods" && !path.startsWith("/api/v1/guide/vods/")) return false;
|
|
223
|
+
const method = request.method ?? "GET";
|
|
224
|
+
const one = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
225
|
+
if (method === "GET" || method === "HEAD") {
|
|
226
|
+
if (path === "/api/v1/guide/vods") {
|
|
227
|
+
ctx.json(response, 200, { vods: await vodEntries(ctx.store, ctx.probe, ctx.base) });
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
const vod = one ? ctx.store.get(one[1]!) : undefined;
|
|
231
|
+
if (!vod) {
|
|
232
|
+
ctx.json(response, 404, { error: "no such video on this guide", code: "not_found" });
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
ctx.json(response, 200, { vod: vodView(vod, ctx.base, await ctx.probe.state(vod.play)) });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
const by = ctx.canControl ? "" : await ctx.member();
|
|
239
|
+
if (!ctx.canControl && by === "") {
|
|
240
|
+
ctx.json(response, 401, { error: "sign in to nixamp.com to publish a video", code: "sign_in" });
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
const refused = ctx.throttle?.();
|
|
244
|
+
if (refused) {
|
|
245
|
+
response.setHeader("retry-after", String(refused.retryAfter));
|
|
246
|
+
ctx.json(response, 429, { error: "too many changes; wait a moment", code: "throttled" });
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (path === "/api/v1/guide/vods" && method === "POST") {
|
|
250
|
+
const raw = await ctx.readBody(request).catch(() => null);
|
|
251
|
+
const body = (typeof raw === "string" ? (() => { try { return JSON.parse(raw) as unknown; } catch { return null; } })() : raw) as
|
|
252
|
+
{ play?: unknown; item?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown } | null;
|
|
253
|
+
// The owner may name one of this server's own items; everybody else hands over the address.
|
|
254
|
+
const own = ctx.canControl && typeof body?.item === "string" && /^[0-9a-f]{16}$/.test(body.item) && ctx.ownPlay ? ctx.ownPlay(body.item) : "";
|
|
255
|
+
const address = vodAddress(own || (typeof body?.play === "string" ? body.play : ""));
|
|
256
|
+
if (!address) {
|
|
257
|
+
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" });
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
const basis = ROOM_BASES.find((b) => b === body?.basis);
|
|
261
|
+
if (!basis || body?.agree !== true) {
|
|
262
|
+
ctx.json(response, 400, { error: "say why it may be shared (owned, permission, licensed, creative-commons or public-domain) and agree", code: "basis_required" });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
const state = (own && ctx.ownState ? ctx.ownState(String(body?.item)) : null) ?? await ctx.probe.state(address.play, true);
|
|
266
|
+
if (!state) {
|
|
267
|
+
ctx.json(response, 404, { error: "that file is not in its server's library, or the key does not open it", code: "not_found" });
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
const all = ctx.store.list();
|
|
271
|
+
const taken = all.find((v) => v.play === address.play);
|
|
272
|
+
if (taken && taken.by !== by && !ctx.canControl) {
|
|
273
|
+
ctx.json(response, 409, { error: "somebody else published that file", code: "taken" });
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (by !== "" && all.filter((v) => v.by === by && v.play !== address.play).length >= VODS_PER_MEMBER) {
|
|
277
|
+
ctx.json(response, 409, { error: `a member may have ${VODS_PER_MEMBER} videos on the guide; take one off first`, code: "too_many" });
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (!taken && all.length >= VODS_MAX) {
|
|
281
|
+
ctx.json(response, 409, { error: "the guide is full", code: "full" });
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 160) : "";
|
|
285
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
286
|
+
const vod = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, duration: state.duration, basis, note, by: taken?.by ?? by });
|
|
287
|
+
ctx.json(response, 200, { vod: vodView(vod, ctx.base, state), page: vodPage(ctx.base, vod.id) });
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
if (one && method === "DELETE") {
|
|
291
|
+
const vod = ctx.store.get(one[1]!);
|
|
292
|
+
if (!vod || (!ctx.canControl && vod.by !== by)) {
|
|
293
|
+
ctx.json(response, 404, { error: "no such video of yours on the guide", code: "not_found" });
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
ctx.store.remove(vod.id);
|
|
297
|
+
ctx.json(response, 200, { removed: vod.id });
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
301
|
+
return true;
|
|
302
|
+
}
|
package/src/library-cli.ts
CHANGED
|
@@ -161,6 +161,25 @@ export async function libraryTool(argv: string[]): Promise<number> {
|
|
|
161
161
|
console.error("nixamp library playlist new NAME [ITEM...] | show ID | add ID ITEM... | delete ID");
|
|
162
162
|
return 2;
|
|
163
163
|
}
|
|
164
|
+
case "publish": {
|
|
165
|
+
// On demand on this server's guide (guide-vods.ts): the server builds the
|
|
166
|
+
// file's public address from its own public URL and listen key.
|
|
167
|
+
const [item] = rest;
|
|
168
|
+
const basis = flag(argv, "--basis");
|
|
169
|
+
if (!item || !basis) { console.error("nixamp library publish needs an item id and --basis owned|permission|licensed|creative-commons|public-domain"); return 2; }
|
|
170
|
+
return out(await call("POST", "/api/v1/guide/vods", { item, basis, agree: true, title: flag(argv, "--title") ?? "", note: flag(argv, "--note") ?? "" }),
|
|
171
|
+
(body) => `on demand: ${String(body["page"])}`);
|
|
172
|
+
}
|
|
173
|
+
case "vods":
|
|
174
|
+
return out(await call("GET", "/api/v1/guide/vods"), (body) => {
|
|
175
|
+
const vods = (body["vods"] as Record<string, unknown>[]) ?? [];
|
|
176
|
+
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");
|
|
177
|
+
});
|
|
178
|
+
case "unpublish": {
|
|
179
|
+
const [vod] = rest;
|
|
180
|
+
if (!vod) { console.error("nixamp library unpublish needs a video id (see nixamp library vods)"); return 2; }
|
|
181
|
+
return out(await call("DELETE", `/api/v1/guide/vods/${vod}`), (body) => `removed ${String(body["removed"])}`);
|
|
182
|
+
}
|
|
164
183
|
default:
|
|
165
184
|
console.log(LIBRARY_USAGE);
|
|
166
185
|
return 2;
|