nixamp 0.78.5 → 0.80.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +6 -5
  2. package/backtoschool/dist/assets/{ar-CYu0byMf.js → ar-BYDVUbTZ.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-BTTM7DeB.js → cs-DsCx7BAy.js} +1 -1
  4. package/backtoschool/dist/assets/{da-iixezQtd.js → da-5tLM7Wal.js} +1 -1
  5. package/backtoschool/dist/assets/{de-Sx3acevR.js → de-2Q8k6IKm.js} +1 -1
  6. package/backtoschool/dist/assets/{es-ByCnw8ja.js → es-CVwFkQ0E.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-BCeFoeH5.js → fi-D7KuvUvL.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-Nz5hMzAE.js → fr-CkWg-nDv.js} +1 -1
  9. package/backtoschool/dist/assets/{hi-x5-IG1rl.js → hi-DayNvhO4.js} +1 -1
  10. package/backtoschool/dist/assets/{hls-OR5FY6YP-O3qhTXm4.js → hls-OR5FY6YP-D2hwLr-P.js} +1 -1
  11. package/backtoschool/dist/assets/{hu-QqIkGcMe.js → hu-BqS46UyM.js} +1 -1
  12. package/backtoschool/dist/assets/{id-OVwO1Uoi.js → id-DTiKw_BS.js} +1 -1
  13. package/backtoschool/dist/assets/{index-Dgqs6yM_.js → index-EM-0SwBp.js} +3 -3
  14. package/backtoschool/dist/assets/{it-CmssY5Ut.js → it-B60apeGa.js} +1 -1
  15. package/backtoschool/dist/assets/{mpegts-RQlLEU1A.js → mpegts-C4v5WI5W.js} +1 -1
  16. package/backtoschool/dist/assets/{mpegts-Y2FARLFE-y49QH7a5.js → mpegts-Y2FARLFE-D4OTyGyp.js} +1 -1
  17. package/backtoschool/dist/assets/{nl-VkQxlPku.js → nl-DctlIuWS.js} +1 -1
  18. package/backtoschool/dist/assets/{ru-Dk8FAbDx.js → ru-CO8g0gfi.js} +1 -1
  19. package/backtoschool/dist/assets/{sv-CNs1UySx.js → sv-CoFMz-Qg.js} +1 -1
  20. package/backtoschool/dist/assets/{uk-BDB1YayY.js → uk-C3_GprSP.js} +1 -1
  21. package/backtoschool/dist/assets/{vi-DnEIKzy7.js → vi-B0RDG4iN.js} +1 -1
  22. package/backtoschool/dist/assets/{zh-B_atHA4S.js → zh--yO6izcR.js} +1 -1
  23. package/backtoschool/dist/index.html +3 -3
  24. package/dist/ai-keys.d.ts +1 -1
  25. package/dist/ai-keys.js +1 -1
  26. package/dist/ai-label.d.ts +38 -0
  27. package/dist/ai-label.js +237 -5
  28. package/dist/ai-text.d.ts +42 -1
  29. package/dist/ai-text.js +69 -7
  30. package/dist/event-writer.d.ts +10 -1
  31. package/dist/event-writer.js +27 -8
  32. package/dist/label-routes.js +9 -0
  33. package/dist/library-mcp.d.ts +6 -6
  34. package/dist/music-hub.d.ts +11 -0
  35. package/dist/music-hub.js +33 -2
  36. package/dist/nixamp-link-api.d.ts +5 -1
  37. package/dist/nixamp-link-api.js +12 -2
  38. package/dist/nixamp-link-types.d.ts +2 -0
  39. package/dist/nixamp-link.d.ts +22 -9
  40. package/dist/nixamp-link.js +75 -58
  41. package/dist/oauth.d.ts +1 -0
  42. package/dist/oauth.js +8 -1
  43. package/dist/profiles.d.ts +1 -1
  44. package/dist/profiles.js +0 -0
  45. package/dist/server.js +19 -10
  46. package/dist/station-cli.js +24 -0
  47. package/dist/station-mcp.d.ts +6 -6
  48. package/dist/station-usage.d.ts +1 -1
  49. package/dist/station-usage.js +4 -1
  50. package/dist/universal-footer.d.ts +2 -0
  51. package/dist/universal-footer.js +39 -0
  52. package/package.json +7 -5
  53. package/src/ai-keys.ts +1 -1
  54. package/src/ai-label.ts +240 -5
  55. package/src/ai-text.ts +84 -8
  56. package/src/event-writer.ts +36 -11
  57. package/src/label-routes.ts +9 -0
  58. package/src/music-hub.ts +38 -2
  59. package/src/nixamp-link-api.ts +15 -3
  60. package/src/nixamp-link-types.ts +2 -0
  61. package/src/nixamp-link.ts +77 -61
  62. package/src/oauth.ts +9 -1
  63. package/src/profiles.ts +0 -0
  64. package/src/server.ts +19 -10
  65. package/src/station-cli.ts +23 -0
  66. package/src/station-usage.ts +4 -1
  67. package/src/universal-footer.ts +41 -0
  68. package/web/dist/assets/{ar-CqVLKLkR.js → ar-C5qVRZh9.js} +1 -1
  69. package/web/dist/assets/{chunk-55GBNX64-BDcCEJUP.js → chunk-NW3WDGEQ-CnyjTRUh.js} +1 -1
  70. package/web/dist/assets/{cs-DwgnZ10A.js → cs-fajZsJrs.js} +1 -1
  71. package/web/dist/assets/{da-C6V-fzX4.js → da-fycV1k1N.js} +1 -1
  72. package/web/dist/assets/{de-CyZ00iAl.js → de-QUzTQ_9X.js} +1 -1
  73. package/web/dist/assets/{es-jlhhpSVu.js → es-CQu2leAJ.js} +1 -1
  74. package/web/dist/assets/{fi-o92ZPZq7.js → fi-AgKEsukC.js} +1 -1
  75. package/web/dist/assets/{fr-BcCMVWa_.js → fr-CRbHnW4y.js} +1 -1
  76. package/web/dist/assets/{hi-Dbgp5pgN.js → hi-BuU7xy9L.js} +1 -1
  77. package/web/dist/assets/{hls-OR5FY6YP-_WOdANsC.js → hls-OR5FY6YP-BxZwqFB9.js} +1 -1
  78. package/web/dist/assets/{hu-CRa0dBhC.js → hu-C40Z-HcJ.js} +1 -1
  79. package/web/dist/assets/{id-BF7Q2wE4.js → id-2b7626UL.js} +1 -1
  80. package/web/dist/assets/{it-z9MVvK3u.js → it-7sr3nJuS.js} +1 -1
  81. package/web/dist/assets/main-zuI3sOLC.js +33 -0
  82. package/web/dist/assets/{mpegts-CL9RC5Ls.js → mpegts-DPRtdBXL.js} +1 -1
  83. package/web/dist/assets/{mpegts-Y2FARLFE-C91SD8Pl.js → mpegts-Y2FARLFE-BY7parIQ.js} +1 -1
  84. package/web/dist/assets/{nl-C-9KQwX0.js → nl-BdDGgfzj.js} +1 -1
  85. package/web/dist/assets/{radio-BnQ78a-O.js → radio-CeRhNjpU.js} +1 -1
  86. package/web/dist/assets/{ru-D835Y1dr.js → ru-BMGI8Oa4.js} +1 -1
  87. package/web/dist/assets/{station-C_6L4CDU.js → station-D5tW9jq4.js} +1 -1
  88. package/web/dist/assets/station-D6KfgFXf.css +1 -0
  89. package/web/dist/assets/{sv-DLSLSdjL.js → sv-BXASdl7c.js} +1 -1
  90. package/web/dist/assets/{uk-DgCBRDrZ.js → uk-urnhuqtG.js} +1 -1
  91. package/web/dist/assets/{vi-C23GbtRZ.js → vi-BhLPAKuD.js} +1 -1
  92. package/web/dist/assets/{vod-CbSC5NFA.js → vod-Dt73bt8P.js} +1 -1
  93. package/web/dist/assets/{watch-D-ZRxfJ_.js → watch-GxgHBQcI.js} +1 -1
  94. package/web/dist/assets/{zh-D0dXViuL.js → zh-BaOCIB9u.js} +1 -1
  95. package/web/dist/index.html +58 -12
  96. package/web/dist/music.html +1 -1
  97. package/web/dist/radio.html +5 -5
  98. package/web/dist/station.html +4 -4
  99. package/web/dist/sw.js +30 -30
  100. package/web/dist/vod.html +4 -4
  101. package/web/dist/watch.html +4 -4
  102. package/dist/pit-footer.d.ts +0 -3
  103. package/dist/pit-footer.js +0 -40
  104. package/src/pit-footer.ts +0 -40
  105. package/web/dist/assets/main-Q8gHrr9u.js +0 -26
  106. package/web/dist/assets/station-CxJvFwzV.css +0 -1
  107. /package/web/dist/assets/{chunk-55GBNX64-DR1XNWCJ.css → chunk-NW3WDGEQ-DR1XNWCJ.css} +0 -0
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * All five want the site's own session: the person, signed in here. What
12
12
  * they hold on nixamp's side is a client token, and it never leaves the
13
- * server -- the page only ever learns a handle and a list of streams.
13
+ * server -- the page only ever learns a handle and a list of streams. The
14
+ * token is kept in memory only, for its hour; never in the database.
14
15
  */
15
16
  import type { IncomingMessage, ServerResponse } from "node:http";
16
17
  import { tokenFrom, type Accounts } from "./accounts.ts";
@@ -77,6 +78,9 @@ function landing(home: string, outcome: string, reason = ""): string {
77
78
  return `${url.pathname}${url.search}${url.hash || "#settings"}`;
78
79
  }
79
80
 
81
+ /** nixamp.com refused the token held in memory. */
82
+ export class StaleToken extends Error {}
83
+
80
84
  /**
81
85
  * Every server the nixamp account remembers, asked what it is doing.
82
86
  *
@@ -92,7 +96,8 @@ export async function streamsFor(token: string, issuer: string, pageSite: string
92
96
  headers: { authorization: `Bearer ${token}`, accept: "application/json" },
93
97
  signal: AbortSignal.timeout(8000),
94
98
  });
95
- if (!answer.ok) throw new Error(answer.status === 401 ? "nixamp.com no longer accepts this connection" : `nixamp.com answered ${answer.status}`);
99
+ if (answer.status === 401) throw new StaleToken("nixamp.com no longer accepts this connection; refresh from nixamp");
100
+ if (!answer.ok) throw new Error(`nixamp.com answered ${answer.status}`);
96
101
  const body = (await answer.json().catch(() => ({}))) as { servers?: typeof servers };
97
102
  servers = Array.isArray(body.servers) ? body.servers : [];
98
103
  const page = pageSite.replace(/\/+$/, "");
@@ -210,10 +215,17 @@ export async function handleNixampLinkApi(
210
215
  // /api/v1/nixamp/streams
211
216
  if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
212
217
  const token = await options.links.accessToken(account.id);
213
- if (token === "") { json(response, 409, { error: "connect your nixamp account first", connected: false }); return true; }
218
+ if (token === "") {
219
+ const { connected } = await options.links.of(account.id);
220
+ json(response, 409, connected
221
+ ? { error: "refresh from nixamp to see your streams", connected: true, refresh: true }
222
+ : { error: "connect your nixamp account first", connected: false });
223
+ return true;
224
+ }
214
225
  try {
215
226
  json(response, 200, { servers: await streamsFor(token, options.issuer, options.issuer, options.fetcher ?? fetch) });
216
227
  } catch (error) {
228
+ if (error instanceof StaleToken) options.links.drop(account.id);
217
229
  json(response, 502, { error: (error as Error).message });
218
230
  }
219
231
  return true;
@@ -6,6 +6,8 @@
6
6
  /** The connection as the account holder sees it. Never the tokens. */
7
7
  export interface LinkView {
8
8
  connected: boolean;
9
+ /** A token is in memory right now, so the streams can be read without asking nixamp.com again. */
10
+ ready: boolean;
9
11
  handle: string;
10
12
  nixampUserId: string;
11
13
  scope: string;
@@ -9,11 +9,17 @@
9
9
  * broadcast backend a host may plug in, and plugging it in is a choice.
10
10
  *
11
11
  * So the school is an OAuth 2.1 client of nixamp.com, the way bittorrented
12
- * is: PKCE, a consent page on nixamp.com, a refresh token kept here against
13
- * the school account, and a grant the person can withdraw from either side.
14
- * What the connection buys is the list of the servers they run on nixamp and
15
- * what is live on them, so "go live" is a pick from a list rather than a
16
- * link pasted from a terminal.
12
+ * is: PKCE and a consent page on nixamp.com. What the connection buys is the
13
+ * list of the servers they run on nixamp and what is live on them, so "go
14
+ * live" is a pick from a list rather than a link pasted from a terminal.
15
+ *
16
+ * No token is kept at rest (fleet rule, 2026-10-28: nothing a user gave us
17
+ * may be readable by root on the box). The row here is the identity link
18
+ * only: which nixamp account, as which handle. The access token lives in
19
+ * this process's memory until it expires (an hour) and is never written to
20
+ * the database; no refresh token is asked for. When it is gone (expired, or
21
+ * the server restarted) the person, who is present to go live, clicks
22
+ * "Refresh from nixamp" and says yes once more on nixamp.com.
17
23
  *
18
24
  * The client half of OAuth is behind an interface, because the server on the
19
25
  * other end is this same program in production and a stub in a test.
@@ -32,16 +38,16 @@ const SCHEMA = `
32
38
  nixamp_user_id TEXT NOT NULL,
33
39
  handle TEXT NOT NULL DEFAULT '',
34
40
  scope TEXT NOT NULL DEFAULT '',
35
- access_token TEXT NOT NULL DEFAULT '',
36
- access_expires_at TIMESTAMPTZ,
37
- refresh_token TEXT NOT NULL DEFAULT '',
38
41
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
39
42
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
40
43
  );
41
44
  `;
42
45
 
43
- /** What the school asks nixamp for: who you are, your servers, and to keep it. */
44
- export const LINK_SCOPE = "profile streams offline_access";
46
+ /**
47
+ * What the school asks nixamp for: who you are and your servers. Not
48
+ * offline_access: a refresh token would have to be kept somewhere.
49
+ */
50
+ export const LINK_SCOPE = "profile streams";
45
51
 
46
52
  /** How long before an access token's end it is treated as spent. */
47
53
  const EARLY_MS = 60_000;
@@ -67,7 +73,7 @@ export interface OAuthExchange {
67
73
  revoke(token: string): Promise<void>;
68
74
  }
69
75
 
70
- export const NOT_CONNECTED: LinkView = { connected: false, handle: "", nixampUserId: "", scope: "", since: null };
76
+ export const NOT_CONNECTED: LinkView = { connected: false, ready: false, handle: "", nixampUserId: "", scope: "", since: null };
71
77
 
72
78
  /** The real thing: nixamp.com over HTTP. */
73
79
  export function nixampExchange(issuer: string, clientId: string, fetcher: typeof fetch = fetch): OAuthExchange {
@@ -134,10 +140,11 @@ function asTime(value: unknown): number | null {
134
140
  return null;
135
141
  }
136
142
 
137
- function view(row: Record<string, unknown> | undefined): LinkView {
143
+ function view(row: Record<string, unknown> | undefined, ready: boolean): LinkView {
138
144
  if (!row) return { ...NOT_CONNECTED };
139
145
  return {
140
146
  connected: true,
147
+ ready,
141
148
  handle: String(row["handle"] ?? ""),
142
149
  nixampUserId: String(row["nixamp_user_id"] ?? ""),
143
150
  scope: String(row["scope"] ?? ""),
@@ -147,6 +154,8 @@ function view(row: Record<string, unknown> | undefined): LinkView {
147
154
 
148
155
  export class NixampLinks {
149
156
  private ready: Promise<void> | null = null;
157
+ /** Access tokens, in memory only, until they end. Never in the database. */
158
+ private readonly live = new Map<string, { token: string; until: number }>();
150
159
 
151
160
  constructor(
152
161
  private readonly db: Queryable,
@@ -158,10 +167,32 @@ export class NixampLinks {
158
167
  ) {}
159
168
 
160
169
  private async ensure(): Promise<void> {
161
- this.ready ??= this.db.query(SCHEMA).then(() => undefined);
170
+ this.ready ??= this.db.query(SCHEMA).then(() => this.forgetLegacyTokens());
162
171
  await this.ready;
163
172
  }
164
173
 
174
+ /**
175
+ * Before 0.79 the tokens were kept in this table in plain text. Hand each
176
+ * refresh token back to nixamp.com (so no grant is left standing there),
177
+ * then drop the columns. The identity link stays; the person clicks
178
+ * "Refresh from nixamp" the next time they go live.
179
+ */
180
+ private async forgetLegacyTokens(): Promise<void> {
181
+ const { rows: columns } = await this.db.query(
182
+ `SELECT column_name FROM information_schema.columns
183
+ WHERE table_schema = current_schema() AND table_name = '${TABLE}' AND column_name IN ('access_token', 'refresh_token', 'access_expires_at')`,
184
+ );
185
+ if (columns.length === 0) return;
186
+ const names = new Set(columns.map((row) => String(row["column_name"])));
187
+ if (names.has("refresh_token")) {
188
+ const { rows } = await this.db.query(`SELECT refresh_token FROM ${TABLE} WHERE refresh_token <> ''`);
189
+ for (const row of rows) await this.exchange.revoke(String(row["refresh_token"] ?? "")).catch(() => undefined);
190
+ }
191
+ await this.db.query(
192
+ `ALTER TABLE ${TABLE} DROP COLUMN IF EXISTS access_token, DROP COLUMN IF EXISTS refresh_token, DROP COLUMN IF EXISTS access_expires_at`,
193
+ );
194
+ }
195
+
165
196
  /**
166
197
  * The first leg: where to send the browser, and the two secrets the
167
198
  * callback must bring back. The state is the CSRF check, the verifier is
@@ -182,7 +213,7 @@ export class NixampLinks {
182
213
  return { url, state, verifier };
183
214
  }
184
215
 
185
- /** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */
216
+ /** The second leg: the code becomes a token, the token says whose; who is kept, the token only in memory. */
186
217
  async finish(userId: string, leg: { code: string; verifier: string; redirectUri: string }): Promise<LinkView> {
187
218
  const grant = await this.exchange.token(new URLSearchParams({
188
219
  grant_type: "authorization_code",
@@ -190,22 +221,20 @@ export class NixampLinks {
190
221
  code_verifier: leg.verifier,
191
222
  redirect_uri: leg.redirectUri,
192
223
  }));
224
+ // Asked for none, but a refresh token sent anyway is handed straight back.
225
+ if (grant.refresh_token) await this.exchange.revoke(grant.refresh_token).catch(() => undefined);
193
226
  const who = await this.exchange.userinfo(grant.access_token);
194
227
  await this.ensure();
195
228
  await this.db.query(
196
- `INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token)
197
- VALUES ($1, $2, $3, $4, $5, $6, $7)
229
+ `INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope)
230
+ VALUES ($1, $2, $3, $4)
198
231
  ON CONFLICT (user_id) DO UPDATE SET nixamp_user_id = EXCLUDED.nixamp_user_id, handle = EXCLUDED.handle,
199
- scope = EXCLUDED.scope, access_token = EXCLUDED.access_token, access_expires_at = EXCLUDED.access_expires_at,
200
- refresh_token = EXCLUDED.refresh_token, updated_at = NOW()`,
201
- [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE, grant.access_token, this.expiry(grant), grant.refresh_token ?? ""],
232
+ scope = EXCLUDED.scope, updated_at = NOW()`,
233
+ [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE],
202
234
  );
203
- return this.of(userId);
204
- }
205
-
206
- private expiry(grant: TokenGrant): Date {
207
235
  const seconds = typeof grant.expires_in === "number" && grant.expires_in > 0 ? grant.expires_in : 3600;
208
- return new Date(this.now() + seconds * 1000);
236
+ this.live.set(userId, { token: grant.access_token, until: this.now() + seconds * 1000 });
237
+ return this.of(userId);
209
238
  }
210
239
 
211
240
  async of(userId: string): Promise<LinkView> {
@@ -214,52 +243,39 @@ export class NixampLinks {
214
243
  `SELECT nixamp_user_id, handle, scope, created_at FROM ${TABLE} WHERE user_id = $1`,
215
244
  [userId],
216
245
  );
217
- return view(rows[0]);
246
+ return view(rows[0], rows[0] !== undefined && this.held(userId) !== "");
247
+ }
248
+
249
+ private held(userId: string): string {
250
+ const kept = this.live.get(userId);
251
+ if (!kept) return "";
252
+ if (kept.until - this.now() > EARLY_MS) return kept.token;
253
+ this.live.delete(userId);
254
+ return "";
218
255
  }
219
256
 
220
257
  /**
221
- * A token good for a call right now, refreshed when the one kept is about
222
- * to end. "" when there is no connection, or the refresh was refused --
223
- * which is what a grant withdrawn on nixamp.com looks like from here, and
224
- * the row is dropped so the settings page says so too.
258
+ * A token good for a call right now, or "" when there is none in memory:
259
+ * not connected, expired, or the server restarted since. The page then
260
+ * offers "Refresh from nixamp".
225
261
  */
226
262
  async accessToken(userId: string): Promise<string> {
227
263
  await this.ensure();
228
- const { rows } = await this.db.query(
229
- `SELECT access_token, access_expires_at, refresh_token FROM ${TABLE} WHERE user_id = $1`,
230
- [userId],
231
- );
232
- const row = rows[0];
233
- if (!row) return "";
234
- const access = String(row["access_token"] ?? "");
235
- const until = asTime(row["access_expires_at"]);
236
- if (access !== "" && until !== null && until - this.now() > EARLY_MS) return access;
237
- const refresh = String(row["refresh_token"] ?? "");
238
- if (refresh === "") return "";
239
- let grant: TokenGrant;
240
- try {
241
- grant = await this.exchange.token(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh }));
242
- } catch {
243
- await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
244
- return "";
245
- }
246
- await this.db.query(
247
- `UPDATE ${TABLE} SET access_token = $2, access_expires_at = $3, refresh_token = $4, updated_at = NOW() WHERE user_id = $1`,
248
- [userId, grant.access_token, this.expiry(grant), grant.refresh_token ?? refresh],
249
- );
250
- return grant.access_token;
264
+ return this.held(userId);
265
+ }
266
+
267
+ /** nixamp.com refused the token: forget it, so the page asks again. */
268
+ drop(userId: string): void {
269
+ this.live.delete(userId);
251
270
  }
252
271
 
253
- /** Withdraw the grant on nixamp.com and forget it here. True when there was one. */
272
+ /** Withdraw the token on nixamp.com and forget the connection here. True when there was one. */
254
273
  async disconnect(userId: string): Promise<boolean> {
255
274
  await this.ensure();
256
- const { rows } = await this.db.query(`SELECT refresh_token, access_token FROM ${TABLE} WHERE user_id = $1`, [userId]);
257
- const row = rows[0];
258
- if (!row) return false;
259
- const refresh = String(row["refresh_token"] ?? "");
260
- const access = String(row["access_token"] ?? "");
261
- await this.exchange.revoke(refresh !== "" ? refresh : access);
262
- await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
263
- return true;
275
+ const token = this.held(userId);
276
+ this.live.delete(userId);
277
+ if (token !== "") await this.exchange.revoke(token).catch(() => undefined);
278
+ const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1 RETURNING user_id`, [userId]);
279
+ return rows.length > 0;
264
280
  }
265
281
  }
package/src/oauth.ts CHANGED
@@ -48,6 +48,7 @@ export interface Identity {
48
48
  * to an existing account and is never the new account's address.
49
49
  */
50
50
  verified?: boolean;
51
+ openprofileUrl?: string;
51
52
  }
52
53
 
53
54
  /**
@@ -73,7 +74,14 @@ export function coinpayProvider(clientId: string, clientSecret: string, base = "
73
74
  // Never verified, whatever the answer says: CoinPay sends no mail to the
74
75
  // address it reports, so anybody can register a CoinPay account under
75
76
  // someone else's address. Its `sub` is the whole identity.
76
- return { provider: "coinpay", subject, email, verified: false };
77
+ return {
78
+ provider: "coinpay",
79
+ subject,
80
+ email,
81
+ verified: false,
82
+ // Only when CoinPay names one: an absent field stays absent, not `undefined`.
83
+ ...(typeof who["openprofile_url"] === "string" ? { openprofileUrl: who["openprofile_url"] } : {}),
84
+ };
77
85
  },
78
86
  };
79
87
  }
package/src/profiles.ts CHANGED
Binary file
package/src/server.ts CHANGED
@@ -47,7 +47,7 @@ import { anonymousHandle, Handles } from "./handles.ts";
47
47
  import { OpenDirs } from "./opendirs.ts";
48
48
  import { Servers } from "./servers.ts";
49
49
  import { AccountProfiles, hostCard, PHOTO_LIMIT } from "./profiles.ts";
50
- import { createProfileHandlers, type ProfileStore, saveSignupProfile, validateProfile } from "@profullstack/profile";
50
+ import { createProfileHandlers, type ProfileStore, saveOpenProfileContact, saveSignupProfile, validateProfile } from "@profullstack/profile";
51
51
  import { SIGNUP_PROFILE_FIELDS, signupProfiles } from "./signup-profiles.ts";
52
52
  import { NixampLinks, nixampExchange } from "./nixamp-link.ts";
53
53
  import { handleNixampLinkApi, nixampLinkPath } from "./nixamp-link-api.ts";
@@ -86,7 +86,7 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
86
86
  import { AiLabel, VeoUnavailable } from "./ai-label.ts";
87
87
  import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
88
88
  import { GrantVerifier, MusicStore, PRICES } from "./music-store.ts";
89
- import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, serverId } from "./music-hub.ts";
89
+ import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, parseMoved, serverId } from "./music-hub.ts";
90
90
  import { fileLedger, pgLedger, VeoBudgetError, VeoStudio } from "./veo-studio.ts";
91
91
  import { KnownServers } from "./known-servers.ts";
92
92
  import { handleStoreRoutes, isStorePath } from "./store-routes.ts";
@@ -102,7 +102,7 @@ import { aiKey } from "./ai-keys.ts";
102
102
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.ts";
103
103
  import { handleRoomProxy, handleRooms, isRoomPath, ROOM_PAGE, roomAddress, RoomProbe, roomEntries, roomSlugOf, roomSlugs, RoomStore, watchBase } from "./guide-rooms.ts";
104
104
  import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.ts";
105
- import { withPitFooter } from "./pit-footer.ts";
105
+ import { withUniversalFooter } from "./universal-footer.ts";
106
106
  import { handleFollowPage } from "./follow-page.ts";
107
107
  import {
108
108
  asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, RADIO_BUY, radioBuyLink, radioBuyQuery, radioNav, radioPeers, radioRewriter,
@@ -138,6 +138,7 @@ import { Durable } from "./durable.ts";
138
138
  import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
139
139
  import { vapidKeysFromEnv, vapidPublicKeyResponse, type VapidKeys } from "@profullstack/notifications/server";
140
140
  import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.ts";
141
+ import { c0mputeKey } from "./ai-text.ts";
141
142
  import { EventWriter } from "./event-writer.ts";
142
143
  import { handleLiveApi } from "./live-api.ts";
143
144
  import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./live-events.ts";
@@ -4991,7 +4992,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4991
4992
  try {
4992
4993
  bytes = await readBytes(request, PHOTO_LIMIT);
4993
4994
  } catch {
4994
- json(response, 413, { error: "a photo may be up to 1 MB" });
4995
+ json(response, 413, { error: "a photo may be up to 10 MB" });
4995
4996
  return;
4996
4997
  }
4997
4998
  const kept = await profiles.setPhoto(who.id, bytes);
@@ -5251,6 +5252,14 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
5251
5252
  return;
5252
5253
  }
5253
5254
 
5255
+ if (identity.provider === "coinpay" && identity.openprofileUrl && options.signupProfiles) {
5256
+ try {
5257
+ await saveOpenProfileContact(options.signupProfiles, String(result.account.id), identity.openprofileUrl);
5258
+ } catch (e) {
5259
+ console.warn("[coinpay] OpenProfile import skipped:", e instanceof Error ? e.message : e);
5260
+ }
5261
+ }
5262
+
5254
5263
  if (pending.userCode) {
5255
5264
  // This round trip was approving a terminal. The browser is finished;
5256
5265
  // the session belongs to whatever is polling.
@@ -7112,7 +7121,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
7112
7121
  // somewhere usable, but it says 404 so it is never indexed.
7113
7122
  const shell = readIfPossible(join(web, "index.html"));
7114
7123
  if (shell !== null) {
7115
- html(response, 404, requestHost === "nixamp.com" ? withPitFooter(shell) : shell);
7124
+ html(response, 404, requestHost === "nixamp.com" ? withUniversalFooter(shell) : shell);
7116
7125
  return;
7117
7126
  }
7118
7127
  }
@@ -7154,17 +7163,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
7154
7163
  }
7155
7164
  const shell = subject ? readIfPossible(file) : null;
7156
7165
  if (subject && shell !== null) {
7157
- html(response, 200, requestHost === "nixamp.com" ? withPitFooter(joinDocument(shell, subject, eventSite ?? "https://nixamp.com")) : joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
7166
+ html(response, 200, requestHost === "nixamp.com" ? withUniversalFooter(joinDocument(shell, subject, eventSite ?? "https://nixamp.com")) : joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
7158
7167
  return;
7159
7168
  }
7160
7169
  }
7161
7170
  // nixamp.com's own pages (the app shell, the radio console) carry the
7162
- // pit.world global footer, appended on the way out. A local install
7171
+ // shared Profullstack footer, appended on the way out. A local install
7163
7172
  // serving the same files on its own host is left alone.
7164
7173
  if (requestHost === "nixamp.com" && file.endsWith(".html") && (request.method === "GET" || request.method === "HEAD")) {
7165
7174
  const shell = readIfPossible(file);
7166
7175
  if (shell !== null) {
7167
- const page = withPitFooter(shell);
7176
+ const page = withUniversalFooter(shell);
7168
7177
  response.writeHead(200, {
7169
7178
  ...CORS,
7170
7179
  "content-type": "text/html; charset=utf-8",
@@ -8212,7 +8221,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
8212
8221
  return [...new Set([...guidePeers(), ...listed, ...(knownServers?.list().map((one) => one.base) ?? [])])];
8213
8222
  };
8214
8223
  // nixamp.com/music: every server's label in one catalogue (music-hub.ts).
8215
- const musicHub = options.directory ? new MusicHub({ peers: musicServers }) : undefined;
8224
+ const musicHub = options.directory ? new MusicHub({ peers: musicServers, moved: parseMoved(process.env["NIXAMP_MUSIC_MOVED"]) }) : undefined;
8216
8225
  const musicStore = supportPayments && supportPayments.enabled && pool && jwtSecret ? new MusicStore({
8217
8226
  db: pool as unknown as import("./support.ts").Db,
8218
8227
  support: supportPayments,
@@ -8882,7 +8891,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
8882
8891
  ...(favorites ? { favorites } : {}),
8883
8892
  ...(settingsSync ? { settingsSync } : {}),
8884
8893
  ...(events ? { events, eventWriter: new EventWriter({
8885
- openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
8894
+ c0mputeKey: c0mputeKey(), openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
8886
8895
  openaiModel: process.env["NIXAMP_WRITER_OPENAI_MODEL"], claudeModel: process.env["NIXAMP_WRITER_CLAUDE_MODEL"],
8887
8896
  }) } : {}),
8888
8897
  ...(events && follows ? { onEventUpdated: (updated: LiveEvent, previous: LiveEvent) => {
@@ -518,6 +518,29 @@ async function labelCommand(argv: string[], call: Call, show: Show): Promise<num
518
518
  return `Label: ${String(settings["holder"])}, ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s an album, every ${String(settings["everyDays"])} days, at most ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : `${String(settings["monthlySeconds"])} s`} a month${settings["enabled"] ? "" : " (off)"}.`;
519
519
  });
520
520
  }
521
+ case "adopt": {
522
+ // Albums already under the label's folder (copied from another server) back on the label.
523
+ // --keys FILE: {"artists": {"<name>": "<old key>"}, "albums": {"<artist key>/<album title>": "<old key>"}}.
524
+ const file = flag(argv, "--keys");
525
+ let keys: Record<string, unknown> = {};
526
+ if (file) {
527
+ try {
528
+ keys = JSON.parse((await import("node:fs")).readFileSync(file, "utf8")) as Record<string, unknown>;
529
+ } catch (error) {
530
+ console.error(`nixamp station label adopt: could not read ${file}: ${(error as Error).message}`);
531
+ return 2;
532
+ }
533
+ }
534
+ const body = { keys: keys["artists"] ?? {}, albums: keys["albums"] ?? {}, ...(has(argv, "--off") ? { enabled: false } : {}) };
535
+ return show(await call("POST", "/api/v1/stations/label/adopt", body), (answer) => {
536
+ const done = answer["adopted"] as Record<string, unknown>;
537
+ const skipped = (done["skipped"] as string[] | undefined) ?? [];
538
+ return [
539
+ `Adopted ${String(done["artists"])} artists, ${String(done["albums"])} albums, ${String(done["songs"])} songs and ${String(done["videos"])} filmed videos; ${String(done["oldKeys"])} artists kept their old keys.`,
540
+ ...skipped.map((one) => ` skipped ${one}`),
541
+ ].join("\n");
542
+ });
543
+ }
521
544
  case "run":
522
545
  return show(await call("POST", "/api/v1/stations/label/run", {}), (body) => body["waiting"] ? `Nothing to record now: ${String(body["waiting"])}.` : "Recording whatever is due.");
523
546
  case "resume":
@@ -32,7 +32,7 @@ export const USAGE = `nixamp station — make a collection into a station
32
32
  nixamp station ai remove KEY [--files]
33
33
  nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)
34
34
  nixamp station ai resume after the provider ran out of credit
35
- nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,
35
+ nixamp station ai key NAME ELEVENLABS_API_KEY | C0MPUTE_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,
36
36
  NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in),
37
37
  NIXAMP_MUSIC_MODEL (music_v1|music_v2|music_v2_5), from stdin
38
38
 
@@ -51,6 +51,9 @@ export const USAGE = `nixamp station — make a collection into a station
51
51
  [--videos|--no-videos] a music video per song and a TV station of them
52
52
  [--reset-day 21] the day the monthly budget renews (the provider's billing day)
53
53
  [--veo|--no-veo] [--veo-shots 4] filmed Veo shots of the band in each album's single (nixamp.com caps the month)
54
+ nixamp station label adopt [--keys FILE] [--off] albums already in the label's folder (copied from another
55
+ server) back on the label; FILE keeps old keys: {"artists": {NAME: KEY},
56
+ "albums": {"ARTISTKEY/TITLE": KEY}}
54
57
  nixamp station label run record whatever is due now
55
58
  nixamp station label resume after the provider ran out of credit
56
59
 
@@ -0,0 +1,41 @@
1
+ import { footerHtml, footerHtmlSync } from "@profullstack/footer";
2
+
3
+ const OPTIONS = {
4
+ site: "https://nixamp.com/",
5
+ since: 2026,
6
+ tagline: "MIT License",
7
+ links: [
8
+ { label: "Code", href: "https://github.com/profullstack/nixamp" },
9
+ { label: "r/nixamp", href: "https://www.reddit.com/r/nixamp" },
10
+ ],
11
+ builtIndieHref: "https://built.indie.pit.world/for?site=https://nixamp.com/",
12
+ pwamartHref: "https://pwamart.com/for?site=https://nixamp.com/",
13
+ };
14
+
15
+ const REFRESH_MS = 15 * 60 * 1000;
16
+ let html = footerHtmlSync(OPTIONS);
17
+ let fetchedAt = 0;
18
+ let inflight: Promise<void> | null = null;
19
+
20
+ function refresh(): Promise<void> {
21
+ inflight ??= footerHtml(OPTIONS)
22
+ .then((value) => {
23
+ html = value;
24
+ fetchedAt = Date.now();
25
+ })
26
+ .catch(() => {
27
+ fetchedAt = Date.now() - REFRESH_MS + 60_000;
28
+ })
29
+ .finally(() => {
30
+ inflight = null;
31
+ });
32
+ return inflight;
33
+ }
34
+
35
+ /** Add the shared footer to Nixamp pages while avoiding a second copy in its app shell. */
36
+ export function withUniversalFooter(page: string): string {
37
+ if (Date.now() - fetchedAt > REFRESH_MS) void refresh();
38
+ if (page.includes("data-pfs-footer=")) return page;
39
+ const at = page.lastIndexOf("</body>");
40
+ return at < 0 ? page : page.slice(0, at) + html + page.slice(at);
41
+ }
@@ -1 +1 @@
1
- import{t as e}from"./main-Q8gHrr9u.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{t as e}from"./main-zuI3sOLC.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};