nixamp 0.34.0 → 0.36.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/auto-stations.d.ts +66 -0
- package/dist/auto-stations.js +108 -0
- package/dist/channels.d.ts +1 -1
- package/dist/library-index.d.ts +2 -0
- package/dist/library-index.js +8 -0
- package/dist/library.d.ts +2 -0
- package/dist/library.js +1 -0
- package/dist/playlist-input.d.ts +9 -0
- package/dist/playlist-input.js +30 -8
- package/dist/server.d.ts +7 -0
- package/dist/server.js +102 -1
- package/dist/station-breaks.d.ts +19 -0
- package/dist/station-breaks.js +92 -0
- package/dist/station-cli.js +22 -0
- package/dist/station-guide.d.ts +4 -0
- package/dist/station-guide.js +86 -0
- package/dist/station-mcp.d.ts +99 -0
- package/dist/station-mcp.js +16 -0
- package/dist/station-metering.d.ts +59 -0
- package/dist/station-metering.js +189 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +20 -5
- package/dist/station-routes.d.ts +13 -0
- package/dist/station-routes.js +90 -3
- package/dist/station-runner.d.ts +9 -0
- package/dist/station-runner.js +15 -0
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +2 -0
- package/dist/stations.d.ts +14 -0
- package/dist/stations.js +26 -0
- package/package.json +1 -1
- package/src/auto-stations.ts +171 -0
- package/src/channels.ts +1 -1
- package/src/library-index.ts +5 -0
- package/src/library.ts +3 -0
- package/src/playlist-input.ts +31 -8
- package/src/server.ts +106 -2
- package/src/station-breaks.ts +93 -0
- package/src/station-cli.ts +21 -0
- package/src/station-guide.ts +92 -0
- package/src/station-mcp.ts +16 -0
- package/src/station-metering.ts +218 -0
- package/src/station-page.ts +20 -5
- package/src/station-routes.ts +98 -3
- package/src/station-runner.ts +20 -0
- package/src/station-usage.ts +2 -0
- package/src/stations.ts +37 -0
- package/web/dist/assets/{ar-DoW0wbjC.js → ar-Ck-FmDpQ.js} +1 -1
- package/web/dist/assets/{cs-CjKqCjy5.js → cs-CPJY3K69.js} +1 -1
- package/web/dist/assets/{da-BQERb7Qz.js → da-BnWtLL09.js} +1 -1
- package/web/dist/assets/{de-CKKbRy6c.js → de-CHevIt93.js} +1 -1
- package/web/dist/assets/{es-CL3v5Og9.js → es-B6wpaUMv.js} +1 -1
- package/web/dist/assets/{fi-DgV81qNU.js → fi-8fKiz2AA.js} +1 -1
- package/web/dist/assets/{fr-BGumytzF.js → fr-0GgY52Sm.js} +1 -1
- package/web/dist/assets/{hi-CAQ9elqo.js → hi-BlsrUAw-.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
- package/web/dist/assets/{hu-UJNgJVWX.js → hu-D3i_6i5w.js} +1 -1
- package/web/dist/assets/{id-BqQOlrF-.js → id-CLLAXbOP.js} +1 -1
- package/web/dist/assets/{it-Cs8G6mvW.js → it-BU13BsSn.js} +1 -1
- package/web/dist/assets/{main-Y1Rvn1GX.js → main-C1ggd2H7.js} +2 -2
- package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-BaoQnMCf.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
- package/web/dist/assets/{nl-BJXQ9nqo.js → nl-DEshyyt7.js} +1 -1
- package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
- package/web/dist/assets/{ru-XmVkvNY8.js → ru-D7sPOc6m.js} +1 -1
- package/web/dist/assets/{sv-pVm9p66e.js → sv-sdrsKAiG.js} +1 -1
- package/web/dist/assets/{uk-4hcyaD3e.js → uk-_lz6hkvo.js} +1 -1
- package/web/dist/assets/{vi-B7MipBlv.js → vi-D81wl7uk.js} +1 -1
- package/web/dist/assets/{zh-B5YVExOW.js → zh-BqMqEom6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/sw.js +23 -23
- package/web/dist/assets/radio-B58WgC1j.js +0 -1
package/src/playlist-input.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface PlaylistInput {
|
|
|
34
34
|
* still arriving, and the channel redials and waits for it.
|
|
35
35
|
*/
|
|
36
36
|
passOver?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* A break every so often, between entries and never in the middle of one:
|
|
39
|
+
* `next` answers a file to play (an advert, an ident) or null for no break
|
|
40
|
+
* this time. The break is outside the rotation and the station's history.
|
|
41
|
+
*/
|
|
42
|
+
breaks?: { everySeconds: number; next: () => Promise<string | null> };
|
|
37
43
|
/** Told when an entry is passed over because it would not play. */
|
|
38
44
|
onFailure?: (event: { at: number; file: string; reason: string }) => void;
|
|
39
45
|
}
|
|
@@ -99,9 +105,10 @@ export class PlaylistInputStream {
|
|
|
99
105
|
let files = o.current?.() ?? o.files;
|
|
100
106
|
if (files.length === 0) throw new Error("The playlist is empty");
|
|
101
107
|
let order = orderFor(files);
|
|
102
|
-
let step = Math.min(o.at, order.length);
|
|
108
|
+
let step = Math.min(Math.max(0, o.at), order.length);
|
|
103
109
|
let played = 0;
|
|
104
110
|
let last = "";
|
|
111
|
+
let lastBreak = 0;
|
|
105
112
|
for (;;) {
|
|
106
113
|
if (signal.aborted) break;
|
|
107
114
|
// Asked at every boundary, so an edit lands on the next track. The
|
|
@@ -128,11 +135,20 @@ export class PlaylistInputStream {
|
|
|
128
135
|
continue;
|
|
129
136
|
}
|
|
130
137
|
{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
// A break, when one is due: in place of the next entry, which stays
|
|
139
|
+
// next. Not at the very start, and never inside an entry.
|
|
140
|
+
let breakFile: string | null = null;
|
|
141
|
+
if (o.breaks && !first && offset - lastBreak >= o.breaks.everySeconds) {
|
|
142
|
+
lastBreak = offset;
|
|
143
|
+
breakFile = await o.breaks.next().catch(() => null);
|
|
144
|
+
if (signal.aborted) return;
|
|
145
|
+
}
|
|
146
|
+
const isBreak = breakFile !== null;
|
|
147
|
+
const at = isBreak ? -1 : order[step]!;
|
|
148
|
+
if (!isBreak) step += 1;
|
|
149
|
+
const file = isBreak ? breakFile! : files[at]!;
|
|
150
|
+
if (!isBreak) last = file;
|
|
151
|
+
if (!isBreak && o.excluded?.(file)) continue;
|
|
136
152
|
const position = first && step - 1 === o.at ? o.position : 0;
|
|
137
153
|
first = false;
|
|
138
154
|
let reached = 0;
|
|
@@ -200,13 +216,20 @@ export class PlaylistInputStream {
|
|
|
200
216
|
abort();
|
|
201
217
|
await closed;
|
|
202
218
|
}
|
|
203
|
-
|
|
204
|
-
|
|
219
|
+
if (!isBreak) {
|
|
220
|
+
failuresInARow = 0;
|
|
221
|
+
played += 1;
|
|
222
|
+
}
|
|
205
223
|
// Match the frame grid used above so rounding cannot accumulate a
|
|
206
224
|
// backwards timestamp over hundreds of short files.
|
|
207
225
|
const length = skipped ? Math.min(duration, reached) : duration;
|
|
208
226
|
offset += o.video ? Math.ceil(length * 30) / 30 : length;
|
|
227
|
+
// The next break is counted from the end of this one, so a spot
|
|
228
|
+
// longer than the gap cannot follow itself.
|
|
229
|
+
if (isBreak) lastBreak = offset;
|
|
209
230
|
} catch (error) {
|
|
231
|
+
// A break that would not play is simply no break.
|
|
232
|
+
if (isBreak && !signal.aborted) continue;
|
|
210
233
|
if (signal.aborted || !o.passOver) throw error;
|
|
211
234
|
// Passed over, not dialled again: the next entry is what a
|
|
212
235
|
// listener should hear, and a file that will not decode now will
|
package/src/server.ts
CHANGED
|
@@ -78,8 +78,12 @@ import { ListenerStore } from "./library-user.ts";
|
|
|
78
78
|
import { Importer } from "./handoff.ts";
|
|
79
79
|
import { handleHandoffs } from "./handoff-routes.ts";
|
|
80
80
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
81
|
+
import { GUIDE_CSP, GUIDE_HTML, guideHosts } from "./station-guide.ts";
|
|
81
82
|
import { StationRunner } from "./station-runner.ts";
|
|
82
83
|
import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
|
|
84
|
+
import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
|
|
85
|
+
import { StationBreaks } from "./station-breaks.ts";
|
|
86
|
+
import { DEFAULT_AUTO, syncAutoStations, type AutoOptions } from "./auto-stations.ts";
|
|
83
87
|
import { CompressionService } from "./compression/service.ts";
|
|
84
88
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
|
|
85
89
|
import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
|
|
@@ -1716,7 +1720,13 @@ export interface HandlerOptions {
|
|
|
1716
1720
|
/** Music handed over from BitTorrented. */
|
|
1717
1721
|
importer?: Importer;
|
|
1718
1722
|
/** This server's stations and what keeps them on the air. */
|
|
1719
|
-
stations?: {
|
|
1723
|
+
stations?: {
|
|
1724
|
+
store: StationStore;
|
|
1725
|
+
runner: StationRunner;
|
|
1726
|
+
usage?: StationUsage;
|
|
1727
|
+
limits?: Limits;
|
|
1728
|
+
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
|
|
1729
|
+
};
|
|
1720
1730
|
web: string | null;
|
|
1721
1731
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
1722
1732
|
webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
|
|
@@ -2639,6 +2649,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2639
2649
|
// port is open from another device before wondering whether the link is
|
|
2640
2650
|
// wrong, and it says nothing about the library.
|
|
2641
2651
|
const eventChannelRequest = Boolean(channelId && await eventForChannel());
|
|
2652
|
+
// The public station guide: at /guide, and at the front of a host named
|
|
2653
|
+
// for it (a Moshpit name such as live.streaming, NIXAMP_GUIDE_HOSTS).
|
|
2654
|
+
const requestHost = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0]!.trim().replace(/:\d+$/, "").toLowerCase();
|
|
2655
|
+
const isGuidePage = Boolean(options.stations) && (request.method === "GET" || request.method === "HEAD") &&
|
|
2656
|
+
(path === "/guide" || (path === "/" && guideHosts().has(requestHost)));
|
|
2642
2657
|
// Whether this request holds only the listen key. A listener may browse
|
|
2643
2658
|
// and hear the library, but where it lives on this machine is the
|
|
2644
2659
|
// owner's business: the absolute path names the user account and the disk.
|
|
@@ -2651,6 +2666,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2651
2666
|
// A station's link is its own key, checked by the station.
|
|
2652
2667
|
!isStationListenPath(path) &&
|
|
2653
2668
|
!STATION_PAGE.test(path) &&
|
|
2669
|
+
!isGuidePage &&
|
|
2654
2670
|
!eventChannelRequest
|
|
2655
2671
|
) {
|
|
2656
2672
|
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
@@ -2703,6 +2719,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2703
2719
|
return { allowed: false, account: "" };
|
|
2704
2720
|
};
|
|
2705
2721
|
|
|
2722
|
+
if (isGuidePage) {
|
|
2723
|
+
response.writeHead(200, {
|
|
2724
|
+
"content-type": "text/html; charset=utf-8",
|
|
2725
|
+
"cache-control": "no-cache",
|
|
2726
|
+
"content-security-policy": GUIDE_CSP,
|
|
2727
|
+
"referrer-policy": "no-referrer",
|
|
2728
|
+
"x-content-type-options": "nosniff",
|
|
2729
|
+
});
|
|
2730
|
+
response.end(request.method === "HEAD" ? undefined : GUIDE_HTML);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2706
2734
|
// The page a station's link opens. The same bytes for every station: it
|
|
2707
2735
|
// reads its id and token from its own address.
|
|
2708
2736
|
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
@@ -2785,6 +2813,13 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2785
2813
|
json,
|
|
2786
2814
|
readBody,
|
|
2787
2815
|
headers: CORS,
|
|
2816
|
+
...(options.stations.usage ? { usage: options.stations.usage } : {}),
|
|
2817
|
+
...(options.stations.limits ? { limits: options.stations.limits } : {}),
|
|
2818
|
+
...(options.stations.auto ? { auto: options.stations.auto } : {}),
|
|
2819
|
+
throttle: () => {
|
|
2820
|
+
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2821
|
+
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
2822
|
+
},
|
|
2788
2823
|
});
|
|
2789
2824
|
return;
|
|
2790
2825
|
}
|
|
@@ -6652,7 +6687,21 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6652
6687
|
const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
|
|
6653
6688
|
const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
|
|
6654
6689
|
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6690
|
+
// What stations deliver, per month, and the operator's limits on it.
|
|
6691
|
+
const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
|
|
6692
|
+
const stationLimits = limitsFromEnv();
|
|
6693
|
+
process.once("exit", () => stationUsage.flush());
|
|
6694
|
+
// Breaks between songs and films: the network's spot, or a house one.
|
|
6695
|
+
const stationBreaks = new StationBreaks({
|
|
6696
|
+
dir: join(stateDir(), "ads"),
|
|
6697
|
+
site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
|
|
6698
|
+
hasSlot: Boolean(process.env["AD_SLOT"]),
|
|
6699
|
+
guard: (url) => refusedDestination(url),
|
|
6700
|
+
});
|
|
6655
6701
|
stationRunner = new StationRunner({
|
|
6702
|
+
breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
|
|
6703
|
+
onRestart: (id) => stationUsage.restarted(id),
|
|
6704
|
+
onFailure: (id) => stationUsage.failed(id),
|
|
6656
6705
|
store: stationStore,
|
|
6657
6706
|
channels,
|
|
6658
6707
|
pull: async (channelId, name, files, known) =>
|
|
@@ -6666,7 +6715,62 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6666
6715
|
roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
|
|
6667
6716
|
log: (line) => console.log(line),
|
|
6668
6717
|
});
|
|
6669
|
-
|
|
6718
|
+
// Auto stations: a station per genre, kept in step with the library.
|
|
6719
|
+
const autoSetting = (): AutoOptions | null => {
|
|
6720
|
+
const saved = readConfig().autoStations;
|
|
6721
|
+
if (!saved) return null;
|
|
6722
|
+
const pick = (name: keyof AutoOptions) => (saved[name] === undefined ? DEFAULT_AUTO[name] : saved[name]);
|
|
6723
|
+
return {
|
|
6724
|
+
video: pick("video") !== false,
|
|
6725
|
+
minItems: Number(pick("minItems")) || DEFAULT_AUTO.minItems,
|
|
6726
|
+
minFilms: Number(pick("minFilms")) || DEFAULT_AUTO.minFilms,
|
|
6727
|
+
start: pick("start") !== false,
|
|
6728
|
+
maxVideoRunning: Number(pick("maxVideoRunning")) >= 0 ? Number(pick("maxVideoRunning")) : DEFAULT_AUTO.maxVideoRunning,
|
|
6729
|
+
breaksEvery: Number(pick("breaksEvery")) >= 0 ? Number(pick("breaksEvery")) : DEFAULT_AUTO.breaksEvery,
|
|
6730
|
+
};
|
|
6731
|
+
};
|
|
6732
|
+
let autoRunning: Promise<unknown> | null = null;
|
|
6733
|
+
const runAuto = (settings: AutoOptions): Promise<unknown> => {
|
|
6734
|
+
if (autoRunning) return autoRunning;
|
|
6735
|
+
autoRunning = syncAutoStations({
|
|
6736
|
+
index: libraryIndex,
|
|
6737
|
+
store: stationStore,
|
|
6738
|
+
runner: stationRunner!,
|
|
6739
|
+
owner: options.owner || "",
|
|
6740
|
+
mayStart: () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations,
|
|
6741
|
+
filmGenres: async (title) => {
|
|
6742
|
+
const hit = await enricher.lookup(title, "title");
|
|
6743
|
+
const genres = hit?.data?.["genres"];
|
|
6744
|
+
return Array.isArray(genres) ? genres.filter((one): one is string => typeof one === "string") : [];
|
|
6745
|
+
},
|
|
6746
|
+
log: (line) => console.log(line),
|
|
6747
|
+
}, settings).then((report) => {
|
|
6748
|
+
const made = report as { radio: unknown[]; tv: unknown[] };
|
|
6749
|
+
console.log(` auto stations: ${made.radio.length} radio, ${made.tv.length} TV.`);
|
|
6750
|
+
return report;
|
|
6751
|
+
}).finally(() => { autoRunning = null; });
|
|
6752
|
+
return autoRunning;
|
|
6753
|
+
};
|
|
6754
|
+
libraryIndex.onScanned = () => {
|
|
6755
|
+
const settings = autoSetting();
|
|
6756
|
+
if (settings) void runAuto(settings);
|
|
6757
|
+
};
|
|
6758
|
+
const stationAuto = {
|
|
6759
|
+
setting: () => autoSetting(),
|
|
6760
|
+
sync: async (input: Record<string, unknown>) => {
|
|
6761
|
+
if (input["off"] === true) {
|
|
6762
|
+
writeConfig({ autoStations: undefined });
|
|
6763
|
+
return { off: true };
|
|
6764
|
+
}
|
|
6765
|
+
const settings: AutoOptions = { ...DEFAULT_AUTO, ...(autoSetting() ?? {}) };
|
|
6766
|
+
for (const name of Object.keys(DEFAULT_AUTO) as (keyof AutoOptions)[]) {
|
|
6767
|
+
if (input[name] !== undefined) (settings as unknown as Record<string, unknown>)[name] = input[name];
|
|
6768
|
+
}
|
|
6769
|
+
writeConfig({ autoStations: settings as unknown as Record<string, unknown> });
|
|
6770
|
+
return runAuto(settings);
|
|
6771
|
+
},
|
|
6772
|
+
};
|
|
6773
|
+
const stations = { store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto };
|
|
6670
6774
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6671
6775
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
|
6672
6776
|
index: libraryIndex,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The break: a short spot between songs or films, instead of commercials.
|
|
3
|
+
*
|
|
4
|
+
* Asked for once per break, never per listener, and never in the middle of
|
|
5
|
+
* anything (playlist-input.ts places it between entries). Where it comes
|
|
6
|
+
* from, in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. the ad network, through this server's own AD_SLOT (ads.ts), or
|
|
9
|
+
* through nixamp.com's /api/ads/next when this server has none, so a
|
|
10
|
+
* self-hosted station carries the same spot the site does and the
|
|
11
|
+
* network meters the impression;
|
|
12
|
+
* 2. a house spot the operator dropped in $XDG_STATE_HOME/nixamp/ads/
|
|
13
|
+
* (spot.mp4 for TV, spot.mp3 for radio);
|
|
14
|
+
* 3. nothing: no break this time, and the music goes on.
|
|
15
|
+
*
|
|
16
|
+
* A creative is fetched once per URL into ads/cache and played from disk:
|
|
17
|
+
* https only, public addresses only, at most 50 MB.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
21
|
+
import { extname, join } from "node:path";
|
|
22
|
+
import { Readable } from "node:stream";
|
|
23
|
+
import { pipeline } from "node:stream/promises";
|
|
24
|
+
import { nextAdvert, type Advert } from "./ads.ts";
|
|
25
|
+
|
|
26
|
+
const MAX_BYTES = 50 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
export interface BreakOptions {
|
|
29
|
+
dir: string;
|
|
30
|
+
/** nixamp.com, when this server has no slot of its own. */
|
|
31
|
+
site: string;
|
|
32
|
+
hasSlot: boolean;
|
|
33
|
+
guard: (url: string) => Promise<string>;
|
|
34
|
+
fetcher?: typeof fetch;
|
|
35
|
+
/** Injected by tests in place of the network. */
|
|
36
|
+
ask?: (kind: "audio" | "video") => Promise<Advert>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class StationBreaks {
|
|
40
|
+
constructor(private readonly options: BreakOptions) {}
|
|
41
|
+
|
|
42
|
+
private async ask(kind: "audio" | "video"): Promise<Advert> {
|
|
43
|
+
if (this.options.ask) return this.options.ask(kind);
|
|
44
|
+
if (this.options.hasSlot) return nextAdvert(kind);
|
|
45
|
+
const send = this.options.fetcher ?? fetch;
|
|
46
|
+
try {
|
|
47
|
+
const answer = await send(`${this.options.site.replace(/\/+$/, "")}/api/ads/next?kind=${kind}`, { signal: AbortSignal.timeout(2_000) });
|
|
48
|
+
if (!answer.ok) return { url: null };
|
|
49
|
+
const body = (await answer.json()) as { url?: unknown; kind?: unknown };
|
|
50
|
+
return typeof body.url === "string" ? { url: body.url, kind: body.kind === "video" ? "video" : "audio" } : { url: null };
|
|
51
|
+
} catch {
|
|
52
|
+
return { url: null };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A file to play for this break, or null for none. */
|
|
57
|
+
async next(kind: "audio" | "video"): Promise<string | null> {
|
|
58
|
+
const advert = await this.ask(kind);
|
|
59
|
+
if (advert.url) {
|
|
60
|
+
const cached = await this.cache(advert.url).catch(() => null);
|
|
61
|
+
if (cached) return cached;
|
|
62
|
+
}
|
|
63
|
+
const house = join(this.options.dir, kind === "video" ? "spot.mp4" : "spot.mp3");
|
|
64
|
+
return existsSync(house) ? house : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async cache(url: string): Promise<string | null> {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
if (parsed.protocol !== "https:") return null;
|
|
70
|
+
const folder = join(this.options.dir, "cache");
|
|
71
|
+
const file = join(folder, `${createHash("sha1").update(url).digest("hex")}${extname(parsed.pathname).slice(0, 6) || ".mp4"}`);
|
|
72
|
+
if (existsSync(file)) return file;
|
|
73
|
+
if (await this.options.guard(url)) return null;
|
|
74
|
+
const answer = await (this.options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(20_000) });
|
|
75
|
+
if (!answer.ok || !answer.body) return null;
|
|
76
|
+
if (Number(answer.headers.get("content-length") ?? 0) > MAX_BYTES) return null;
|
|
77
|
+
mkdirSync(folder, { recursive: true });
|
|
78
|
+
const part = `${file}.part`;
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
const counted = Readable.fromWeb(answer.body as import("node:stream/web").ReadableStream).on("data", (chunk: Buffer) => {
|
|
81
|
+
bytes += chunk.length;
|
|
82
|
+
if (bytes > MAX_BYTES) counted.destroy(new Error("too big"));
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await pipeline(counted, createWriteStream(part));
|
|
86
|
+
renameSync(part, file);
|
|
87
|
+
return file;
|
|
88
|
+
} catch {
|
|
89
|
+
rmSync(part, { force: true });
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/station-cli.ts
CHANGED
|
@@ -177,6 +177,27 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
177
177
|
return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
|
|
178
178
|
});
|
|
179
179
|
}
|
|
180
|
+
case "auto": {
|
|
181
|
+
const body: Record<string, unknown> = argv.includes("--off") ? { off: true } : {
|
|
182
|
+
...(argv.includes("--no-video") ? { video: false } : {}),
|
|
183
|
+
...(argv.includes("--no-start") ? { start: false } : {}),
|
|
184
|
+
...(flag(argv, "--min") ? { minItems: Number(flag(argv, "--min")) } : {}),
|
|
185
|
+
...(flag(argv, "--films") ? { minFilms: Number(flag(argv, "--films")) } : {}),
|
|
186
|
+
...(flag(argv, "--tv") ? { maxVideoRunning: Number(flag(argv, "--tv")) } : {}),
|
|
187
|
+
...(flag(argv, "--breaks") ? { breaksEvery: Number(flag(argv, "--breaks")) } : {}),
|
|
188
|
+
};
|
|
189
|
+
return show(await call("POST", "/api/v1/stations/auto", body), (answer) => {
|
|
190
|
+
const report = answer["report"] as { off?: boolean; radio?: Array<Record<string, unknown>>; tv?: Array<Record<string, unknown>>; skipped?: string[] };
|
|
191
|
+
if (report.off) return "Auto stations are off. The stations already made stay as they are.";
|
|
192
|
+
const line = (one: Record<string, unknown>, unit: string) => ` ${String(one["genre"]).padEnd(16)} ${String(one["files"] ?? one["films"]).padStart(5)} ${unit} ${one["running"] ? "on air" : "stopped"}${one["made"] ? " (new)" : ""} [${String(one["id"])}]`;
|
|
193
|
+
return [
|
|
194
|
+
"Radio:", ...(report.radio ?? []).map((one) => line(one, "files")),
|
|
195
|
+
"TV:", ...((report.tv ?? []).length ? (report.tv ?? []).map((one) => line(one, "films")) : [" none (no film genres known yet)"]),
|
|
196
|
+
...((report.skipped ?? []).length ? [`Too small for a station: ${(report.skipped ?? []).join(", ")}`] : []),
|
|
197
|
+
"Every library scan keeps these up to date. They are unlisted: `nixamp station link ID` for a link.",
|
|
198
|
+
].join("\n");
|
|
199
|
+
});
|
|
200
|
+
}
|
|
180
201
|
case "delete": {
|
|
181
202
|
const station = needId();
|
|
182
203
|
if (!station) return 2;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The station guide: every station on this server its owner made public
|
|
3
|
+
* and listed, what is on each, and a way in. What a Moshpit name like
|
|
4
|
+
* live.streaming opens.
|
|
5
|
+
*
|
|
6
|
+
* Like the station page, the same bytes for every server: it asks
|
|
7
|
+
* /api/v1/stations/guide for the list and writes it in as text.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
const STYLE = `
|
|
12
|
+
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
|
|
13
|
+
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
|
|
14
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
15
|
+
main{max-width:880px;margin:0 auto;padding:40px 16px}
|
|
16
|
+
h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
|
|
17
|
+
h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
|
|
18
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
|
|
19
|
+
a.station{display:block;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;color:inherit;text-decoration:none}
|
|
20
|
+
a.station:hover,a.station:focus-visible{border-color:var(--accent);outline:none}
|
|
21
|
+
.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
|
|
22
|
+
.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
|
|
23
|
+
footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const SCRIPT = `
|
|
27
|
+
(async () => {
|
|
28
|
+
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
|
|
29
|
+
const draw = (stations) => {
|
|
30
|
+
for (const [kind, box] of [["audio", "#radio"], ["video", "#tv"]]) {
|
|
31
|
+
const grid = document.querySelector(box);
|
|
32
|
+
grid.replaceChildren();
|
|
33
|
+
const list = stations.filter((s) => s.kind === kind);
|
|
34
|
+
document.querySelector(box + "-title").hidden = list.length === 0;
|
|
35
|
+
for (const s of list) {
|
|
36
|
+
const a = el("a", "station");
|
|
37
|
+
a.href = s.page;
|
|
38
|
+
a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
|
|
39
|
+
el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
|
|
40
|
+
grid.append(a);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
document.querySelector("#empty").hidden = stations.length > 0;
|
|
44
|
+
};
|
|
45
|
+
const load = async () => {
|
|
46
|
+
try {
|
|
47
|
+
const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
|
|
48
|
+
if (r.ok) draw((await r.json()).stations);
|
|
49
|
+
} catch {}
|
|
50
|
+
};
|
|
51
|
+
await load();
|
|
52
|
+
setInterval(load, 15000);
|
|
53
|
+
})();
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
57
|
+
|
|
58
|
+
export const GUIDE_HTML = `<!doctype html>
|
|
59
|
+
<html lang="en">
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="utf-8">
|
|
62
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
63
|
+
<title>Live · stations</title>
|
|
64
|
+
<meta name="description" content="Radio and TV stations, live now.">
|
|
65
|
+
<link rel="icon" href="data:,">
|
|
66
|
+
<style>${STYLE}</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<main>
|
|
70
|
+
<h1>Live</h1>
|
|
71
|
+
<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
|
|
72
|
+
<h2 id="radio-title" hidden>Radio</h2>
|
|
73
|
+
<div id="radio" class="grid" aria-live="polite"></div>
|
|
74
|
+
<h2 id="tv-title" hidden>TV</h2>
|
|
75
|
+
<div id="tv" class="grid" aria-live="polite"></div>
|
|
76
|
+
<p id="empty" class="empty">Nothing is on the air here yet.</p>
|
|
77
|
+
<footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
|
|
78
|
+
</main>
|
|
79
|
+
<script>${SCRIPT}</script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export const GUIDE_CSP = [
|
|
85
|
+
"default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
|
|
86
|
+
"img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
87
|
+
].join("; ");
|
|
88
|
+
|
|
89
|
+
/** The hosts whose front page is the guide (NIXAMP_GUIDE_HOSTS, comma-separated). */
|
|
90
|
+
export function guideHosts(env: Record<string, string | undefined> = process.env): Set<string> {
|
|
91
|
+
return new Set((env["NIXAMP_GUIDE_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean));
|
|
92
|
+
}
|
package/src/station-mcp.ts
CHANGED
|
@@ -80,6 +80,18 @@ export const STATION_TOOLS = [
|
|
|
80
80
|
required: ["id", "visibility"],
|
|
81
81
|
},
|
|
82
82
|
},
|
|
83
|
+
{
|
|
84
|
+
name: "station_auto",
|
|
85
|
+
description: "A station for every genre in the library (radio from music tags, TV from film genres), kept up to date after every library scan, with a short break between songs and films. Unlisted; publishing stays a separate step. off: true stops the automation.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
server: SERVER,
|
|
90
|
+
video: { type: "boolean" }, minItems: { type: "integer" }, minFilms: { type: "integer" },
|
|
91
|
+
maxVideoRunning: { type: "integer" }, breaksEvery: { type: "integer" }, start: { type: "boolean" }, off: { type: "boolean" },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
83
95
|
{
|
|
84
96
|
name: "station_unpublish",
|
|
85
97
|
description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
|
|
@@ -149,6 +161,10 @@ export async function callStationTool(name: string, args: Record<string, unknown
|
|
|
149
161
|
const all = (answer.body["stations"] as Record<string, unknown>[] | undefined) ?? [];
|
|
150
162
|
return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
|
|
151
163
|
}
|
|
164
|
+
if (name === "station_auto") {
|
|
165
|
+
const answer = await call("POST", "/api/v1/stations/auto", pick(["video", "minItems", "minFilms", "maxVideoRunning", "breaksEvery", "start", "off"]));
|
|
166
|
+
return answer.ok ? say(JSON.stringify(answer.body["report"], null, 2)) : refused(answer.body);
|
|
167
|
+
}
|
|
152
168
|
if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create") return say("Which station? Pass its id from station_list.", true);
|
|
153
169
|
if (name === "station_get") {
|
|
154
170
|
const answer = await call("GET", `/api/v1/stations/${id}`);
|