nixamp 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels.d.ts +12 -0
- package/dist/channels.js +8 -0
- package/dist/library-cli.d.ts +3 -0
- package/dist/library-cli.js +198 -0
- package/dist/library-index.d.ts +190 -0
- package/dist/library-index.js +643 -0
- package/dist/library-mcp.d.ts +230 -0
- package/dist/library-mcp.js +139 -0
- package/dist/library-routes.d.ts +34 -0
- package/dist/library-routes.js +258 -0
- package/dist/library-usage.d.ts +3 -0
- package/dist/library-usage.js +20 -0
- package/dist/library-user.d.ts +67 -0
- package/dist/library-user.js +204 -0
- package/dist/main.js +8 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +10 -1
- package/dist/playlist-input.d.ts +10 -0
- package/dist/playlist-input.js +9 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +83 -12
- package/dist/station-cli.js +4 -3
- package/dist/station-mcp.d.ts +311 -0
- package/dist/station-mcp.js +179 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +2 -1
- package/dist/station-routes.js +2 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +1 -1
- package/package.json +1 -1
- package/src/channels.ts +10 -0
- package/src/library-cli.ts +175 -0
- package/src/library-index.ts +709 -0
- package/src/library-mcp.ts +132 -0
- package/src/library-routes.ts +282 -0
- package/src/library-usage.ts +21 -0
- package/src/library-user.ts +218 -0
- package/src/main.ts +7 -0
- package/src/mcp.ts +12 -1
- package/src/playlist-input.ts +9 -0
- package/src/server.ts +83 -13
- package/src/station-cli.ts +4 -3
- package/src/station-mcp.ts +190 -0
- package/src/station-page.ts +2 -1
- package/src/station-routes.ts +2 -1
- package/src/station-usage.ts +1 -1
- package/web/dist/assets/{ar-CTUDxy1J.js → ar-C_4gIadE.js} +1 -1
- package/web/dist/assets/{cs-m4JTaO3o.js → cs-B18R5adY.js} +1 -1
- package/web/dist/assets/{da-DHnJElYB.js → da-B6w5URQ2.js} +1 -1
- package/web/dist/assets/{de-DkxPohnf.js → de-Dlhr9ikG.js} +1 -1
- package/web/dist/assets/{es-oY_G83d7.js → es-BIpArFOC.js} +1 -1
- package/web/dist/assets/{fi-fCO8XRdg.js → fi-o9W4XS3X.js} +1 -1
- package/web/dist/assets/{fr-CSupf-IN.js → fr-BgVwhPsP.js} +1 -1
- package/web/dist/assets/{hi-Dve7PJsC.js → hi-zIXqrXNC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D5-AkKSR.js → hls-3VKVEQE3-5pAjDDLr.js} +1 -1
- package/web/dist/assets/{hu-D1KYamzV.js → hu-Dpn_DIRi.js} +1 -1
- package/web/dist/assets/{id-Cofp_4aQ.js → id-Cu1V6gAm.js} +1 -1
- package/web/dist/assets/{index-7mCQZGNF.js → index-D-PZ4nGC.js} +2 -2
- package/web/dist/assets/{it-62iXQWQD.js → it-BNcoJEYQ.js} +1 -1
- package/web/dist/assets/{mpegts-BT5ESacM.js → mpegts-CabjhxTI.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-BgHd8PFT.js → mpegts-LO6RVLD6-CRSxiR1J.js} +1 -1
- package/web/dist/assets/{nl-CzRRG-CN.js → nl-gvivlDEE.js} +1 -1
- package/web/dist/assets/{ru-OykC1LJQ.js → ru-BxQnVrHu.js} +1 -1
- package/web/dist/assets/{sv-B8FXBDHm.js → sv-BP1JhAR8.js} +1 -1
- package/web/dist/assets/{uk-DhAJBjwH.js → uk-ChYhgzlf.js} +1 -1
- package/web/dist/assets/{vi-CdBSRReC.js → vi-CD7vzLwP.js} +1 -1
- package/web/dist/assets/{zh-wGWNyBEZ.js → zh-BmGS-RcE.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
package/src/main.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts"
|
|
|
8
8
|
* ffmpeg decodes; we read every sample on its way to the speakers and draw it.
|
|
9
9
|
*/
|
|
10
10
|
import { USAGE as STATION_USAGE } from "./station-usage.ts";
|
|
11
|
+
import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
|
|
11
12
|
import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
import { resolve } from "node:path";
|
|
@@ -397,6 +398,7 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
|
|
|
397
398
|
|
|
398
399
|
export function helpFor(topic: string | undefined): string {
|
|
399
400
|
if (topic === "station" || topic === "stations") return STATION_USAGE;
|
|
401
|
+
if (topic === "library") return LIBRARY_USAGE;
|
|
400
402
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
401
403
|
}
|
|
402
404
|
|
|
@@ -648,6 +650,11 @@ export async function main(): Promise<void> {
|
|
|
648
650
|
process.exitCode = await syncCommand(rest);
|
|
649
651
|
return;
|
|
650
652
|
}
|
|
653
|
+
if (first === "library" && LIBRARY_COMMANDS.has(rest[0] ?? "")) {
|
|
654
|
+
const { libraryTool } = await import("./library-cli.ts");
|
|
655
|
+
process.exitCode = await libraryTool(rest);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
651
658
|
if (first === "library") {
|
|
652
659
|
const { libraryCommand } = await import("./library.ts");
|
|
653
660
|
process.exitCode = await libraryCommand(rest);
|
package/src/mcp.ts
CHANGED
|
@@ -30,6 +30,8 @@ import { describeRecord } from "./hash.ts";
|
|
|
30
30
|
import { describeFile, keepFile, type Described } from "./media-local.ts";
|
|
31
31
|
import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.ts";
|
|
32
32
|
import { personaLines, readPersona, readVoices, writePersona } from "./profile.ts";
|
|
33
|
+
import { STATION_TOOLS, callStationTool, isStationTool, type StationToolOptions } from "./station-mcp.ts";
|
|
34
|
+
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.ts";
|
|
33
35
|
|
|
34
36
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
35
37
|
|
|
@@ -242,6 +244,8 @@ export const TOOLS: ToolDefinition[] = [
|
|
|
242
244
|
required: ["server"],
|
|
243
245
|
},
|
|
244
246
|
},
|
|
247
|
+
...STATION_TOOLS,
|
|
248
|
+
...LIBRARY_TOOLS,
|
|
245
249
|
];
|
|
246
250
|
|
|
247
251
|
interface TrollboxLine {
|
|
@@ -257,6 +261,8 @@ function said(line: TrollboxLine): string {
|
|
|
257
261
|
|
|
258
262
|
export interface McpOptions {
|
|
259
263
|
fetcher?: typeof fetch;
|
|
264
|
+
/** Where stations are asked; the tests hand in a local daemon. */
|
|
265
|
+
stations?: StationToolOptions;
|
|
260
266
|
/** Injected by the tests; the session on disk otherwise. */
|
|
261
267
|
session?: { site: string; token: string } | null;
|
|
262
268
|
/** How a recording becomes a WAV; the tests hand in a fake. */
|
|
@@ -309,6 +315,11 @@ function describe(row: PartyRow): string {
|
|
|
309
315
|
* pipe, and so the same call is reachable from anywhere else that wants it.
|
|
310
316
|
*/
|
|
311
317
|
export async function callTool(name: string, args: Record<string, unknown>, options: McpOptions = {}): Promise<ToolResult> {
|
|
318
|
+
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
319
|
+
if (isStationTool(name) || isLibraryTool(name)) {
|
|
320
|
+
const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
|
|
321
|
+
return isStationTool(name) ? callStationTool(name, args, local) : callLibraryTool(name, args, local);
|
|
322
|
+
}
|
|
312
323
|
const session = options.session === undefined ? readSession() : options.session;
|
|
313
324
|
if (!session) {
|
|
314
325
|
return failed("This machine is not signed in to nixamp. Run `nixamp login`, or set NIXAMP_TOKEN.");
|
|
@@ -592,7 +603,7 @@ export async function handleMessage(message: Request, options: McpOptions = {}):
|
|
|
592
603
|
return reply({
|
|
593
604
|
protocolVersion: PROTOCOL_VERSION,
|
|
594
605
|
capabilities: { tools: { listChanged: false } },
|
|
595
|
-
serverInfo: { name: "nixamp", title: "nixamp: watch parties, rooms and transcripts", version: "2" },
|
|
606
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
596
607
|
instructions:
|
|
597
608
|
"Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
598
609
|
});
|
package/src/playlist-input.ts
CHANGED
|
@@ -53,6 +53,12 @@ export class PlaylistInputStream {
|
|
|
53
53
|
private entries: { at: number; file: string; start: number; position: number }[] = [];
|
|
54
54
|
error: Error | null = null;
|
|
55
55
|
complete = false;
|
|
56
|
+
/**
|
|
57
|
+
* What each entry's own tags say, read when it was probed to be played --
|
|
58
|
+
* so a station can say what is on before the library has got round to
|
|
59
|
+
* reading the file, which on a big library is a while.
|
|
60
|
+
*/
|
|
61
|
+
readonly tags = new Map<string, { title: string; artist: string; album: string }>();
|
|
56
62
|
constructor(private readonly options: PlaylistInput) {}
|
|
57
63
|
|
|
58
64
|
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
@@ -134,6 +140,9 @@ export class PlaylistInputStream {
|
|
|
134
140
|
const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
|
|
135
141
|
if (signal.aborted) return;
|
|
136
142
|
if (!codecs.audio && !codecs.video) throw new Error(`Cannot decode playlist entry ${at + 1}`);
|
|
143
|
+
if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
|
|
144
|
+
this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
|
|
145
|
+
}
|
|
137
146
|
const duration = (codecs.duration ?? 0) - position;
|
|
138
147
|
if (duration <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
|
139
148
|
this.entries.push({ at, file, start: offset, position });
|
package/src/server.ts
CHANGED
|
@@ -72,6 +72,9 @@ import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
|
72
72
|
import { HlsPackagers, segmentType, withKey } from "./hls.ts";
|
|
73
73
|
import { refusedDestination } from "./destinations.ts";
|
|
74
74
|
import { handleStations, isStationListenPath } from "./station-routes.ts";
|
|
75
|
+
import { handleLibrary } from "./library-routes.ts";
|
|
76
|
+
import { LibraryIndex, probeItem, trackOf, type Probed } from "./library-index.ts";
|
|
77
|
+
import { ListenerStore } from "./library-user.ts";
|
|
75
78
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
76
79
|
import { StationRunner } from "./station-runner.ts";
|
|
77
80
|
import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
|
|
@@ -1706,6 +1709,8 @@ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8
|
|
|
1706
1709
|
}
|
|
1707
1710
|
|
|
1708
1711
|
export interface HandlerOptions {
|
|
1712
|
+
/** The library as an index: sources, items, search. */
|
|
1713
|
+
library?: { index: LibraryIndex; probe: (path: string) => Promise<Probed>; retag: (tracks: Track[]) => void; listeners?: ListenerStore };
|
|
1709
1714
|
/** This server's stations and what keeps them on the air. */
|
|
1710
1715
|
stations?: { store: StationStore; runner: StationRunner };
|
|
1711
1716
|
web: string | null;
|
|
@@ -2708,6 +2713,30 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2708
2713
|
return;
|
|
2709
2714
|
}
|
|
2710
2715
|
|
|
2716
|
+
// Past the key gate is past it: the owner, or somebody given the listen
|
|
2717
|
+
// link, may read the library; only the owner may change it.
|
|
2718
|
+
if (options.library && (path === "/api/v1/library" || path.startsWith("/api/v1/library/"))) {
|
|
2719
|
+
const control = await controlOf();
|
|
2720
|
+
// Who this is: the owner's account, "owner" for the control key, or
|
|
2721
|
+
// any other signed-in nixamp.com account that has the link.
|
|
2722
|
+
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
2723
|
+
const principal = control.account || (control.allowed ? "owner" : (session && options.owner ? (await options.owner.accountFor(session)) || null : null));
|
|
2724
|
+
await handleLibrary(request, response, path, url, {
|
|
2725
|
+
index: options.library.index,
|
|
2726
|
+
canRead: true,
|
|
2727
|
+
canWrite: control.allowed,
|
|
2728
|
+
account: control.account,
|
|
2729
|
+
principal,
|
|
2730
|
+
...(options.library.listeners ? { listeners: options.library.listeners } : {}),
|
|
2731
|
+
probe: options.library.probe,
|
|
2732
|
+
retag: options.library.retag,
|
|
2733
|
+
sendFile,
|
|
2734
|
+
json,
|
|
2735
|
+
readBody,
|
|
2736
|
+
});
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2711
2740
|
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2712
2741
|
const control = await controlOf();
|
|
2713
2742
|
const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
|
|
@@ -2719,7 +2748,22 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2719
2748
|
channels: options.channels!,
|
|
2720
2749
|
canControl: control.allowed,
|
|
2721
2750
|
account: control.account,
|
|
2722
|
-
select: (body) =>
|
|
2751
|
+
select: (body) => {
|
|
2752
|
+
// A playlist of the owner's, as the station's files: its ready items, in its order.
|
|
2753
|
+
if (typeof (body as { playlist?: unknown }).playlist === "string" && options.library?.listeners) {
|
|
2754
|
+
const owner = control.account || "owner";
|
|
2755
|
+
let list;
|
|
2756
|
+
try {
|
|
2757
|
+
list = options.library.listeners.playlist(owner, (body as { playlist: string }).playlist);
|
|
2758
|
+
} catch (error) {
|
|
2759
|
+
throw new StationError("playlist_not_found", (error as Error).message, 404);
|
|
2760
|
+
}
|
|
2761
|
+
const files = list.items.map((id) => options.library!.index.item(id)).filter((one) => one?.state === "ready").map((one) => one!.path);
|
|
2762
|
+
if (files.length === 0) throw new StationError("empty_playlist", "none of that playlist's items are ready to play");
|
|
2763
|
+
return files;
|
|
2764
|
+
}
|
|
2765
|
+
return selectStationFiles(engine, body);
|
|
2766
|
+
},
|
|
2723
2767
|
trackFor: (file) => trackFor(engine, file),
|
|
2724
2768
|
base: `${scheme}://${request.headers.host ?? "localhost"}`,
|
|
2725
2769
|
json,
|
|
@@ -6392,7 +6436,7 @@ function isFile(path: string): boolean {
|
|
|
6392
6436
|
}
|
|
6393
6437
|
}
|
|
6394
6438
|
|
|
6395
|
-
function sendFile(request: IncomingMessage, response: ServerResponse, file: string): void {
|
|
6439
|
+
function sendFile(request: IncomingMessage, response: ServerResponse, file: string, cache?: string): void {
|
|
6396
6440
|
let size: number;
|
|
6397
6441
|
try {
|
|
6398
6442
|
size = statSync(file).size;
|
|
@@ -6409,7 +6453,7 @@ function sendFile(request: IncomingMessage, response: ServerResponse, file: stri
|
|
|
6409
6453
|
};
|
|
6410
6454
|
// The shell must never be cached by a service worker's fetch fallback, but
|
|
6411
6455
|
// hashed assets and audio can be.
|
|
6412
|
-
headers["cache-control"] = type.startsWith("text/html") ? "no-cache" : "public, max-age=3600";
|
|
6456
|
+
headers["cache-control"] = cache ?? (type.startsWith("text/html") ? "no-cache" : "public, max-age=3600");
|
|
6413
6457
|
|
|
6414
6458
|
if (range) {
|
|
6415
6459
|
headers["content-range"] = `bytes ${range.start}-${range.end}/${size}`;
|
|
@@ -6550,6 +6594,23 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6550
6594
|
stationRunner?.onChannelEnd(info);
|
|
6551
6595
|
},
|
|
6552
6596
|
});
|
|
6597
|
+
// The library as an index kept beside the keys: what each file is, learned
|
|
6598
|
+
// once and asked about again only when it changes. The folder this server
|
|
6599
|
+
// was started on is its first source.
|
|
6600
|
+
const libraryIndex = new LibraryIndex(LibraryIndex.fileFor(stateDir(), options.port));
|
|
6601
|
+
const homeSource = (() => {
|
|
6602
|
+
try {
|
|
6603
|
+
return !isRemote(root) && statSync(root).isDirectory() ? libraryIndex.ensureSource(root, options.owner || "") : null;
|
|
6604
|
+
} catch {
|
|
6605
|
+
return null;
|
|
6606
|
+
}
|
|
6607
|
+
})();
|
|
6608
|
+
const probeFile = (path: string): Promise<Probed> => probeItem(tools, path);
|
|
6609
|
+
const retagPlayer = (tracks: Track[]): void => {
|
|
6610
|
+
if (tracks.length > 0) engine.retag(tracks, root);
|
|
6611
|
+
};
|
|
6612
|
+
const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
|
|
6613
|
+
const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
|
|
6553
6614
|
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6554
6615
|
stationRunner = new StationRunner({
|
|
6555
6616
|
store: stationStore,
|
|
@@ -6562,7 +6623,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6562
6623
|
audioBitrate: known.audioBitrate,
|
|
6563
6624
|
})),
|
|
6564
6625
|
// The library as it is now, or as it was chosen while it is still being read.
|
|
6565
|
-
roots: () => [engine.snapshot(false).root || chosen].filter((one) => one !== ""),
|
|
6626
|
+
roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
|
|
6566
6627
|
log: (line) => console.log(line),
|
|
6567
6628
|
});
|
|
6568
6629
|
const stations = { store: stationStore, runner: stationRunner };
|
|
@@ -7158,6 +7219,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7158
7219
|
owner,
|
|
7159
7220
|
channels,
|
|
7160
7221
|
stations,
|
|
7222
|
+
library,
|
|
7161
7223
|
rememberChannels: remembering,
|
|
7162
7224
|
catalogs,
|
|
7163
7225
|
publishUrls: () => publishUrls,
|
|
@@ -7453,15 +7515,23 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7453
7515
|
}
|
|
7454
7516
|
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
7455
7517
|
if (isRemote(root)) return;
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7518
|
+
if (!homeSource) {
|
|
7519
|
+
// A playlist file or a single file: tagged as it always was.
|
|
7520
|
+
return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
|
|
7521
|
+
.then((tagged) => engine.retag(tagged, root))
|
|
7522
|
+
.catch(() => undefined);
|
|
7523
|
+
}
|
|
7524
|
+
// What is already known, at once: a restart has its tags before the
|
|
7525
|
+
// first listener arrives. Then only new and changed files are asked
|
|
7526
|
+
// about, and their tags reach the player a couple of hundred at a time
|
|
7527
|
+
// rather than all at the end.
|
|
7528
|
+
retagPlayer(libraryIndex.tracksFor(found.map((track) => track.path)));
|
|
7529
|
+
const { done } = libraryIndex.scan(homeSource.id, probeFile, {
|
|
7530
|
+
onProgress: (changed) => retagPlayer(changed.filter((one) => one.state === "ready").map(trackOf)),
|
|
7531
|
+
});
|
|
7532
|
+
return done.then((report) => {
|
|
7533
|
+
console.log(`nixamp serve — library: ${report.unchanged} unchanged, ${report.probed} read${report.failed ? `, ${report.failed} would not read` : ""}${report.missing ? `, ${report.missing} gone` : ""}${report.outside ? `, ${report.outside} links outside left out` : ""}.`);
|
|
7534
|
+
});
|
|
7465
7535
|
})
|
|
7466
7536
|
.catch((error: unknown) => {
|
|
7467
7537
|
console.log(`nixamp: could not read ${root}: ${(error as Error).message}`);
|
package/src/station-cli.ts
CHANGED
|
@@ -116,14 +116,15 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
116
116
|
const root = readState()?.source ?? "";
|
|
117
117
|
if (root && folder.startsWith(root)) folder = folder.slice(root.length);
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const playlist = flag(argv, "--playlist");
|
|
120
|
+
if (items === undefined && folder === undefined && playlist === undefined) {
|
|
121
|
+
console.error("nixamp station create needs --folder PATH, --playlist ID or --items 0,1,2");
|
|
121
122
|
return 2;
|
|
122
123
|
}
|
|
123
124
|
const bitrate = flag(argv, "--bitrate");
|
|
124
125
|
const answer = await call("POST", "/api/v1/stations", {
|
|
125
126
|
name,
|
|
126
|
-
...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
127
|
+
...(playlist !== undefined ? { playlist } : items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
127
128
|
shuffle: has(argv, "--shuffle"),
|
|
128
129
|
repeat: !has(argv, "--no-repeat"),
|
|
129
130
|
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stations as MCP tools: what `nixamp station` does, for an agent.
|
|
3
|
+
*
|
|
4
|
+
* The same API and the same rules as the CLI and the page. On this machine
|
|
5
|
+
* the tools use the daemon's control key, which is what being on the machine
|
|
6
|
+
* means; for another server they send this machine's nixamp.com session,
|
|
7
|
+
* which the server accepts only if that account owns it. Publishing is its
|
|
8
|
+
* own tool and asks for the basis, exactly as for a person.
|
|
9
|
+
*/
|
|
10
|
+
import { daemonUrl, readState } from "./daemon.ts";
|
|
11
|
+
import { readSession } from "./session.ts";
|
|
12
|
+
import { KEY_HEADER } from "./share.ts";
|
|
13
|
+
|
|
14
|
+
const STRING = { type: "string" } as const;
|
|
15
|
+
const ID = { ...STRING, description: "The station's id (10 characters), from station_list." };
|
|
16
|
+
const SERVER = { ...STRING, description: "A nixamp server's address; this machine's daemon when omitted." };
|
|
17
|
+
|
|
18
|
+
export const STATION_TOOLS = [
|
|
19
|
+
{
|
|
20
|
+
name: "station_list",
|
|
21
|
+
description: "List this server's stations: id, name, state (live, stopped, offline...), visibility and listening link.",
|
|
22
|
+
inputSchema: { type: "object", properties: { server: SERVER } },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "station_get",
|
|
26
|
+
description: "One station in full: files, rotation, state, any problem, and its links.",
|
|
27
|
+
inputSchema: { type: "object", properties: { server: SERVER, id: ID }, required: ["id"] },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "station_create",
|
|
31
|
+
description: "Make a station from a folder in the library (path relative to it, \"\" for all of it), a playlist, or track indexes. It starts private and stopped; nothing is published.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
server: SERVER,
|
|
36
|
+
name: STRING,
|
|
37
|
+
folder: { ...STRING, description: "A folder under the library, e.g. \"Albums/Late Night\"." },
|
|
38
|
+
items: { type: "array", items: { type: "integer" }, description: "Track indexes, as /api/state lists them." },
|
|
39
|
+
playlist: { ...STRING, description: "A playlist id (playlist_list): the station plays its ready items in order." },
|
|
40
|
+
description: STRING,
|
|
41
|
+
shuffle: { type: "boolean" },
|
|
42
|
+
repeat: { type: "boolean", description: "Round and round (default) or once." },
|
|
43
|
+
bitrate: { type: "integer", description: "kbps: 64, 96, 128 (default), 160, 192, 256 or 320." },
|
|
44
|
+
idempotencyKey: { ...STRING, description: "Asking again with the same key returns the same station." },
|
|
45
|
+
},
|
|
46
|
+
required: ["name"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "station_update",
|
|
51
|
+
description: "Rename, describe, change files or rotation. Edits to the files land on the next track.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: { server: SERVER, id: ID, name: STRING, description: STRING, folder: STRING, items: { type: "array", items: { type: "integer" } }, shuffle: { type: "boolean" }, repeat: { type: "boolean" }, bitrate: { type: "integer" } },
|
|
55
|
+
required: ["id"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "station_control",
|
|
60
|
+
description: "start, stop or skip a station, or retry the files it left out. Starting does not publish.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: { server: SERVER, id: ID, action: { type: "string", enum: ["start", "stop", "skip", "retry"] } },
|
|
64
|
+
required: ["id", "action"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "station_publish",
|
|
69
|
+
description: "Make a station unlisted (a link) or public (and listed in the nixamp.com directory). Public needs the basis on which it may be broadcast: owned, permission, licensed or public-domain. A declaration, not legal clearance.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
server: SERVER,
|
|
74
|
+
id: ID,
|
|
75
|
+
visibility: { type: "string", enum: ["unlisted", "public"] },
|
|
76
|
+
listed: { type: "boolean", description: "In the directory (public only). Default true for public." },
|
|
77
|
+
basis: { type: "string", enum: ["owned", "permission", "licensed", "public-domain"] },
|
|
78
|
+
note: STRING,
|
|
79
|
+
},
|
|
80
|
+
required: ["id", "visibility"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "station_unpublish",
|
|
85
|
+
description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
|
|
86
|
+
inputSchema: { type: "object", properties: { server: SERVER, id: ID, private: { type: "boolean" } }, required: ["id"] },
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
interface Result {
|
|
91
|
+
content: { type: "text"; text: string }[];
|
|
92
|
+
isError?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const say = (value: string, isError = false): Result => ({ content: [{ type: "text", text: value }], ...(isError ? { isError: true } : {}) });
|
|
96
|
+
|
|
97
|
+
export interface StationToolOptions {
|
|
98
|
+
fetcher?: typeof fetch;
|
|
99
|
+
/** Where the local daemon is and its key; the tests hand these in. */
|
|
100
|
+
local?: { url: string; key: string | null } | null;
|
|
101
|
+
session?: { site: string; token: string } | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Where to send a tool's request, and with which credential. */
|
|
105
|
+
export function connect(args: Record<string, unknown>, options: StationToolOptions): { base: string; headers: Record<string, string> } | { error: string } {
|
|
106
|
+
const server = typeof args["server"] === "string" ? args["server"].replace(/\/+$/, "") : "";
|
|
107
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
108
|
+
if (server === "") {
|
|
109
|
+
const local = options.local === undefined ? (() => { const state = readState(); return state ? { url: daemonUrl(state), key: state.key } : null; })() : options.local;
|
|
110
|
+
if (!local) return { error: "No nixamp daemon runs on this machine. Start one (nixamp daemon start ~/Music) or pass `server`." };
|
|
111
|
+
if (local.key) headers[KEY_HEADER] = local.key;
|
|
112
|
+
return { base: local.url, headers };
|
|
113
|
+
}
|
|
114
|
+
const session = options.session === undefined ? readSession() : options.session;
|
|
115
|
+
if (!session) return { error: "Another server needs this machine signed in as its owner: nixamp login." };
|
|
116
|
+
headers["authorization"] = `Bearer ${session.token}`;
|
|
117
|
+
return { base: server, headers };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function isStationTool(name: string): boolean {
|
|
121
|
+
return STATION_TOOLS.some((tool) => tool.name === name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function line(station: Record<string, unknown>): string {
|
|
125
|
+
const links = (station["links"] ?? {}) as Record<string, string>;
|
|
126
|
+
const problem = station["error"] ? `; problem: ${String(station["error"])}` : "";
|
|
127
|
+
const link = station["visibility"] !== "private" && links["page"] ? `\n listen: ${links["page"]}` : "";
|
|
128
|
+
return `${String(station["name"])} [${String(station["id"])}] ${String(station["state"])}, ${String(station["visibility"])}${station["listed"] ? " and listed" : ""}${problem}${link}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function callStationTool(name: string, args: Record<string, unknown>, options: StationToolOptions = {}): Promise<Result> {
|
|
132
|
+
const send = options.fetcher ?? fetch;
|
|
133
|
+
const target = connect(args, options);
|
|
134
|
+
if ("error" in target) return say(target.error, true);
|
|
135
|
+
const { base, headers } = target;
|
|
136
|
+
const id = typeof args["id"] === "string" ? args["id"] : "";
|
|
137
|
+
const call = async (method: string, path: string, body?: unknown): Promise<{ ok: boolean; body: Record<string, unknown> }> => {
|
|
138
|
+
const response = await send(`${base}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
139
|
+
const parsed = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
140
|
+
return { ok: response.ok, body: parsed };
|
|
141
|
+
};
|
|
142
|
+
const refused = (body: Record<string, unknown>): Result => say(`${String(body["error"] ?? "refused")} (${String(body["code"] ?? "error")})`, true);
|
|
143
|
+
const pick = (keys: string[]): Record<string, unknown> => Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
if (name === "station_list") {
|
|
147
|
+
const answer = await call("GET", "/api/v1/stations?limit=200");
|
|
148
|
+
if (!answer.ok) return refused(answer.body);
|
|
149
|
+
const all = (answer.body["stations"] as Record<string, unknown>[] | undefined) ?? [];
|
|
150
|
+
return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
|
|
151
|
+
}
|
|
152
|
+
if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create") return say("Which station? Pass its id from station_list.", true);
|
|
153
|
+
if (name === "station_get") {
|
|
154
|
+
const answer = await call("GET", `/api/v1/stations/${id}`);
|
|
155
|
+
return answer.ok ? say(JSON.stringify(answer.body["station"], null, 2)) : refused(answer.body);
|
|
156
|
+
}
|
|
157
|
+
if (name === "station_create") {
|
|
158
|
+
const answer = await call("POST", "/api/v1/stations", pick(["name", "folder", "items", "playlist", "description", "shuffle", "repeat", "bitrate", "idempotencyKey"]));
|
|
159
|
+
return answer.ok ? say(`Made, private and stopped: ${line(answer.body["station"] as Record<string, unknown>)}`) : refused(answer.body);
|
|
160
|
+
}
|
|
161
|
+
if (name === "station_update") {
|
|
162
|
+
const answer = await call("PATCH", `/api/v1/stations/${id}`, pick(["name", "description", "folder", "items", "shuffle", "repeat", "bitrate"]));
|
|
163
|
+
return answer.ok ? say(line(answer.body["station"] as Record<string, unknown>)) : refused(answer.body);
|
|
164
|
+
}
|
|
165
|
+
if (name === "station_control") {
|
|
166
|
+
const action = String(args["action"] ?? "");
|
|
167
|
+
if (!["start", "stop", "skip", "retry"].includes(action)) return say("action is start, stop, skip or retry", true);
|
|
168
|
+
const answer = await call("POST", `/api/v1/stations/${id}/${action}`, {});
|
|
169
|
+
return answer.ok ? say(line(answer.body["station"] as Record<string, unknown>)) : refused(answer.body);
|
|
170
|
+
}
|
|
171
|
+
if (name === "station_publish") {
|
|
172
|
+
const visibility = args["visibility"] === "public" ? "public" : "unlisted";
|
|
173
|
+
const answer = await call("POST", `/api/v1/stations/${id}/access`, {
|
|
174
|
+
visibility,
|
|
175
|
+
listed: visibility === "public" && args["listed"] !== false,
|
|
176
|
+
...(typeof args["basis"] === "string" ? { rights: { basis: args["basis"], note: typeof args["note"] === "string" ? args["note"] : "" } } : {}),
|
|
177
|
+
});
|
|
178
|
+
return answer.ok ? say(line(answer.body["station"] as Record<string, unknown>)) : refused(answer.body);
|
|
179
|
+
}
|
|
180
|
+
if (name === "station_unpublish") {
|
|
181
|
+
const answer = await call("POST", `/api/v1/stations/${id}/access`, args["private"] === true ? { visibility: "private" } : { listed: false });
|
|
182
|
+
if (!answer.ok) return refused(answer.body);
|
|
183
|
+
const dropped = Number(answer.body["dropped"] ?? 0);
|
|
184
|
+
return say(`${line(answer.body["station"] as Record<string, unknown>)}${dropped ? `\n${dropped} listener(s) disconnected.` : ""}`);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return say(`Could not reach ${base}: ${(error as Error).message}`, true);
|
|
188
|
+
}
|
|
189
|
+
return say(`No such tool: ${name}`, true);
|
|
190
|
+
}
|
package/src/station-page.ts
CHANGED
|
@@ -101,6 +101,7 @@ const PAGE = `<!doctype html>
|
|
|
101
101
|
<meta charset="utf-8">
|
|
102
102
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
103
103
|
<meta name="robots" content="noindex">
|
|
104
|
+
<link rel="icon" href="data:,">
|
|
104
105
|
<title>Station · nixamp</title>
|
|
105
106
|
<style>${STYLE}</style>
|
|
106
107
|
</head>
|
|
@@ -137,7 +138,7 @@ export const STATION_PAGE_CSP = [
|
|
|
137
138
|
`style-src ${hash(STYLE)}`,
|
|
138
139
|
"connect-src 'self'",
|
|
139
140
|
"media-src 'self'",
|
|
140
|
-
"img-src 'self'",
|
|
141
|
+
"img-src 'self' data:",
|
|
141
142
|
"base-uri 'none'",
|
|
142
143
|
"form-action 'none'",
|
|
143
144
|
"frame-ancestors 'none'",
|
package/src/station-routes.ts
CHANGED
|
@@ -76,7 +76,8 @@ function nowPlayingFile(ctx: StationContext, station: Station): string {
|
|
|
76
76
|
export function publicView(ctx: StationContext, station: Station): PublicStation {
|
|
77
77
|
const state = ctx.runner.observed(station.id);
|
|
78
78
|
const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
79
|
-
|
|
79
|
+
// The tags the player read when it put this file on, else the library's.
|
|
80
|
+
const track = file ? (ctx.channels.tagsOf(channelOf(station.id), file) ?? ctx.trackFor(file)) : null;
|
|
80
81
|
return {
|
|
81
82
|
id: station.id,
|
|
82
83
|
name: station.name,
|
package/src/station-usage.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const USAGE = `nixamp station — make a collection into a station
|
|
3
3
|
|
|
4
4
|
nixamp station list
|
|
5
|
-
nixamp station create --name NAME (--folder PATH | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]
|
|
5
|
+
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128]
|
|
6
6
|
nixamp station show ID
|
|
7
7
|
nixamp station status ID
|
|
8
8
|
nixamp station start ID
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e}from"./index-
|
|
1
|
+
import{n as e}from"./index-D-PZ4nGC.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 +1 @@
|
|
|
1
|
-
import{n as e}from"./index-
|
|
1
|
+
import{n as e}from"./index-D-PZ4nGC.js";var t={...e,"No parties live right now.":`Právě neprobíhají žádné párty.`,"Could not refresh parties.":`Párty se nepodařilo aktualizovat.`,"Pick a server in the directory, or paste its link here.":`Vyberte server v seznamu nebo sem vložte jeho odkaz.`,"Interface language":`Jazyk rozhraní`,Language:`Jazyk`,"Translation language":`Jazyk překladu`,"Original (auto-detect)":`Původní (automatické rozpoznání)`,Play:`Přehrát`,Pause:`Pozastavit`,Stop:`Zastavit`,Next:`Další`,Previous:`Předchozí`,Select:`Vybrat`,"Next/Prev":`Další/Předchozí`,Quit:`Ukončit`,"Now Playing":`Právě hraje`,"Spectrum Analyser":`Spektrální analyzátor`,Playlist:`Seznam skladeb`,Files:`Soubory`,"Open files":`Otevřít soubory`,"Open folder":`Otevřít složku`,"Add to library":`Přidat do knihovny`,"Replace playlist":`Nahradit seznam`,Filter:`Filtrovat`,Search:`Hledat`,"Nothing loaded.":`Nic není načteno.`,"Empty.":`Prázdné.`,"Loading…":`Načítání…`,"Connecting…":`Připojování…`,Connected:`Připojeno`,"Not connected":`Nepřipojeno`,"No source":`Žádný zdroj`,Playing:`Přehrávání`,Stopped:`Zastaveno`,Volume:`Hlasitost`,Seek:`Pozice přehrávání`,"Full screen":`Celá obrazovka`,Download:`Stáhnout`,Share:`Sdílet`,Copy:`Kopírovat`,"Copy link":`Kopírovat odkaz`,Copied:`Zkopírováno`,Save:`Uložit`,Cancel:`Zrušit`,Close:`Zavřít`,Remove:`Odebrat`,Delete:`Smazat`,Edit:`Upravit`,Add:`Přidat`,Send:`Odeslat`,Retry:`Zkusit znovu`,Back:`Zpět`,"Next page":`Další stránka`,"Previous page":`Předchozí stránka`,"Show more":`Zobrazit více`,Panels:`Panely`,"Reset layout":`Obnovit rozložení`,Show:`Zobrazit`,Hide:`Skrýt`,Collapse:`Sbalit`,Expand:`Rozbalit`,"Move up":`Nahoru`,"Move down":`Dolů`,Install:`Nainstalovat`,"TV mode":`Režim TV`,On:`Zapnuto`,Off:`Vypnuto`,Account:`Účet`,"Sign in":`Přihlásit se`,"Sign out":`Odhlásit se`,"Create account":`Vytvořit účet`,Email:`E-mail`,Password:`Heslo`,"Forgot password?":`Zapomenuté heslo?`,"Welcome back":`Vítejte zpět`,Profile:`Profil`,Handle:`Uživatelské jméno`,"Your servers":`Vaše servery`,Server:`Server`,Connect:`Připojit`,Disconnect:`Odpojit`,Directory:`Adresář`,Favourites:`Oblíbené`,Follow:`Sledovat`,Unfollow:`Přestat sledovat`,Notifications:`Oznámení`,"Phone number":`Telefonní číslo`,"Save number":`Uložit číslo`,Transcript:`Přepis`,Captions:`Titulky`,"Translate audio":`Překládat zvuk`,"Audio language":`Jazyk zvuku`,"Audio options":`Možnosti zvuku`,"Keep background sound":`Zachovat zvuk na pozadí`,"Background level":`Hlasitost pozadí`,"Speaker voices":`Hlasy mluvčích`,"Any voice":`Libovolný hlas`,"Male voice":`Mužský hlas`,"Female voice":`Ženský hlas`,"Original audio.":`Původní zvuk.`,"Buy translated audio":`Koupit překlad zvuku`,"Audio credit":`Zvukový kredit`,"Buy pass":`Koupit přístup`,Pass:`Přístup`,"Pay with":`Zaplatit pomocí`,"Check payment":`Ověřit platbu`,"Open checkout":`Přejít k platbě`,"Free sessions":`Bezplatné relace`,"Usage rates and expiry":`Ceny a platnost`,Catalogs:`Katalogy`,"Add catalog":`Přidat katalog`,"Catalog name":`Název katalogu`,Link:`Odkaz`,"Play link":`Přehrát odkaz`,"Go live":`Vysílat živě`,Publish:`Zveřejnit`,Listen:`Poslouchat`,"Live now":`Právě živě`,Upcoming:`Připravované`,Parties:`Společná sledování`,"Join party":`Připojit se ke sledování`,"Invite code":`Kód pozvánky`,Invite:`Pozvat`,Chat:`Chat`,"Chat message":`Zpráva chatu`,People:`Lidé`,Speaker:`Mluvčí`,Speakers:`Mluvčí`,Listener:`Posluchač`,Moderator:`Moderátor`,"Raise hand":`Zvednout ruku`,"Hand raises":`Zvednuté ruce`,Questions:`Otázky`,Resources:`Materiály`,Replay:`Záznam`,Schedule:`Rozvrh`,Event:`Událost`,Host:`Pořadatel`,About:`O události`,Stage:`Pódium`,Title:`Název`,Description:`Popis`,Topic:`Téma`,Public:`Veřejné`,"Anyone with the link":`Kdokoli s odkazem`,"Invited people":`Pozvané osoby`,"Who can join":`Kdo se může připojit`,"Date and time":`Datum a čas`,"Expected length":`Očekávaná délka`,Repeat:`Opakovat`,"Does not repeat":`Neopakuje se`,"Every day":`Každý den`,"Every week":`Každý týden`,Homepage:`Webové stránky`,"Host name":`Jméno pořadatele`,"Profile photo URL":`URL profilové fotky`,"Broadcast link":`Odkaz na vysílání`,"Edit event":`Upravit událost`,"Save changes":`Uložit změny`,"Create live":`Vytvořit vysílání`,"Schedule class":`Naplánovat lekci`,"Start class":`Zahájit lekci`,"End event":`Ukončit událost`,"End session":`Ukončit relaci`,"Session ended":`Relace ukončena`,"Event ended":`Událost ukončena`,Cancelled:`Zrušeno`,"Replay available":`Záznam je k dispozici`,Join:`Připojit se`,"Create invite":`Vytvořit pozvánku`,"Invite as":`Pozvat jako`,"Watch now":`Sledovat nyní`,"Start a room":`Vytvořit místnost`,"Recent replays":`Nedávné záznamy`,"Your account":`Váš účet`,"Write with AI":`Psát pomocí AI`,"Suggested title":`Navrhovaný název`,"Suggested description":`Navrhovaný popis`,"Suggested topic":`Navrhované téma`,"Use these details":`Použít tyto údaje`,"Learn something live.":`Učte se naživo.`,"Screen share":`Sdílet obrazovku`,"Upload and broadcast":`Nahrát a vysílat`,Help:`Nápověda`,"Reload Player":`Znovu načíst přehrávač`,"Copy Bundled CLI Path":`Kopírovat cestu k přibalenému CLI`,"Developer tools":`Vývojářské nástroje`,Undo:`Zpět`,Redo:`Znovu`,Cut:`Vyjmout`,Paste:`Vložit`,"Select all":`Vybrat vše`,Status:`Stav`,"Activity log":`Protokol aktivit`,Admin:`Správa`,"Playback controls":`Ovládání přehrávání`,"Tracks and folders":`Skladby a složky`,"Original-language captions.":`Titulky v původním jazyce.`,"Skip to playback controls":`Přejít k ovládání přehrávání`,"Skip to playlist":`Přejít k seznamu skladeb`,"Skip to transcript and translated audio":`Přejít k přepisu a přeloženému zvuku`};export{t as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e}from"./index-
|
|
1
|
+
import{n as e}from"./index-D-PZ4nGC.js";var t={...e,"No parties live right now.":`Ingen fester er live lige nu.`,"Could not refresh parties.":`Kunne ikke opdatere festerne.`,"Pick a server in the directory, or paste its link here.":`Vælg en server i oversigten, eller indsæt dens link her.`,"Interface language":`Grænsefladesprog`,Language:`Sprog`,"Translation language":`Oversættelsessprog`,"Original (auto-detect)":`Original (automatisk registrering)`,Play:`Afspil`,Pause:`Pause`,Stop:`Stop`,Next:`Næste`,Previous:`Forrige`,Select:`Vælg`,"Next/Prev":`Næste/Forrige`,Quit:`Afslut`,"Now Playing":`Afspilles nu`,"Spectrum Analyser":`Spektrumanalysator`,Playlist:`Afspilningsliste`,Files:`Filer`,"Open files":`Åbn filer`,"Open folder":`Åbn mappe`,"Add to library":`Føj til bibliotek`,"Replace playlist":`Erstat afspilningsliste`,Filter:`Filtrer`,Search:`Søg`,"Nothing loaded.":`Intet indlæst.`,"Empty.":`Tomt.`,"Loading…":`Indlæser…`,"Connecting…":`Forbinder…`,Connected:`Forbundet`,"Not connected":`Ikke forbundet`,"No source":`Ingen kilde`,Playing:`Afspiller`,Stopped:`Stoppet`,Volume:`Lydstyrke`,Seek:`Afspilningsposition`,"Full screen":`Fuld skærm`,Download:`Download`,Share:`Del`,Copy:`Kopiér`,"Copy link":`Kopiér link`,Copied:`Kopieret`,Save:`Gem`,Cancel:`Annuller`,Close:`Luk`,Remove:`Fjern`,Delete:`Slet`,Edit:`Rediger`,Add:`Tilføj`,Send:`Send`,Retry:`Prøv igen`,Back:`Tilbage`,"Next page":`Næste side`,"Previous page":`Forrige side`,"Show more":`Vis flere`,Panels:`Paneler`,"Reset layout":`Nulstil layout`,Show:`Vis`,Hide:`Skjul`,Collapse:`Fold sammen`,Expand:`Fold ud`,"Move up":`Flyt op`,"Move down":`Flyt ned`,Install:`Installer`,"TV mode":`TV-tilstand`,On:`Til`,Off:`Fra`,Account:`Konto`,"Sign in":`Log ind`,"Sign out":`Log ud`,"Create account":`Opret konto`,Email:`E-mail`,Password:`Adgangskode`,"Forgot password?":`Glemt adgangskode?`,"Welcome back":`Velkommen tilbage`,Profile:`Profil`,Handle:`Brugernavn`,"Your servers":`Dine servere`,Server:`Server`,Connect:`Forbind`,Disconnect:`Afbryd`,Directory:`Oversigt`,Favourites:`Favoritter`,Follow:`Følg`,Unfollow:`Følg ikke længere`,Notifications:`Notifikationer`,"Phone number":`Telefonnummer`,"Save number":`Gem nummer`,Transcript:`Transskription`,Captions:`Undertekster`,"Translate audio":`Oversæt lyd`,"Audio language":`Lydsprog`,"Audio options":`Lydindstillinger`,"Keep background sound":`Bevar baggrundslyd`,"Background level":`Baggrundslydstyrke`,"Speaker voices":`Talernes stemmer`,"Any voice":`Vilkårlig stemme`,"Male voice":`Mandlig stemme`,"Female voice":`Kvindelig stemme`,"Original audio.":`Original lyd.`,"Buy translated audio":`Køb oversat lyd`,"Audio credit":`Lydsaldo`,"Buy pass":`Køb adgang`,Pass:`Adgang`,"Pay with":`Betal med`,"Check payment":`Kontrollér betaling`,"Open checkout":`Gå til betaling`,"Free sessions":`Gratis sessioner`,"Usage rates and expiry":`Priser og udløb`,Catalogs:`Kataloger`,"Add catalog":`Tilføj katalog`,"Catalog name":`Katalognavn`,Link:`Link`,"Play link":`Afspil link`,"Go live":`Gå live`,Publish:`Udgiv`,Listen:`Lyt`,"Live now":`Live nu`,Upcoming:`Kommende`,Parties:`Seerfester`,"Join party":`Deltag i festen`,"Invite code":`Invitationskode`,Invite:`Inviter`,Chat:`Chat`,"Chat message":`Chatbesked`,People:`Personer`,Speaker:`Taler`,Speakers:`Talere`,Listener:`Lytter`,Moderator:`Moderator`,"Raise hand":`Ræk hånden op`,"Hand raises":`Oprakte hænder`,Questions:`Spørgsmål`,Resources:`Materialer`,Replay:`Genudsendelse`,Schedule:`Program`,Event:`Begivenhed`,Host:`Vært`,About:`Om`,Stage:`Scene`,Title:`Titel`,Description:`Beskrivelse`,Topic:`Emne`,Public:`Offentlig`,"Anyone with the link":`Alle med linket`,"Invited people":`Inviterede personer`,"Who can join":`Hvem kan deltage`,"Date and time":`Dato og klokkeslæt`,"Expected length":`Forventet varighed`,Repeat:`Gentag`,"Does not repeat":`Gentages ikke`,"Every day":`Hver dag`,"Every week":`Hver uge`,Homepage:`Hjemmeside`,"Host name":`Værtens navn`,"Profile photo URL":`URL til profilbillede`,"Broadcast link":`Udsendelseslink`,"Edit event":`Rediger begivenhed`,"Save changes":`Gem ændringer`,"Create live":`Opret livesending`,"Schedule class":`Planlæg undervisning`,"Start class":`Start undervisning`,"End event":`Afslut begivenhed`,"End session":`Afslut session`,"Session ended":`Session afsluttet`,"Event ended":`Begivenhed afsluttet`,Cancelled:`Annulleret`,"Replay available":`Genudsendelse tilgængelig`,Join:`Deltag`,"Create invite":`Opret invitation`,"Invite as":`Inviter som`,"Watch now":`Se nu`,"Start a room":`Opret et rum`,"Recent replays":`Seneste genudsendelser`,"Your account":`Din konto`,"Write with AI":`Skriv med AI`,"Suggested title":`Foreslået titel`,"Suggested description":`Foreslået beskrivelse`,"Suggested topic":`Foreslået emne`,"Use these details":`Brug disse oplysninger`,"Learn something live.":`Lær noget live.`,"Screen share":`Del skærm`,"Upload and broadcast":`Upload og udsend`,Help:`Hjælp`,"Reload Player":`Genindlæs afspiller`,"Copy Bundled CLI Path":`Kopiér stien til medfølgende CLI`,"Developer tools":`Udviklerværktøjer`,Undo:`Fortryd`,Redo:`Gentag`,Cut:`Klip`,Paste:`Indsæt`,"Select all":`Vælg alle`,Status:`Status`,"Activity log":`Aktivitetslog`,Admin:`Administration`,"Playback controls":`Afspilningskontroller`,"Tracks and folders":`Numre og mapper`,"Original-language captions.":`Undertekster på originalsproget.`,"Skip to playback controls":`Spring til afspilningskontroller`,"Skip to playlist":`Spring til afspilningslisten`,"Skip to transcript and translated audio":`Spring til transskription og oversat lyd`};export{t as default};
|