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.
- package/README.md +6 -5
- package/backtoschool/dist/assets/{ar-CYu0byMf.js → ar-BYDVUbTZ.js} +1 -1
- package/backtoschool/dist/assets/{cs-BTTM7DeB.js → cs-DsCx7BAy.js} +1 -1
- package/backtoschool/dist/assets/{da-iixezQtd.js → da-5tLM7Wal.js} +1 -1
- package/backtoschool/dist/assets/{de-Sx3acevR.js → de-2Q8k6IKm.js} +1 -1
- package/backtoschool/dist/assets/{es-ByCnw8ja.js → es-CVwFkQ0E.js} +1 -1
- package/backtoschool/dist/assets/{fi-BCeFoeH5.js → fi-D7KuvUvL.js} +1 -1
- package/backtoschool/dist/assets/{fr-Nz5hMzAE.js → fr-CkWg-nDv.js} +1 -1
- package/backtoschool/dist/assets/{hi-x5-IG1rl.js → hi-DayNvhO4.js} +1 -1
- package/backtoschool/dist/assets/{hls-OR5FY6YP-O3qhTXm4.js → hls-OR5FY6YP-D2hwLr-P.js} +1 -1
- package/backtoschool/dist/assets/{hu-QqIkGcMe.js → hu-BqS46UyM.js} +1 -1
- package/backtoschool/dist/assets/{id-OVwO1Uoi.js → id-DTiKw_BS.js} +1 -1
- package/backtoschool/dist/assets/{index-Dgqs6yM_.js → index-EM-0SwBp.js} +3 -3
- package/backtoschool/dist/assets/{it-CmssY5Ut.js → it-B60apeGa.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-RQlLEU1A.js → mpegts-C4v5WI5W.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-Y2FARLFE-y49QH7a5.js → mpegts-Y2FARLFE-D4OTyGyp.js} +1 -1
- package/backtoschool/dist/assets/{nl-VkQxlPku.js → nl-DctlIuWS.js} +1 -1
- package/backtoschool/dist/assets/{ru-Dk8FAbDx.js → ru-CO8g0gfi.js} +1 -1
- package/backtoschool/dist/assets/{sv-CNs1UySx.js → sv-CoFMz-Qg.js} +1 -1
- package/backtoschool/dist/assets/{uk-BDB1YayY.js → uk-C3_GprSP.js} +1 -1
- package/backtoschool/dist/assets/{vi-DnEIKzy7.js → vi-B0RDG4iN.js} +1 -1
- package/backtoschool/dist/assets/{zh-B_atHA4S.js → zh--yO6izcR.js} +1 -1
- package/backtoschool/dist/index.html +3 -3
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +1 -1
- package/dist/ai-label.d.ts +38 -0
- package/dist/ai-label.js +237 -5
- package/dist/ai-text.d.ts +42 -1
- package/dist/ai-text.js +69 -7
- package/dist/event-writer.d.ts +10 -1
- package/dist/event-writer.js +27 -8
- package/dist/label-routes.js +9 -0
- package/dist/library-mcp.d.ts +6 -6
- package/dist/music-hub.d.ts +11 -0
- package/dist/music-hub.js +33 -2
- package/dist/nixamp-link-api.d.ts +5 -1
- package/dist/nixamp-link-api.js +12 -2
- package/dist/nixamp-link-types.d.ts +2 -0
- package/dist/nixamp-link.d.ts +22 -9
- package/dist/nixamp-link.js +75 -58
- package/dist/oauth.d.ts +1 -0
- package/dist/oauth.js +8 -1
- package/dist/profiles.d.ts +1 -1
- package/dist/profiles.js +0 -0
- package/dist/server.js +19 -10
- package/dist/station-cli.js +24 -0
- package/dist/station-mcp.d.ts +6 -6
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +4 -1
- package/dist/universal-footer.d.ts +2 -0
- package/dist/universal-footer.js +39 -0
- package/package.json +7 -5
- package/src/ai-keys.ts +1 -1
- package/src/ai-label.ts +240 -5
- package/src/ai-text.ts +84 -8
- package/src/event-writer.ts +36 -11
- package/src/label-routes.ts +9 -0
- package/src/music-hub.ts +38 -2
- package/src/nixamp-link-api.ts +15 -3
- package/src/nixamp-link-types.ts +2 -0
- package/src/nixamp-link.ts +77 -61
- package/src/oauth.ts +9 -1
- package/src/profiles.ts +0 -0
- package/src/server.ts +19 -10
- package/src/station-cli.ts +23 -0
- package/src/station-usage.ts +4 -1
- package/src/universal-footer.ts +41 -0
- package/web/dist/assets/{ar-CqVLKLkR.js → ar-C5qVRZh9.js} +1 -1
- package/web/dist/assets/{chunk-55GBNX64-BDcCEJUP.js → chunk-NW3WDGEQ-CnyjTRUh.js} +1 -1
- package/web/dist/assets/{cs-DwgnZ10A.js → cs-fajZsJrs.js} +1 -1
- package/web/dist/assets/{da-C6V-fzX4.js → da-fycV1k1N.js} +1 -1
- package/web/dist/assets/{de-CyZ00iAl.js → de-QUzTQ_9X.js} +1 -1
- package/web/dist/assets/{es-jlhhpSVu.js → es-CQu2leAJ.js} +1 -1
- package/web/dist/assets/{fi-o92ZPZq7.js → fi-AgKEsukC.js} +1 -1
- package/web/dist/assets/{fr-BcCMVWa_.js → fr-CRbHnW4y.js} +1 -1
- package/web/dist/assets/{hi-Dbgp5pgN.js → hi-BuU7xy9L.js} +1 -1
- package/web/dist/assets/{hls-OR5FY6YP-_WOdANsC.js → hls-OR5FY6YP-BxZwqFB9.js} +1 -1
- package/web/dist/assets/{hu-CRa0dBhC.js → hu-C40Z-HcJ.js} +1 -1
- package/web/dist/assets/{id-BF7Q2wE4.js → id-2b7626UL.js} +1 -1
- package/web/dist/assets/{it-z9MVvK3u.js → it-7sr3nJuS.js} +1 -1
- package/web/dist/assets/main-zuI3sOLC.js +33 -0
- package/web/dist/assets/{mpegts-CL9RC5Ls.js → mpegts-DPRtdBXL.js} +1 -1
- package/web/dist/assets/{mpegts-Y2FARLFE-C91SD8Pl.js → mpegts-Y2FARLFE-BY7parIQ.js} +1 -1
- package/web/dist/assets/{nl-C-9KQwX0.js → nl-BdDGgfzj.js} +1 -1
- package/web/dist/assets/{radio-BnQ78a-O.js → radio-CeRhNjpU.js} +1 -1
- package/web/dist/assets/{ru-D835Y1dr.js → ru-BMGI8Oa4.js} +1 -1
- package/web/dist/assets/{station-C_6L4CDU.js → station-D5tW9jq4.js} +1 -1
- package/web/dist/assets/station-D6KfgFXf.css +1 -0
- package/web/dist/assets/{sv-DLSLSdjL.js → sv-BXASdl7c.js} +1 -1
- package/web/dist/assets/{uk-DgCBRDrZ.js → uk-urnhuqtG.js} +1 -1
- package/web/dist/assets/{vi-C23GbtRZ.js → vi-BhLPAKuD.js} +1 -1
- package/web/dist/assets/{vod-CbSC5NFA.js → vod-Dt73bt8P.js} +1 -1
- package/web/dist/assets/{watch-D-ZRxfJ_.js → watch-GxgHBQcI.js} +1 -1
- package/web/dist/assets/{zh-D0dXViuL.js → zh-BaOCIB9u.js} +1 -1
- package/web/dist/index.html +58 -12
- package/web/dist/music.html +1 -1
- package/web/dist/radio.html +5 -5
- package/web/dist/station.html +4 -4
- package/web/dist/sw.js +30 -30
- package/web/dist/vod.html +4 -4
- package/web/dist/watch.html +4 -4
- package/dist/pit-footer.d.ts +0 -3
- package/dist/pit-footer.js +0 -40
- package/src/pit-footer.ts +0 -40
- package/web/dist/assets/main-Q8gHrr9u.js +0 -26
- package/web/dist/assets/station-CxJvFwzV.css +0 -1
- /package/web/dist/assets/{chunk-55GBNX64-DR1XNWCJ.css → chunk-NW3WDGEQ-DR1XNWCJ.css} +0 -0
package/src/nixamp-link-api.ts
CHANGED
|
@@ -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 (
|
|
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 === "") {
|
|
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;
|
package/src/nixamp-link-types.ts
CHANGED
|
@@ -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;
|
package/src/nixamp-link.ts
CHANGED
|
@@ -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
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
44
|
-
|
|
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(() =>
|
|
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
|
|
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
|
|
197
|
-
VALUES ($1, $2, $3, $4
|
|
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,
|
|
200
|
-
|
|
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
|
-
|
|
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,
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
const
|
|
260
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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" ?
|
|
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" ?
|
|
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
|
-
//
|
|
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 =
|
|
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) => {
|
package/src/station-cli.ts
CHANGED
|
@@ -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":
|
package/src/station-usage.ts
CHANGED
|
@@ -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-
|
|
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};
|