nixamp 0.28.1 → 0.28.3
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 +2 -2
- package/dist/directory.d.ts +2 -0
- package/dist/directory.js +3 -0
- package/dist/owner.js +4 -0
- package/dist/server.js +85 -8
- package/dist/translation-passes.d.ts +2 -7
- package/dist/translation-passes.js +2 -3
- package/dist/upgrade-allowance.d.ts +1 -1
- package/dist/upgrade-allowance.js +6 -3
- package/package.json +1 -1
- package/src/directory.ts +5 -0
- package/src/owner.ts +2 -0
- package/src/server.ts +76 -8
- package/src/translation-passes.ts +2 -3
- package/src/upgrade-allowance.ts +6 -3
- package/web/dist/assets/{hls-3VKVEQE3-DQXeuMAN.js → hls-3VKVEQE3-B4Sk5BbY.js} +1 -1
- package/web/dist/assets/index-B33hHbmF.js +3 -0
- package/web/dist/assets/{mpegts-BRcpX4rU.js → mpegts-CoCC1wqt.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-tqmB8BfI.js → mpegts-LO6RVLD6-Ct0Biz5E.js} +1 -1
- package/web/dist/index.html +14 -4
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-HDrZHpqb.js +0 -3
package/README.md
CHANGED
|
@@ -574,7 +574,7 @@ uses its direct model; it does not first translate the audio into English.
|
|
|
574
574
|
|
|
575
575
|
### Hear it in your language
|
|
576
576
|
|
|
577
|
-
Every signed-in account gets **
|
|
577
|
+
Every signed-in account gets **ten free live-use sessions per UTC day**, shared
|
|
578
578
|
across Nixamp's paid panels and upgrades. There is no timed cutoff: a session
|
|
579
579
|
continues while its listener remains, within the existing API usage limits.
|
|
580
580
|
Reconnects to the same session within 90 seconds reuse it. A stopped session
|
|
@@ -614,7 +614,7 @@ multiplied by five. Credentials and balances never travel in checkout URLs.
|
|
|
614
614
|
New checkout creation is capped at five per account and fifty per account server
|
|
615
615
|
per UTC day, plus IP and request throttles; retries reuse the original invoice.
|
|
616
616
|
|
|
617
|
-
After the
|
|
617
|
+
After the ten free sessions, new sessions require purchased credit. Configure `COINPAY_X402_KEY`
|
|
618
618
|
with `payments:create` permission and at least one business wallet; the scoped
|
|
619
619
|
key supplies the merchant identity. Free sessions and existing credit still work during a
|
|
620
620
|
checkout outage. A self-hosted operator explicitly sponsoring API usage may set
|
package/dist/directory.d.ts
CHANGED
|
@@ -141,6 +141,8 @@ export interface Announcement {
|
|
|
141
141
|
export interface LineupEntry {
|
|
142
142
|
id: string;
|
|
143
143
|
name: string;
|
|
144
|
+
/** Account that put this channel on the air, when known. */
|
|
145
|
+
startedBy?: string;
|
|
144
146
|
kind: "audio" | "video";
|
|
145
147
|
art: string;
|
|
146
148
|
about: string;
|
package/dist/directory.js
CHANGED
|
@@ -112,6 +112,9 @@ export function parseLineupEntry(input) {
|
|
|
112
112
|
return {
|
|
113
113
|
id,
|
|
114
114
|
name,
|
|
115
|
+
...(typeof record["startedBy"] === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(record["startedBy"])
|
|
116
|
+
? { startedBy: record["startedBy"] }
|
|
117
|
+
: {}),
|
|
115
118
|
kind: record["kind"] === "video" ? "video" : "audio",
|
|
116
119
|
// A picture is somewhere a crawler can go, or nothing: a data: URL is a
|
|
117
120
|
// page in a tag, and javascript: is a page in a tag that runs.
|
package/dist/owner.js
CHANGED
|
@@ -132,10 +132,14 @@ export function needsAdmin(path, method = "GET") {
|
|
|
132
132
|
export function needsMember(path, method = "GET") {
|
|
133
133
|
if (method === "POST" && /^\/api\/tracks\/\d+\/live$/.test(path))
|
|
134
134
|
return true;
|
|
135
|
+
if (method === "POST" && path === "/api/folders/live")
|
|
136
|
+
return true;
|
|
135
137
|
if (method === "POST" && /^\/api\/catalogs\/[^/]+\/entries\/[^/]+\/live$/.test(path))
|
|
136
138
|
return true;
|
|
137
139
|
if (method === "POST" && /^\/api\/channels\/[^/]+\/keep$/.test(path))
|
|
138
140
|
return true;
|
|
141
|
+
if (method === "POST" && /^\/api\/channels\/[^/]+\/(?:start|stop|restart)$/.test(path))
|
|
142
|
+
return true;
|
|
139
143
|
// A link -- a YouTube page, an IPTV feed, a file somewhere -- is the one
|
|
140
144
|
// thing a member can go live with that needs nothing on the server first,
|
|
141
145
|
// and it was the one thing they could not. Going live with one is the same
|
package/dist/server.js
CHANGED
|
@@ -1009,6 +1009,7 @@ export function lineupEntry(listenLink, info) {
|
|
|
1009
1009
|
return {
|
|
1010
1010
|
id: info.id,
|
|
1011
1011
|
name: info.name,
|
|
1012
|
+
...(info.startedBy ? { startedBy: info.startedBy } : {}),
|
|
1012
1013
|
kind: info.kind ?? "audio",
|
|
1013
1014
|
art: absoluteArtOf(listenLink, info),
|
|
1014
1015
|
about: info.about ?? "",
|
|
@@ -3480,14 +3481,18 @@ export function createHandler(engine, options) {
|
|
|
3480
3481
|
return;
|
|
3481
3482
|
}
|
|
3482
3483
|
if (!check.allowed) {
|
|
3483
|
-
|
|
3484
|
+
const ownedChannel = channelId === "" ? "" : options.channels?.info(channelId)?.startedBy ?? "";
|
|
3485
|
+
const memberOwnsChannel = member !== "" && ownedChannel === member;
|
|
3486
|
+
if (member !== "" && (needsMember(path, request.method ?? "GET") || memberOwnsChannel)) {
|
|
3484
3487
|
// Metered per member, before anything is done: going live is a
|
|
3485
3488
|
// decoder on this machine, and a script could ask for one a
|
|
3486
3489
|
// second. Three a minute is a person; more is not.
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3490
|
+
if (needsMember(path, request.method ?? "GET")) {
|
|
3491
|
+
const refused = await memberThrottle.handle(requestFor(request));
|
|
3492
|
+
if (refused) {
|
|
3493
|
+
await answerWith(response, refused);
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3491
3496
|
}
|
|
3492
3497
|
liveBy = member;
|
|
3493
3498
|
}
|
|
@@ -3564,7 +3569,15 @@ export function createHandler(engine, options) {
|
|
|
3564
3569
|
redials: one.redials ?? 0,
|
|
3565
3570
|
error: one.error ?? "",
|
|
3566
3571
|
// For a list: how long it is, and which entry is on, from 0.
|
|
3567
|
-
...(one.playlist ? {
|
|
3572
|
+
...(one.playlist ? {
|
|
3573
|
+
entries: one.playlist.length,
|
|
3574
|
+
entry: one.playlistAt ?? 0,
|
|
3575
|
+
playlist: one.playlist.map((source) => {
|
|
3576
|
+
const parts = source.split(/[\\/]/).filter(Boolean);
|
|
3577
|
+
return parts.length > 1 ? parts.slice(-2).join(" / ") : (parts[0] || "Untitled");
|
|
3578
|
+
}),
|
|
3579
|
+
live: one.live !== false,
|
|
3580
|
+
} : {}),
|
|
3568
3581
|
// The member who put it on, when one did: theirs to take off.
|
|
3569
3582
|
startedBy: one.startedBy ?? "",
|
|
3570
3583
|
// A picture of it, and a line about it, where there is one.
|
|
@@ -4027,6 +4040,62 @@ export function createHandler(engine, options) {
|
|
|
4027
4040
|
* do and which made two files at once impossible for anybody. The owner
|
|
4028
4041
|
* and any member may; the caps and the throttle above say how much.
|
|
4029
4042
|
*/
|
|
4043
|
+
if (path === "/api/folders/live" && request.method === "POST") {
|
|
4044
|
+
if (!options.channels) {
|
|
4045
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
4048
|
+
let body = {};
|
|
4049
|
+
try {
|
|
4050
|
+
body = JSON.parse(await readBody(request));
|
|
4051
|
+
}
|
|
4052
|
+
catch {
|
|
4053
|
+
json(response, 400, { error: "bad JSON" });
|
|
4054
|
+
return;
|
|
4055
|
+
}
|
|
4056
|
+
const indices = Array.isArray(body.indices)
|
|
4057
|
+
? [...new Set(body.indices.filter((one) => Number.isInteger(one) && one >= 0))]
|
|
4058
|
+
: [];
|
|
4059
|
+
if (indices.length === 0 || indices.length > 10_000) {
|
|
4060
|
+
json(response, 400, { error: "choose a folder with playable files" });
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
const tracks = engine.snapshot().tracks ?? [];
|
|
4064
|
+
const selected = indices
|
|
4065
|
+
.map((index) => ({ index, track: tracks[index], source: engine.trackPath(index) }))
|
|
4066
|
+
.filter((one) => Boolean(one.track && one.source && !isRemote(one.source)));
|
|
4067
|
+
if (selected.length !== indices.length) {
|
|
4068
|
+
json(response, 404, { error: "one or more folder files are no longer available" });
|
|
4069
|
+
return;
|
|
4070
|
+
}
|
|
4071
|
+
selected.sort((a, b) => String(a.track.title ?? a.source).localeCompare(String(b.track.title ?? b.source), undefined, { numeric: true, sensitivity: "base" }));
|
|
4072
|
+
const sources = selected.map((one) => one.source);
|
|
4073
|
+
const channelId = cleanId(`folder-${sha("sha1").update(sources.join("\n")).digest("hex").slice(0, 12)}`);
|
|
4074
|
+
const name = typeof body.name === "string" && body.name.trim() !== ""
|
|
4075
|
+
? body.name.trim().slice(0, 120)
|
|
4076
|
+
: selected[0]?.track.folder || "folder";
|
|
4077
|
+
if (liveBy && !options.channels.has(channelId)) {
|
|
4078
|
+
const refusal = memberLiveRefusal(options.channels, liveBy);
|
|
4079
|
+
if (refusal) {
|
|
4080
|
+
json(response, 429, { error: refusal });
|
|
4081
|
+
return;
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
if (!options.channels.has(channelId)) {
|
|
4085
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, name, sources[0], [], "", { playlist: sources, live: true });
|
|
4086
|
+
if (!started) {
|
|
4087
|
+
json(response, 409, { error: "that folder is already going on the air" });
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
4090
|
+
if (liveBy)
|
|
4091
|
+
options.channels.info(channelId).startedBy = liveBy;
|
|
4092
|
+
}
|
|
4093
|
+
options.channels.keep(channelId);
|
|
4094
|
+
options.rememberChannels?.(rememberedNow(options.channels));
|
|
4095
|
+
void options.live?.announce?.();
|
|
4096
|
+
json(response, 200, { channel: channelId, name, video: options.channels.kindOf(channelId) !== "audio" });
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
4030
4099
|
const trackLive = /^\/api\/tracks\/(\d+)\/live$/.exec(path);
|
|
4031
4100
|
if (trackLive && request.method === "POST") {
|
|
4032
4101
|
if (!options.channels) {
|
|
@@ -4406,7 +4475,7 @@ export function createHandler(engine, options) {
|
|
|
4406
4475
|
json(response, 200, { ok: true, channel: id, name });
|
|
4407
4476
|
return;
|
|
4408
4477
|
}
|
|
4409
|
-
if (action === undefined && request.method === "DELETE") {
|
|
4478
|
+
if ((action === undefined && request.method === "DELETE") || (action === "stop" && request.method === "POST")) {
|
|
4410
4479
|
// A member takes off what they put on, and nothing else.
|
|
4411
4480
|
if (liveBy && channels.info(id)?.startedBy !== liveBy) {
|
|
4412
4481
|
json(response, 403, { error: "that stream is not yours to take off" });
|
|
@@ -4444,7 +4513,7 @@ export function createHandler(engine, options) {
|
|
|
4444
4513
|
* take it off, put it back -- without having to know the source, which
|
|
4445
4514
|
* a browser is never told.
|
|
4446
4515
|
*/
|
|
4447
|
-
if (action === "restart") {
|
|
4516
|
+
if (action === "start" || action === "restart") {
|
|
4448
4517
|
if (!channels.has(id)) {
|
|
4449
4518
|
json(response, 404, { error: "nothing is playing on that channel" });
|
|
4450
4519
|
return;
|
|
@@ -4453,6 +4522,10 @@ export function createHandler(engine, options) {
|
|
|
4453
4522
|
json(response, 409, { error: "that channel is published into this server; restart it at the publisher" });
|
|
4454
4523
|
return;
|
|
4455
4524
|
}
|
|
4525
|
+
if (liveBy && channels.info(id)?.startedBy !== liveBy) {
|
|
4526
|
+
json(response, 403, { error: "that stream is not yours to restart" });
|
|
4527
|
+
return;
|
|
4528
|
+
}
|
|
4456
4529
|
const restarted = channels.restart(id);
|
|
4457
4530
|
json(response, restarted ? 200 : 409, { ok: restarted });
|
|
4458
4531
|
return;
|
|
@@ -4472,6 +4545,10 @@ export function createHandler(engine, options) {
|
|
|
4472
4545
|
// a member, counted and marked like anything else they put on.
|
|
4473
4546
|
if (liveBy) {
|
|
4474
4547
|
const info = channels.info(id);
|
|
4548
|
+
if (info && info.startedBy && info.startedBy !== liveBy) {
|
|
4549
|
+
json(response, 403, { error: "that stream is not yours to keep" });
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4475
4552
|
if (info && !info.startedBy && !channels.isEphemeral(id)) {
|
|
4476
4553
|
json(response, 403, { error: "that stream is the owner's" });
|
|
4477
4554
|
return;
|
|
@@ -5,17 +5,12 @@ export declare const TRANSLATION_PLANS: readonly [{
|
|
|
5
5
|
readonly id: "day";
|
|
6
6
|
readonly name: "Day";
|
|
7
7
|
readonly days: 1;
|
|
8
|
-
readonly priceCents:
|
|
9
|
-
}, {
|
|
10
|
-
readonly id: "week";
|
|
11
|
-
readonly name: "Week";
|
|
12
|
-
readonly days: 7;
|
|
13
|
-
readonly priceCents: 2500;
|
|
8
|
+
readonly priceCents: 100;
|
|
14
9
|
}, {
|
|
15
10
|
readonly id: "month";
|
|
16
11
|
readonly name: "Month";
|
|
17
12
|
readonly days: 30;
|
|
18
|
-
readonly priceCents:
|
|
13
|
+
readonly priceCents: 500;
|
|
19
14
|
}];
|
|
20
15
|
export type TranslationPlan = typeof TRANSLATION_PLANS[number];
|
|
21
16
|
export type TranslationUsage = "transcription" | "voice";
|
|
@@ -5,9 +5,8 @@ import { SpeechError } from "./speech.js";
|
|
|
5
5
|
import { UpgradeAllowances } from "./upgrade-allowance.js";
|
|
6
6
|
export const TRANSLATION_MULTIPLIER = 5;
|
|
7
7
|
export const TRANSLATION_PLANS = [
|
|
8
|
-
{ id: "day", name: "Day", days: 1, priceCents:
|
|
9
|
-
{ id: "
|
|
10
|
-
{ id: "month", name: "Month", days: 30, priceCents: 10000 },
|
|
8
|
+
{ id: "day", name: "Day", days: 1, priceCents: 100 },
|
|
9
|
+
{ id: "month", name: "Month", days: 30, priceCents: 500 },
|
|
11
10
|
];
|
|
12
11
|
const COINS = ["USDC_POL", "USDC_SOL", "SOL", "POL", "BTC", "ETH", "USDC_ETH", "BCH", "DOGE"];
|
|
13
12
|
const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Claim only on explicit use, never from balance polling or provider chunks. */
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { SpeechError } from "./speech.js";
|
|
5
|
-
export const FREE_UPGRADE_SESSIONS =
|
|
5
|
+
export const FREE_UPGRADE_SESSIONS = 10;
|
|
6
6
|
export const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
7
7
|
const DAY = 86_400_000;
|
|
8
8
|
export class UpgradeAllowances {
|
|
@@ -16,8 +16,11 @@ export class UpgradeAllowances {
|
|
|
16
16
|
async ensure() {
|
|
17
17
|
this.schema ??= this.db.query(`CREATE TABLE IF NOT EXISTS upgrade_daily_uses (
|
|
18
18
|
by_account TEXT NOT NULL, day BIGINT NOT NULL,
|
|
19
|
-
uses INTEGER NOT NULL CHECK (uses
|
|
20
|
-
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`)
|
|
19
|
+
uses INTEGER NOT NULL CHECK (uses >= 1),
|
|
20
|
+
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`)
|
|
21
|
+
.then(() => this.db.query(`ALTER TABLE upgrade_daily_uses DROP CONSTRAINT IF EXISTS upgrade_daily_uses_uses_check`))
|
|
22
|
+
.then(() => this.db.query(`ALTER TABLE upgrade_daily_uses ADD CONSTRAINT upgrade_daily_uses_uses_check CHECK (uses BETWEEN 1 AND 10)`))
|
|
23
|
+
.then(() => undefined)
|
|
21
24
|
.catch(error => { this.schema = null; throw error; });
|
|
22
25
|
await this.schema;
|
|
23
26
|
}
|
package/package.json
CHANGED
package/src/directory.ts
CHANGED
|
@@ -149,6 +149,8 @@ export interface Announcement {
|
|
|
149
149
|
export interface LineupEntry {
|
|
150
150
|
id: string;
|
|
151
151
|
name: string;
|
|
152
|
+
/** Account that put this channel on the air, when known. */
|
|
153
|
+
startedBy?: string;
|
|
152
154
|
kind: "audio" | "video";
|
|
153
155
|
art: string;
|
|
154
156
|
about: string;
|
|
@@ -247,6 +249,9 @@ export function parseLineupEntry(input: unknown): LineupEntry | null {
|
|
|
247
249
|
return {
|
|
248
250
|
id,
|
|
249
251
|
name,
|
|
252
|
+
...(typeof record["startedBy"] === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(record["startedBy"])
|
|
253
|
+
? { startedBy: record["startedBy"] }
|
|
254
|
+
: {}),
|
|
250
255
|
kind: record["kind"] === "video" ? "video" : "audio",
|
|
251
256
|
// A picture is somewhere a crawler can go, or nothing: a data: URL is a
|
|
252
257
|
// page in a tag, and javascript: is a page in a tag that runs.
|
package/src/owner.ts
CHANGED
|
@@ -146,8 +146,10 @@ export function needsAdmin(path: string, method = "GET"): boolean {
|
|
|
146
146
|
*/
|
|
147
147
|
export function needsMember(path: string, method = "GET"): boolean {
|
|
148
148
|
if (method === "POST" && /^\/api\/tracks\/\d+\/live$/.test(path)) return true;
|
|
149
|
+
if (method === "POST" && path === "/api/folders/live") return true;
|
|
149
150
|
if (method === "POST" && /^\/api\/catalogs\/[^/]+\/entries\/[^/]+\/live$/.test(path)) return true;
|
|
150
151
|
if (method === "POST" && /^\/api\/channels\/[^/]+\/keep$/.test(path)) return true;
|
|
152
|
+
if (method === "POST" && /^\/api\/channels\/[^/]+\/(?:start|stop|restart)$/.test(path)) return true;
|
|
151
153
|
// A link -- a YouTube page, an IPTV feed, a file somewhere -- is the one
|
|
152
154
|
// thing a member can go live with that needs nothing on the server first,
|
|
153
155
|
// and it was the one thing they could not. Going live with one is the same
|
package/src/server.ts
CHANGED
|
@@ -1253,6 +1253,7 @@ export function lineupEntry(listenLink: string, info: ChannelInfo): LineupEntry
|
|
|
1253
1253
|
return {
|
|
1254
1254
|
id: info.id,
|
|
1255
1255
|
name: info.name,
|
|
1256
|
+
...(info.startedBy ? { startedBy: info.startedBy } : {}),
|
|
1256
1257
|
kind: info.kind ?? "audio",
|
|
1257
1258
|
art: absoluteArtOf(listenLink, info),
|
|
1258
1259
|
about: info.about ?? "",
|
|
@@ -3926,14 +3927,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3926
3927
|
return;
|
|
3927
3928
|
}
|
|
3928
3929
|
if (!check.allowed) {
|
|
3929
|
-
|
|
3930
|
+
const ownedChannel = channelId === "" ? "" : options.channels?.info(channelId)?.startedBy ?? "";
|
|
3931
|
+
const memberOwnsChannel = member !== "" && ownedChannel === member;
|
|
3932
|
+
if (member !== "" && (needsMember(path, request.method ?? "GET") || memberOwnsChannel)) {
|
|
3930
3933
|
// Metered per member, before anything is done: going live is a
|
|
3931
3934
|
// decoder on this machine, and a script could ask for one a
|
|
3932
3935
|
// second. Three a minute is a person; more is not.
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3936
|
+
if (needsMember(path, request.method ?? "GET")) {
|
|
3937
|
+
const refused = await memberThrottle.handle(requestFor(request));
|
|
3938
|
+
if (refused) {
|
|
3939
|
+
await answerWith(response, refused);
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3937
3942
|
}
|
|
3938
3943
|
liveBy = member;
|
|
3939
3944
|
} else {
|
|
@@ -4010,7 +4015,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4010
4015
|
redials: one.redials ?? 0,
|
|
4011
4016
|
error: one.error ?? "",
|
|
4012
4017
|
// For a list: how long it is, and which entry is on, from 0.
|
|
4013
|
-
...(one.playlist ? {
|
|
4018
|
+
...(one.playlist ? {
|
|
4019
|
+
entries: one.playlist.length,
|
|
4020
|
+
entry: one.playlistAt ?? 0,
|
|
4021
|
+
playlist: one.playlist.map((source) => {
|
|
4022
|
+
const parts = source.split(/[\\/]/).filter(Boolean);
|
|
4023
|
+
return parts.length > 1 ? parts.slice(-2).join(" / ") : (parts[0] || "Untitled");
|
|
4024
|
+
}),
|
|
4025
|
+
live: one.live !== false,
|
|
4026
|
+
} : {}),
|
|
4014
4027
|
// The member who put it on, when one did: theirs to take off.
|
|
4015
4028
|
startedBy: one.startedBy ?? "",
|
|
4016
4029
|
// A picture of it, and a line about it, where there is one.
|
|
@@ -4488,6 +4501,53 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4488
4501
|
* do and which made two files at once impossible for anybody. The owner
|
|
4489
4502
|
* and any member may; the caps and the throttle above say how much.
|
|
4490
4503
|
*/
|
|
4504
|
+
if (path === "/api/folders/live" && request.method === "POST") {
|
|
4505
|
+
if (!options.channels) {
|
|
4506
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
4507
|
+
return;
|
|
4508
|
+
}
|
|
4509
|
+
let body: { indices?: unknown; name?: unknown } = {};
|
|
4510
|
+
try { body = JSON.parse(await readBody(request)) as typeof body; } catch {
|
|
4511
|
+
json(response, 400, { error: "bad JSON" });
|
|
4512
|
+
return;
|
|
4513
|
+
}
|
|
4514
|
+
const indices = Array.isArray(body.indices)
|
|
4515
|
+
? [...new Set(body.indices.filter((one): one is number => Number.isInteger(one) && one >= 0))]
|
|
4516
|
+
: [];
|
|
4517
|
+
if (indices.length === 0 || indices.length > 10_000) {
|
|
4518
|
+
json(response, 400, { error: "choose a folder with playable files" });
|
|
4519
|
+
return;
|
|
4520
|
+
}
|
|
4521
|
+
const tracks = engine.snapshot().tracks ?? [];
|
|
4522
|
+
const selected = indices
|
|
4523
|
+
.map((index) => ({ index, track: tracks[index], source: engine.trackPath(index) }))
|
|
4524
|
+
.filter((one): one is { index: number; track: (typeof tracks)[number]; source: string } => Boolean(one.track && one.source && !isRemote(one.source)));
|
|
4525
|
+
if (selected.length !== indices.length) {
|
|
4526
|
+
json(response, 404, { error: "one or more folder files are no longer available" });
|
|
4527
|
+
return;
|
|
4528
|
+
}
|
|
4529
|
+
selected.sort((a, b) => String(a.track.title ?? a.source).localeCompare(String(b.track.title ?? b.source), undefined, { numeric: true, sensitivity: "base" }));
|
|
4530
|
+
const sources = selected.map((one) => one.source);
|
|
4531
|
+
const channelId = cleanId(`folder-${sha("sha1").update(sources.join("\n")).digest("hex").slice(0, 12)}`);
|
|
4532
|
+
const name = typeof body.name === "string" && body.name.trim() !== ""
|
|
4533
|
+
? body.name.trim().slice(0, 120)
|
|
4534
|
+
: selected[0]?.track.folder || "folder";
|
|
4535
|
+
if (liveBy && !options.channels.has(channelId)) {
|
|
4536
|
+
const refusal = memberLiveRefusal(options.channels, liveBy);
|
|
4537
|
+
if (refusal) { json(response, 429, { error: refusal }); return; }
|
|
4538
|
+
}
|
|
4539
|
+
if (!options.channels.has(channelId)) {
|
|
4540
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, name, sources[0]!, [], "", { playlist: sources, live: true });
|
|
4541
|
+
if (!started) { json(response, 409, { error: "that folder is already going on the air" }); return; }
|
|
4542
|
+
if (liveBy) options.channels.info(channelId)!.startedBy = liveBy;
|
|
4543
|
+
}
|
|
4544
|
+
options.channels.keep(channelId);
|
|
4545
|
+
options.rememberChannels?.(rememberedNow(options.channels));
|
|
4546
|
+
void options.live?.announce?.();
|
|
4547
|
+
json(response, 200, { channel: channelId, name, video: options.channels.kindOf(channelId) !== "audio" });
|
|
4548
|
+
return;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4491
4551
|
const trackLive = /^\/api\/tracks\/(\d+)\/live$/.exec(path);
|
|
4492
4552
|
if (trackLive && request.method === "POST") {
|
|
4493
4553
|
if (!options.channels) {
|
|
@@ -4858,7 +4918,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4858
4918
|
return;
|
|
4859
4919
|
}
|
|
4860
4920
|
|
|
4861
|
-
if (action === undefined && request.method === "DELETE") {
|
|
4921
|
+
if ((action === undefined && request.method === "DELETE") || (action === "stop" && request.method === "POST")) {
|
|
4862
4922
|
// A member takes off what they put on, and nothing else.
|
|
4863
4923
|
if (liveBy && channels.info(id)?.startedBy !== liveBy) {
|
|
4864
4924
|
json(response, 403, { error: "that stream is not yours to take off" });
|
|
@@ -4898,7 +4958,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4898
4958
|
* take it off, put it back -- without having to know the source, which
|
|
4899
4959
|
* a browser is never told.
|
|
4900
4960
|
*/
|
|
4901
|
-
if (action === "restart") {
|
|
4961
|
+
if (action === "start" || action === "restart") {
|
|
4902
4962
|
if (!channels.has(id)) {
|
|
4903
4963
|
json(response, 404, { error: "nothing is playing on that channel" });
|
|
4904
4964
|
return;
|
|
@@ -4907,6 +4967,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4907
4967
|
json(response, 409, { error: "that channel is published into this server; restart it at the publisher" });
|
|
4908
4968
|
return;
|
|
4909
4969
|
}
|
|
4970
|
+
if (liveBy && channels.info(id)?.startedBy !== liveBy) {
|
|
4971
|
+
json(response, 403, { error: "that stream is not yours to restart" });
|
|
4972
|
+
return;
|
|
4973
|
+
}
|
|
4910
4974
|
const restarted = channels.restart(id);
|
|
4911
4975
|
json(response, restarted ? 200 : 409, { ok: restarted });
|
|
4912
4976
|
return;
|
|
@@ -4927,6 +4991,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4927
4991
|
// a member, counted and marked like anything else they put on.
|
|
4928
4992
|
if (liveBy) {
|
|
4929
4993
|
const info = channels.info(id);
|
|
4994
|
+
if (info && info.startedBy && info.startedBy !== liveBy) {
|
|
4995
|
+
json(response, 403, { error: "that stream is not yours to keep" });
|
|
4996
|
+
return;
|
|
4997
|
+
}
|
|
4930
4998
|
if (info && !info.startedBy && !channels.isEphemeral(id)) {
|
|
4931
4999
|
json(response, 403, { error: "that stream is the owner's" });
|
|
4932
5000
|
return;
|
|
@@ -7,9 +7,8 @@ import { UpgradeAllowances, type UpgradeAllowance } from "./upgrade-allowance.ts
|
|
|
7
7
|
|
|
8
8
|
export const TRANSLATION_MULTIPLIER = 5;
|
|
9
9
|
export const TRANSLATION_PLANS = [
|
|
10
|
-
{ id: "day", name: "Day", days: 1, priceCents:
|
|
11
|
-
{ id: "
|
|
12
|
-
{ id: "month", name: "Month", days: 30, priceCents: 10000 },
|
|
10
|
+
{ id: "day", name: "Day", days: 1, priceCents: 100 },
|
|
11
|
+
{ id: "month", name: "Month", days: 30, priceCents: 500 },
|
|
13
12
|
] as const;
|
|
14
13
|
export type TranslationPlan = typeof TRANSLATION_PLANS[number];
|
|
15
14
|
export type TranslationUsage = "transcription" | "voice";
|
package/src/upgrade-allowance.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import type { Queryable } from "./follows.ts";
|
|
5
5
|
import { SpeechError } from "./speech.ts";
|
|
6
6
|
|
|
7
|
-
export const FREE_UPGRADE_SESSIONS =
|
|
7
|
+
export const FREE_UPGRADE_SESSIONS = 10;
|
|
8
8
|
export const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
9
9
|
const DAY = 86_400_000;
|
|
10
10
|
export interface UpgradeAllowance {
|
|
@@ -18,8 +18,11 @@ export class UpgradeAllowances {
|
|
|
18
18
|
async ensure(): Promise<void> {
|
|
19
19
|
this.schema ??= this.db.query(`CREATE TABLE IF NOT EXISTS upgrade_daily_uses (
|
|
20
20
|
by_account TEXT NOT NULL, day BIGINT NOT NULL,
|
|
21
|
-
uses INTEGER NOT NULL CHECK (uses
|
|
22
|
-
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`)
|
|
21
|
+
uses INTEGER NOT NULL CHECK (uses >= 1),
|
|
22
|
+
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`)
|
|
23
|
+
.then(() => this.db.query(`ALTER TABLE upgrade_daily_uses DROP CONSTRAINT IF EXISTS upgrade_daily_uses_uses_check`))
|
|
24
|
+
.then(() => this.db.query(`ALTER TABLE upgrade_daily_uses ADD CONSTRAINT upgrade_daily_uses_uses_check CHECK (uses BETWEEN 1 AND 10)`))
|
|
25
|
+
.then(() => undefined)
|
|
23
26
|
.catch(error => { this.schema = null; throw error; });
|
|
24
27
|
await this.schema;
|
|
25
28
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-B33hHbmF.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|