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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What stations cost to run, and the limits that keep one from costing
|
|
3
|
+
* everything.
|
|
4
|
+
*
|
|
5
|
+
* Counted where it is spent: every byte a station's link delivers and every
|
|
6
|
+
* second somebody is connected, per station, per calendar month, kept in
|
|
7
|
+
* `usage-<port>.json`. Restarts and files that would not play are counted
|
|
8
|
+
* too, because those are what an operator is asked about.
|
|
9
|
+
*
|
|
10
|
+
* Limits are the operator's (environment, below), and each is a refusal a
|
|
11
|
+
* person can act on, never a silent cut:
|
|
12
|
+
*
|
|
13
|
+
* NIXAMP_STATION_MAX_LISTENERS per station (default 200)
|
|
14
|
+
* NIXAMP_MAX_LISTENERS across every station (default 500)
|
|
15
|
+
* NIXAMP_MAX_RUNNING_STATIONS stations on the air at once (default 10)
|
|
16
|
+
* NIXAMP_STATION_MONTHLY_GB delivered per station per month (default 0: no allowance)
|
|
17
|
+
*
|
|
18
|
+
* When an allowance runs out the policy is "pause": nobody new is admitted
|
|
19
|
+
* until the month turns or the allowance is raised; nobody already
|
|
20
|
+
* listening is cut off mid-song. Control requests are limited separately
|
|
21
|
+
* (STATION_CONTROL_LIMIT) so that a flood of listeners never locks the owner
|
|
22
|
+
* out, and a flood of control requests never starves listeners.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
export interface Limits {
|
|
28
|
+
stationListeners: number;
|
|
29
|
+
totalListeners: number;
|
|
30
|
+
runningStations: number;
|
|
31
|
+
/** Bytes per station per month; 0 is no allowance. */
|
|
32
|
+
monthlyBytes: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function limitsFromEnv(env: Record<string, string | undefined> = process.env): Limits {
|
|
36
|
+
const whole = (name: string, fallback: number): number => {
|
|
37
|
+
const value = Number(env[name]);
|
|
38
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
stationListeners: whole("NIXAMP_STATION_MAX_LISTENERS", 200),
|
|
42
|
+
totalListeners: whole("NIXAMP_MAX_LISTENERS", 500),
|
|
43
|
+
runningStations: whole("NIXAMP_MAX_RUNNING_STATIONS", 10),
|
|
44
|
+
monthlyBytes: Math.round(Number(env["NIXAMP_STATION_MONTHLY_GB"] ?? 0) * 1024 ** 3) || 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Station control: start, stop, edit, publish. Thirty a minute is a person. */
|
|
49
|
+
export const STATION_CONTROL_LIMIT = { allowed: 30, windowMs: 60_000 };
|
|
50
|
+
|
|
51
|
+
export interface MonthUsage {
|
|
52
|
+
bytes: number;
|
|
53
|
+
listenerSeconds: number;
|
|
54
|
+
peakListeners: number;
|
|
55
|
+
restarts: number;
|
|
56
|
+
failures: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const BLANK: MonthUsage = { bytes: 0, listenerSeconds: 0, peakListeners: 0, restarts: 0, failures: 0 };
|
|
60
|
+
|
|
61
|
+
export function monthOf(at: number): string {
|
|
62
|
+
return new Date(at).toISOString().slice(0, 7);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class StationUsage {
|
|
66
|
+
/** station -> month -> usage */
|
|
67
|
+
private usage: Record<string, Record<string, MonthUsage>> = {};
|
|
68
|
+
private live = new Map<string, Map<object, number>>();
|
|
69
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
|
|
71
|
+
constructor(private readonly file: string, private readonly now: () => number = Date.now) {
|
|
72
|
+
try {
|
|
73
|
+
this.usage = JSON.parse(readFileSync(file, "utf8")) as typeof this.usage;
|
|
74
|
+
} catch {
|
|
75
|
+
this.usage = {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static fileFor(stateDir: string, port: number): string {
|
|
80
|
+
return join(stateDir, `usage-${port}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private month(station: string, at = this.now()): MonthUsage {
|
|
84
|
+
const byMonth = (this.usage[station] ??= {});
|
|
85
|
+
return (byMonth[monthOf(at)] ??= { ...BLANK });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private touch(): void {
|
|
89
|
+
if (this.timer) return;
|
|
90
|
+
this.timer = setTimeout(() => this.flush(), 5_000);
|
|
91
|
+
this.timer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
flush(): void {
|
|
95
|
+
if (this.timer) clearTimeout(this.timer);
|
|
96
|
+
this.timer = null;
|
|
97
|
+
// Listening still going on is counted up to now, then counted on from now.
|
|
98
|
+
const at = this.now();
|
|
99
|
+
for (const [station, listeners] of this.live) {
|
|
100
|
+
for (const [who, since] of listeners) {
|
|
101
|
+
this.month(station, at).listenerSeconds += Math.max(0, (at - since) / 1000);
|
|
102
|
+
listeners.set(who, at);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
107
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
108
|
+
writeFileSync(temporary, JSON.stringify(this.usage), { mode: 0o600 });
|
|
109
|
+
renameSync(temporary, this.file);
|
|
110
|
+
} catch {
|
|
111
|
+
// Counting is not a reason to stop serving.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Somebody started listening; returns what to call when they stop. */
|
|
116
|
+
joined(station: string): () => void {
|
|
117
|
+
const who = {};
|
|
118
|
+
let listeners = this.live.get(station);
|
|
119
|
+
if (!listeners) {
|
|
120
|
+
listeners = new Map();
|
|
121
|
+
this.live.set(station, listeners);
|
|
122
|
+
}
|
|
123
|
+
listeners.set(who, this.now());
|
|
124
|
+
const month = this.month(station);
|
|
125
|
+
month.peakListeners = Math.max(month.peakListeners, listeners.size);
|
|
126
|
+
this.touch();
|
|
127
|
+
return () => {
|
|
128
|
+
const since = listeners!.get(who);
|
|
129
|
+
if (since === undefined) return;
|
|
130
|
+
listeners!.delete(who);
|
|
131
|
+
this.month(station).listenerSeconds += Math.max(0, (this.now() - since) / 1000);
|
|
132
|
+
this.touch();
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
delivered(station: string, bytes: number): void {
|
|
137
|
+
this.month(station).bytes += bytes;
|
|
138
|
+
this.touch();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
restarted(station: string): void {
|
|
142
|
+
this.month(station).restarts += 1;
|
|
143
|
+
this.touch();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
failed(station: string): void {
|
|
147
|
+
this.month(station).failures += 1;
|
|
148
|
+
this.touch();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
listeners(station: string): number {
|
|
152
|
+
return this.live.get(station)?.size ?? 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
totalListeners(): number {
|
|
156
|
+
let total = 0;
|
|
157
|
+
for (const listeners of this.live.values()) total += listeners.size;
|
|
158
|
+
return total;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
thisMonth(station: string): MonthUsage {
|
|
162
|
+
return { ...this.month(station) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
history(station: string): Record<string, MonthUsage> {
|
|
166
|
+
return { ...(this.usage[station] ?? {}) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
forget(station: string): void {
|
|
170
|
+
delete this.usage[station];
|
|
171
|
+
this.live.delete(station);
|
|
172
|
+
this.touch();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether one more listener may join this station now: null to go
|
|
177
|
+
* ahead, or why not, with a code and how long to wait.
|
|
178
|
+
*/
|
|
179
|
+
admit(station: string, limits: Limits): { code: string; error: string; status: number; retryAfter: number } | null {
|
|
180
|
+
if (limits.monthlyBytes > 0 && this.month(station).bytes >= limits.monthlyBytes) {
|
|
181
|
+
const now = new Date(this.now());
|
|
182
|
+
const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
183
|
+
return { code: "allowance_exhausted", error: "this station has used its delivery allowance for the month", status: 429, retryAfter: Math.ceil((next - this.now()) / 1000) };
|
|
184
|
+
}
|
|
185
|
+
if (limits.stationListeners > 0 && this.listeners(station) >= limits.stationListeners) {
|
|
186
|
+
return { code: "station_full", error: "this station is full; try again in a little while", status: 503, retryAfter: 60 };
|
|
187
|
+
}
|
|
188
|
+
if (limits.totalListeners > 0 && this.totalListeners() >= limits.totalListeners) {
|
|
189
|
+
return { code: "server_full", error: "this server is carrying as many listeners as it can; try again in a little while", status: 503, retryAfter: 60 };
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Things an owner should know about a station, in words. */
|
|
196
|
+
export function alertsFor(
|
|
197
|
+
station: { desired: string; failures: Record<string, number> },
|
|
198
|
+
state: string,
|
|
199
|
+
offlineSince: number,
|
|
200
|
+
usage: MonthUsage,
|
|
201
|
+
limits: Limits,
|
|
202
|
+
now = Date.now(),
|
|
203
|
+
): string[] {
|
|
204
|
+
const alerts: string[] = [];
|
|
205
|
+
if (station.desired === "running" && (state === "offline" || state === "recovering") && offlineSince > 0) {
|
|
206
|
+
const minutes = Math.floor((now - offlineSince) / 60_000);
|
|
207
|
+
if (minutes >= 2) alerts.push(`Off the air for ${minutes} minutes while it should be running.`);
|
|
208
|
+
}
|
|
209
|
+
const left = Object.values(station.failures).filter((count) => count >= 3).length;
|
|
210
|
+
if (left > 0) alerts.push(`${left} file${left === 1 ? " is" : "s are"} left out because ${left === 1 ? "it" : "they"} would not play.`);
|
|
211
|
+
if (usage.restarts >= 5) alerts.push(`Restarted ${usage.restarts} times this month: something keeps stopping it.`);
|
|
212
|
+
if (limits.monthlyBytes > 0) {
|
|
213
|
+
const share = usage.bytes / limits.monthlyBytes;
|
|
214
|
+
if (share >= 1) alerts.push("The delivery allowance for this month is used up: nobody new can join until it resets.");
|
|
215
|
+
else if (share >= 0.9) alerts.push(`${Math.round(share * 100)}% of this month's delivery allowance is used.`);
|
|
216
|
+
}
|
|
217
|
+
return alerts;
|
|
218
|
+
}
|
package/src/station-page.ts
CHANGED
|
@@ -30,6 +30,7 @@ button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(-
|
|
|
30
30
|
label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
|
|
31
31
|
input[type=range]{width:120px;accent-color:var(--accent)}
|
|
32
32
|
.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
|
|
33
|
+
.screen{display:block;width:100%;aspect-ratio:16/9;background:#000;border-radius:10px;margin:12px 0}
|
|
33
34
|
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
34
35
|
@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
|
|
35
36
|
@keyframes pulse{50%{opacity:.35}}
|
|
@@ -42,13 +43,29 @@ const SCRIPT = `
|
|
|
42
43
|
const q = "?t=" + encodeURIComponent(token);
|
|
43
44
|
const api = "/api/v1/stations/" + encodeURIComponent(id);
|
|
44
45
|
const $ = (s) => document.querySelector(s);
|
|
45
|
-
|
|
46
|
+
// Sound, or a TV station's picture: the element is chosen by the first state.
|
|
47
|
+
let audio = new Audio();
|
|
46
48
|
audio.preload = "none";
|
|
49
|
+
let wired = false;
|
|
50
|
+
const wire = () => {
|
|
51
|
+
if (wired) return;
|
|
52
|
+
wired = true;
|
|
53
|
+
audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
|
|
54
|
+
audio.addEventListener("error", again);
|
|
55
|
+
audio.addEventListener("ended", again);
|
|
56
|
+
audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
|
|
57
|
+
};
|
|
47
58
|
let wanted = false, retry = 0, timer = 0, last = null, live = false;
|
|
48
59
|
const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
|
|
49
60
|
const say = (text) => { $("#announce").textContent = text; };
|
|
50
61
|
const draw = (s) => {
|
|
51
62
|
last = s;
|
|
63
|
+
if (s.kind === "video" && audio.tagName !== "VIDEO") {
|
|
64
|
+
audio = $("#screen");
|
|
65
|
+
audio.hidden = false;
|
|
66
|
+
wired = false;
|
|
67
|
+
wire();
|
|
68
|
+
}
|
|
52
69
|
document.title = s.name + " · nixamp";
|
|
53
70
|
$("#name").textContent = s.name;
|
|
54
71
|
$("#about").textContent = s.description || "";
|
|
@@ -102,10 +119,7 @@ const SCRIPT = `
|
|
|
102
119
|
clearTimeout(timer);
|
|
103
120
|
timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
|
|
104
121
|
};
|
|
105
|
-
|
|
106
|
-
audio.addEventListener("error", again);
|
|
107
|
-
audio.addEventListener("ended", again);
|
|
108
|
-
audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
|
|
122
|
+
wire();
|
|
109
123
|
$("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
|
|
110
124
|
$("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
|
|
111
125
|
poll();
|
|
@@ -130,6 +144,7 @@ const PAGE = `<!doctype html>
|
|
|
130
144
|
<main>
|
|
131
145
|
<div class="card">
|
|
132
146
|
<span id="state" class="chip unavailable" role="status">Loading</span>
|
|
147
|
+
<video id="screen" class="screen" playsinline preload="none" hidden></video>
|
|
133
148
|
<h1 id="name"> </h1>
|
|
134
149
|
<p id="about" class="about" hidden></p>
|
|
135
150
|
<div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
|
package/src/station-routes.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
16
|
import type { Channels } from "./channels.ts";
|
|
17
17
|
import type { StationRunner } from "./station-runner.ts";
|
|
18
|
+
import { alertsFor, type Limits, type StationUsage } from "./station-metering.ts";
|
|
18
19
|
import {
|
|
19
20
|
StationError, channelOf, tokensMatch, type OwnerStation, type PublicStation, type Station, type StationStore,
|
|
20
21
|
} from "./stations.ts";
|
|
@@ -24,7 +25,7 @@ const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|
|
|
|
24
25
|
|
|
25
26
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
26
27
|
export function isStationListenPath(path: string): boolean {
|
|
27
|
-
return LISTEN.test(path);
|
|
28
|
+
return LISTEN.test(path) || path === "/api/v1/stations/guide";
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface StationContext {
|
|
@@ -45,6 +46,13 @@ export interface StationContext {
|
|
|
45
46
|
readBody: (request: IncomingMessage) => Promise<string>;
|
|
46
47
|
/** Headers every answer carries (CORS). */
|
|
47
48
|
headers: Record<string, string>;
|
|
49
|
+
/** Metering and limits; absent in tests that do not care. */
|
|
50
|
+
usage?: StationUsage;
|
|
51
|
+
limits?: Limits;
|
|
52
|
+
/** Null to go ahead with a change, or how long to wait (control rate limit). */
|
|
53
|
+
throttle?: () => { retryAfter: number } | null;
|
|
54
|
+
/** Auto stations: run a sync with these settings (and keep them), or read them. */
|
|
55
|
+
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
function fail(ctx: StationContext, response: ServerResponse, error: unknown): void {
|
|
@@ -86,7 +94,10 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
|
|
|
86
94
|
state,
|
|
87
95
|
// Tags, or nothing: a bare file name is the owner's, not the public's.
|
|
88
96
|
nowPlaying: track && (track.title || track.artist) ? track : null,
|
|
89
|
-
|
|
97
|
+
kind: station.video ? "video" : "audio",
|
|
98
|
+
format: state === "stopped" || state === "offline" ? null : station.video
|
|
99
|
+
? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
|
|
100
|
+
: { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
|
|
90
101
|
listeners: ctx.runner.listeners(station.id),
|
|
91
102
|
};
|
|
92
103
|
}
|
|
@@ -101,6 +112,10 @@ export function ownerView(ctx: StationContext, station: Station): OwnerStation &
|
|
|
101
112
|
error: ctx.runner.error(station.id),
|
|
102
113
|
links: links(ctx, station),
|
|
103
114
|
nowPlaying: nowPlayingFile(ctx, station),
|
|
115
|
+
...(ctx.usage && ctx.limits ? {
|
|
116
|
+
metrics: { listeners: ctx.usage.listeners(station.id), month: ctx.usage.thisMonth(station.id), limits: ctx.limits },
|
|
117
|
+
alerts: alertsFor(station, ctx.runner.observed(station.id), ctx.runner.downSince(station.id), ctx.usage.thisMonth(station.id), ctx.limits),
|
|
118
|
+
} : {}),
|
|
104
119
|
};
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -115,6 +130,16 @@ export async function handleStations(
|
|
|
115
130
|
if (path !== "/api/v1/stations" && !path.startsWith("/api/v1/stations/")) return false;
|
|
116
131
|
const method = request.method ?? "GET";
|
|
117
132
|
|
|
133
|
+
// The public guide: only stations their owner made public and listed, with
|
|
134
|
+
// their links, because public is what that means. Nothing else, ever.
|
|
135
|
+
if (path === "/api/v1/stations/guide" && (method === "GET" || method === "HEAD")) {
|
|
136
|
+
const listed = ctx.store.list().filter((one) => one.visibility === "public" && one.listed);
|
|
137
|
+
ctx.json(response, 200, {
|
|
138
|
+
stations: listed.map((one) => ({ ...publicView(ctx, one), page: links(ctx, one).page, m3u: links(ctx, one).m3u })),
|
|
139
|
+
});
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
const listen = LISTEN.exec(path);
|
|
119
144
|
if (listen) {
|
|
120
145
|
const station = ctx.store.get(listen[1]!);
|
|
@@ -204,20 +229,45 @@ export async function handleStations(
|
|
|
204
229
|
ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
|
|
205
230
|
return true;
|
|
206
231
|
}
|
|
232
|
+
// Admitted only within the station's and the server's limits, and the
|
|
233
|
+
// month's allowance; refused with what to do about it.
|
|
234
|
+
const refused = method === "GET" && ctx.usage && ctx.limits ? ctx.usage.admit(station.id, ctx.limits) : null;
|
|
235
|
+
if (refused) {
|
|
236
|
+
response.writeHead(refused.status, { ...ctx.headers, "content-type": "application/json; charset=utf-8", "retry-after": String(refused.retryAfter) });
|
|
237
|
+
response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
207
240
|
response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
|
|
208
241
|
if (method === "HEAD") {
|
|
209
242
|
response.end();
|
|
210
243
|
return true;
|
|
211
244
|
}
|
|
212
|
-
|
|
245
|
+
// Every byte that goes out through the link is counted against the station.
|
|
246
|
+
const usage = ctx.usage;
|
|
247
|
+
const counted = usage
|
|
248
|
+
? {
|
|
249
|
+
write: (chunk: Buffer): boolean => {
|
|
250
|
+
usage.delivered(station.id, chunk.length);
|
|
251
|
+
return response.write(chunk);
|
|
252
|
+
},
|
|
253
|
+
end: (): void => { response.end(); },
|
|
254
|
+
pending: (): number => response.writableLength,
|
|
255
|
+
}
|
|
256
|
+
: response;
|
|
257
|
+
const detach = ctx.channels.listen(channelId, counted);
|
|
213
258
|
if (detach === null) {
|
|
214
259
|
response.end();
|
|
215
260
|
return true;
|
|
216
261
|
}
|
|
262
|
+
const left = usage?.joined(station.id);
|
|
217
263
|
const untrack = ctx.runner.track(station.id, { end: () => response.destroy() });
|
|
264
|
+
let gone = false;
|
|
218
265
|
const leave = (): void => {
|
|
266
|
+
if (gone) return;
|
|
267
|
+
gone = true;
|
|
219
268
|
detach();
|
|
220
269
|
untrack();
|
|
270
|
+
left?.();
|
|
221
271
|
};
|
|
222
272
|
request.on("close", leave);
|
|
223
273
|
response.on("close", leave);
|
|
@@ -229,6 +279,15 @@ export async function handleStations(
|
|
|
229
279
|
ctx.json(response, 403, { error: "only this server's owner manages its stations", code: "forbidden" });
|
|
230
280
|
return true;
|
|
231
281
|
}
|
|
282
|
+
// Changes are limited apart from listening, so neither starves the other.
|
|
283
|
+
if (method !== "GET" && ctx.throttle) {
|
|
284
|
+
const wait = ctx.throttle();
|
|
285
|
+
if (wait) {
|
|
286
|
+
response.setHeader("retry-after", String(wait.retryAfter));
|
|
287
|
+
ctx.json(response, 429, { error: "too many changes at once; wait a moment", code: "rate_limited", retryAfter: wait.retryAfter });
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
232
291
|
|
|
233
292
|
const body = async (): Promise<Record<string, unknown>> => {
|
|
234
293
|
if (method === "GET" || method === "DELETE") return {};
|
|
@@ -271,6 +330,23 @@ export async function handleStations(
|
|
|
271
330
|
return true;
|
|
272
331
|
}
|
|
273
332
|
|
|
333
|
+
if (path === "/api/v1/stations/auto") {
|
|
334
|
+
if (!ctx.auto) {
|
|
335
|
+
ctx.json(response, 404, { error: "auto stations are not available here", code: "not_found" });
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
if (method === "GET") {
|
|
339
|
+
ctx.json(response, 200, { setting: ctx.auto.setting() });
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
if (method === "POST") {
|
|
343
|
+
ctx.json(response, 200, { report: await ctx.auto.sync(await body()) });
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
ctx.json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
274
350
|
const match = /^\/api\/v1\/stations\/([a-z0-9]{10})(?:\/([a-z-]+))?$/.exec(path);
|
|
275
351
|
if (!match) {
|
|
276
352
|
ctx.json(response, 404, { error: "no such station", code: "not_found" });
|
|
@@ -305,11 +381,30 @@ export async function handleStations(
|
|
|
305
381
|
return true;
|
|
306
382
|
}
|
|
307
383
|
|
|
384
|
+
if (action === "metrics" && method === "GET") {
|
|
385
|
+
if (!ctx.usage || !ctx.limits) {
|
|
386
|
+
ctx.json(response, 404, { error: "metering is off on this server", code: "not_found" });
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
ctx.json(response, 200, {
|
|
390
|
+
listeners: ctx.usage.listeners(station.id),
|
|
391
|
+
month: ctx.usage.thisMonth(station.id),
|
|
392
|
+
history: ctx.usage.history(station.id),
|
|
393
|
+
limits: ctx.limits,
|
|
394
|
+
alerts: alertsFor(station, ctx.runner.observed(station.id), ctx.runner.downSince(station.id), ctx.usage.thisMonth(station.id), ctx.limits),
|
|
395
|
+
});
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
308
398
|
if (method !== "POST") {
|
|
309
399
|
ctx.json(response, 405, { error: "POST", code: "method_not_allowed" });
|
|
310
400
|
return true;
|
|
311
401
|
}
|
|
312
402
|
if (action === "start") {
|
|
403
|
+
const running = ctx.store.list().filter((one) => one.desired === "running" && one.id !== id).length;
|
|
404
|
+
if (ctx.limits && ctx.limits.runningStations > 0 && running >= ctx.limits.runningStations) {
|
|
405
|
+
ctx.json(response, 409, { error: `this server runs at most ${ctx.limits.runningStations} stations at once; stop one first`, code: "too_many_running" });
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
313
408
|
const started = await ctx.runner.start(id);
|
|
314
409
|
ctx.json(response, 200, { station: ownerView(ctx, started) });
|
|
315
410
|
return true;
|
package/src/station-runner.ts
CHANGED
|
@@ -24,6 +24,11 @@ export interface RunnerDeps {
|
|
|
24
24
|
roots: () => string[];
|
|
25
25
|
log?: (line: string) => void;
|
|
26
26
|
now?: () => number;
|
|
27
|
+
/** Told when a station is restarted after an unexpected end, and when a file will not play. */
|
|
28
|
+
onRestart?: (id: string) => void;
|
|
29
|
+
onFailure?: (id: string) => void;
|
|
30
|
+
/** A file for a break on a radio (false) or TV (true) station, or null for none. */
|
|
31
|
+
breaks?: (video: boolean) => Promise<string | null>;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** Something a listener is connected through, that can be ended. */
|
|
@@ -42,6 +47,8 @@ export class StationRunner {
|
|
|
42
47
|
private starting = new Set<string>();
|
|
43
48
|
private errors = new Map<string, string>();
|
|
44
49
|
private listening = new Map<string, Set<Ending>>();
|
|
50
|
+
/** When a station that should be on went off, for "off the air for N minutes". */
|
|
51
|
+
private down = new Map<string, number>();
|
|
45
52
|
|
|
46
53
|
constructor(private readonly deps: RunnerDeps) {}
|
|
47
54
|
|
|
@@ -76,6 +83,7 @@ export class StationRunner {
|
|
|
76
83
|
/** Wanted off, and taken off. Stays off across restarts. */
|
|
77
84
|
stop(id: string): Station {
|
|
78
85
|
const station = this.deps.store.setDesired(id, "stopped", this.now());
|
|
86
|
+
this.down.delete(id);
|
|
79
87
|
this.cancelRecovery(id);
|
|
80
88
|
this.attempts.delete(id);
|
|
81
89
|
this.errors.delete(id);
|
|
@@ -137,8 +145,12 @@ export class StationRunner {
|
|
|
137
145
|
return now ? this.playable(now) : [];
|
|
138
146
|
},
|
|
139
147
|
excluded: (file) => store.isExcluded(id, file),
|
|
148
|
+
...(station.breaksEvery > 0 && this.deps.breaks
|
|
149
|
+
? { breaks: { everySeconds: station.breaksEvery, next: () => this.deps.breaks!(station.video) } }
|
|
150
|
+
: {}),
|
|
140
151
|
onFailure: ({ file, reason }) => {
|
|
141
152
|
this.errors.set(id, reason);
|
|
153
|
+
this.deps.onFailure?.(id);
|
|
142
154
|
if (store.recordFailure(id, file)) {
|
|
143
155
|
this.log(` Station "${station.name}": left out a file that failed ${EXCLUDE_AFTER} times.`);
|
|
144
156
|
}
|
|
@@ -151,6 +163,7 @@ export class StationRunner {
|
|
|
151
163
|
return false;
|
|
152
164
|
}
|
|
153
165
|
this.startedAt.set(id, this.now());
|
|
166
|
+
this.down.delete(id);
|
|
154
167
|
this.deps.channels.keep(channelId);
|
|
155
168
|
return true;
|
|
156
169
|
} catch (error) {
|
|
@@ -176,6 +189,8 @@ export class StationRunner {
|
|
|
176
189
|
this.errors.delete(id);
|
|
177
190
|
return;
|
|
178
191
|
}
|
|
192
|
+
if (!this.down.has(id)) this.down.set(id, this.now());
|
|
193
|
+
this.deps.onRestart?.(id);
|
|
179
194
|
const ran = this.now() - (this.startedAt.get(id) ?? 0);
|
|
180
195
|
if (ran >= HEALTHY_RUN_MS) this.attempts.delete(id);
|
|
181
196
|
this.errors.set(id, info.error || "the station stopped");
|
|
@@ -211,6 +226,11 @@ export class StationRunner {
|
|
|
211
226
|
return (this.attempts.get(id) ?? 0) <= RECOVERY_DELAYS_MS.length && this.timers.has(id) ? "recovering" : "offline";
|
|
212
227
|
}
|
|
213
228
|
|
|
229
|
+
/** When it went off while wanted, or 0 when it is not off. */
|
|
230
|
+
downSince(id: string): number {
|
|
231
|
+
return this.down.get(id) ?? 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
214
234
|
error(id: string): string {
|
|
215
235
|
return this.errors.get(id) ?? "";
|
|
216
236
|
}
|
package/src/station-usage.ts
CHANGED
|
@@ -13,6 +13,8 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
13
13
|
nixamp station unpublish ID [--private]
|
|
14
14
|
nixamp station link ID [--rotate]
|
|
15
15
|
nixamp station delete ID
|
|
16
|
+
nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
|
|
17
|
+
a station per genre, kept in step with the library (--off stops it)
|
|
16
18
|
|
|
17
19
|
--json machine-readable output
|
|
18
20
|
--url, --key another server, as with \`nixamp admin\`
|
package/src/stations.ts
CHANGED
|
@@ -54,6 +54,12 @@ export interface Station {
|
|
|
54
54
|
listenToken: string;
|
|
55
55
|
/** Audio bitrate of the station's rendition, in kbps. */
|
|
56
56
|
bitrate: number;
|
|
57
|
+
/** A TV station: pictures as well as sound. */
|
|
58
|
+
video: boolean;
|
|
59
|
+
/** A break (advert or ident) at most this often, in seconds; 0 for none. */
|
|
60
|
+
breaksEvery: number;
|
|
61
|
+
/** Made and kept up to date by auto stations (e.g. "genre:audio:rock"); "" for one a person made. */
|
|
62
|
+
auto: string;
|
|
57
63
|
rights: Rights | null;
|
|
58
64
|
/** nixamp.com account that made it, "" when made with the control key. */
|
|
59
65
|
owner: string;
|
|
@@ -131,6 +137,8 @@ export interface StationInput {
|
|
|
131
137
|
shuffle?: unknown;
|
|
132
138
|
repeat?: unknown;
|
|
133
139
|
bitrate?: unknown;
|
|
140
|
+
video?: unknown;
|
|
141
|
+
breaksEvery?: unknown;
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
function text(value: unknown, max: number, field: string): string {
|
|
@@ -168,6 +176,9 @@ export class StationStore {
|
|
|
168
176
|
owner: one.owner ?? "",
|
|
169
177
|
description: one.description ?? "",
|
|
170
178
|
idempotencyKey: one.idempotencyKey ?? "",
|
|
179
|
+
video: one.video ?? false,
|
|
180
|
+
breaksEvery: one.breaksEvery ?? 0,
|
|
181
|
+
auto: one.auto ?? "",
|
|
171
182
|
});
|
|
172
183
|
}
|
|
173
184
|
}
|
|
@@ -217,6 +228,9 @@ export class StationStore {
|
|
|
217
228
|
desired: "stopped",
|
|
218
229
|
listenToken: newToken(),
|
|
219
230
|
bitrate: this.bitrateFrom(input.bitrate),
|
|
231
|
+
video: input.video === true,
|
|
232
|
+
breaksEvery: this.breaksFrom(input.breaksEvery),
|
|
233
|
+
auto: "",
|
|
220
234
|
rights: null,
|
|
221
235
|
owner,
|
|
222
236
|
failures: {},
|
|
@@ -229,6 +243,26 @@ export class StationStore {
|
|
|
229
243
|
return station;
|
|
230
244
|
}
|
|
231
245
|
|
|
246
|
+
private breaksFrom(value: unknown): number {
|
|
247
|
+
if (value === undefined || value === null || value === 0) return 0;
|
|
248
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 120 || value > 86_400) {
|
|
249
|
+
throw new StationError("invalid_breaks", "breaksEvery is seconds between breaks, from 120 to 86400, or 0 for none");
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Mark a station as made by auto stations, so a later sync updates it rather than making another. */
|
|
255
|
+
markAuto(id: string, key: string): Station {
|
|
256
|
+
const station = this.require(id);
|
|
257
|
+
station.auto = key;
|
|
258
|
+
this.save();
|
|
259
|
+
return station;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
byAuto(key: string): Station | undefined {
|
|
263
|
+
return [...this.stations.values()].find((one) => one.auto === key);
|
|
264
|
+
}
|
|
265
|
+
|
|
232
266
|
private bitrateFrom(value: unknown): number {
|
|
233
267
|
if (value === undefined) return DEFAULT_BITRATE;
|
|
234
268
|
if (typeof value !== "number" || !BITRATES.includes(value)) {
|
|
@@ -249,6 +283,7 @@ export class StationStore {
|
|
|
249
283
|
if (input.shuffle !== undefined) station.shuffle = input.shuffle === true;
|
|
250
284
|
if (input.repeat !== undefined) station.repeat = input.repeat !== false;
|
|
251
285
|
if (input.bitrate !== undefined) station.bitrate = this.bitrateFrom(input.bitrate);
|
|
286
|
+
if (input.breaksEvery !== undefined) station.breaksEvery = this.breaksFrom(input.breaksEvery);
|
|
252
287
|
if (items !== null) {
|
|
253
288
|
if (items.length === 0) throw new StationError("empty_station", "a station needs at least one file that plays");
|
|
254
289
|
if (items.length > MAX_ITEMS) throw new StationError("too_many_items", `a station holds at most ${MAX_ITEMS} files`);
|
|
@@ -393,6 +428,8 @@ export interface PublicStation {
|
|
|
393
428
|
description: string;
|
|
394
429
|
visibility: Visibility;
|
|
395
430
|
state: Observed;
|
|
431
|
+
/** Radio or TV. */
|
|
432
|
+
kind: "audio" | "video";
|
|
396
433
|
nowPlaying: { title: string; artist: string; album: string } | null;
|
|
397
434
|
format: { codec: string; bitrateKbps: number; sampleRate: number; channels: number } | null;
|
|
398
435
|
listeners: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e}from"./main-
|
|
1
|
+
import{n as e}from"./main-C1ggd2H7.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
|