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.
Files changed (73) hide show
  1. package/dist/auto-stations.d.ts +66 -0
  2. package/dist/auto-stations.js +108 -0
  3. package/dist/channels.d.ts +1 -1
  4. package/dist/library-index.d.ts +2 -0
  5. package/dist/library-index.js +8 -0
  6. package/dist/library.d.ts +2 -0
  7. package/dist/library.js +1 -0
  8. package/dist/playlist-input.d.ts +9 -0
  9. package/dist/playlist-input.js +30 -8
  10. package/dist/server.d.ts +7 -0
  11. package/dist/server.js +102 -1
  12. package/dist/station-breaks.d.ts +19 -0
  13. package/dist/station-breaks.js +92 -0
  14. package/dist/station-cli.js +22 -0
  15. package/dist/station-guide.d.ts +4 -0
  16. package/dist/station-guide.js +86 -0
  17. package/dist/station-mcp.d.ts +99 -0
  18. package/dist/station-mcp.js +16 -0
  19. package/dist/station-metering.d.ts +59 -0
  20. package/dist/station-metering.js +189 -0
  21. package/dist/station-page.d.ts +1 -1
  22. package/dist/station-page.js +20 -5
  23. package/dist/station-routes.d.ts +13 -0
  24. package/dist/station-routes.js +90 -3
  25. package/dist/station-runner.d.ts +9 -0
  26. package/dist/station-runner.js +15 -0
  27. package/dist/station-usage.d.ts +1 -1
  28. package/dist/station-usage.js +2 -0
  29. package/dist/stations.d.ts +14 -0
  30. package/dist/stations.js +26 -0
  31. package/package.json +1 -1
  32. package/src/auto-stations.ts +171 -0
  33. package/src/channels.ts +1 -1
  34. package/src/library-index.ts +5 -0
  35. package/src/library.ts +3 -0
  36. package/src/playlist-input.ts +31 -8
  37. package/src/server.ts +106 -2
  38. package/src/station-breaks.ts +93 -0
  39. package/src/station-cli.ts +21 -0
  40. package/src/station-guide.ts +92 -0
  41. package/src/station-mcp.ts +16 -0
  42. package/src/station-metering.ts +218 -0
  43. package/src/station-page.ts +20 -5
  44. package/src/station-routes.ts +98 -3
  45. package/src/station-runner.ts +20 -0
  46. package/src/station-usage.ts +2 -0
  47. package/src/stations.ts +37 -0
  48. package/web/dist/assets/{ar-DoW0wbjC.js → ar-Ck-FmDpQ.js} +1 -1
  49. package/web/dist/assets/{cs-CjKqCjy5.js → cs-CPJY3K69.js} +1 -1
  50. package/web/dist/assets/{da-BQERb7Qz.js → da-BnWtLL09.js} +1 -1
  51. package/web/dist/assets/{de-CKKbRy6c.js → de-CHevIt93.js} +1 -1
  52. package/web/dist/assets/{es-CL3v5Og9.js → es-B6wpaUMv.js} +1 -1
  53. package/web/dist/assets/{fi-DgV81qNU.js → fi-8fKiz2AA.js} +1 -1
  54. package/web/dist/assets/{fr-BGumytzF.js → fr-0GgY52Sm.js} +1 -1
  55. package/web/dist/assets/{hi-CAQ9elqo.js → hi-BlsrUAw-.js} +1 -1
  56. package/web/dist/assets/{hls-3VKVEQE3-Ceri0flt.js → hls-3VKVEQE3-BZd3zjlr.js} +1 -1
  57. package/web/dist/assets/{hu-UJNgJVWX.js → hu-D3i_6i5w.js} +1 -1
  58. package/web/dist/assets/{id-BqQOlrF-.js → id-CLLAXbOP.js} +1 -1
  59. package/web/dist/assets/{it-Cs8G6mvW.js → it-BU13BsSn.js} +1 -1
  60. package/web/dist/assets/{main-Y1Rvn1GX.js → main-C1ggd2H7.js} +2 -2
  61. package/web/dist/assets/{mpegts-jS_Diizz.js → mpegts-BaoQnMCf.js} +1 -1
  62. package/web/dist/assets/{mpegts-LO6RVLD6-CPI8xaZ-.js → mpegts-LO6RVLD6-YBT-W6xc.js} +1 -1
  63. package/web/dist/assets/{nl-BJXQ9nqo.js → nl-DEshyyt7.js} +1 -1
  64. package/web/dist/assets/radio-BpH1qMMQ.js +1 -0
  65. package/web/dist/assets/{ru-XmVkvNY8.js → ru-D7sPOc6m.js} +1 -1
  66. package/web/dist/assets/{sv-pVm9p66e.js → sv-sdrsKAiG.js} +1 -1
  67. package/web/dist/assets/{uk-4hcyaD3e.js → uk-_lz6hkvo.js} +1 -1
  68. package/web/dist/assets/{vi-B7MipBlv.js → vi-D81wl7uk.js} +1 -1
  69. package/web/dist/assets/{zh-B5YVExOW.js → zh-BqMqEom6.js} +1 -1
  70. package/web/dist/index.html +1 -1
  71. package/web/dist/radio.html +1 -1
  72. package/web/dist/sw.js +23 -23
  73. 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
+ }
@@ -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
- const audio = new Audio();
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
- audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
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">&nbsp;</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>
@@ -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
- format: state === "stopped" || state === "offline" ? null : { codec: "mp3", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 },
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
- const detach = ctx.channels.listen(channelId, response);
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;
@@ -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
  }
@@ -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-Y1Rvn1GX.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};
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};