nixamp 0.53.5 → 0.54.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/follow-page.d.ts +23 -0
- package/dist/follow-page.js +111 -0
- package/dist/radio-pages.d.ts +211 -0
- package/dist/radio-pages.js +415 -0
- package/dist/radio-slug.d.ts +18 -0
- package/dist/radio-slug.js +43 -0
- package/dist/server.js +242 -3
- package/dist/station-guide.d.ts +2 -0
- package/dist/station-guide.js +5 -3
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +4 -2
- package/dist/station-routes.d.ts +1 -0
- package/dist/station-routes.js +28 -3
- package/package.json +1 -1
- package/src/follow-page.ts +127 -0
- package/src/radio-pages.ts +513 -0
- package/src/radio-slug.ts +45 -0
- package/src/server.ts +232 -3
- package/src/station-guide.ts +7 -3
- package/src/station-page.ts +4 -2
- package/src/station-routes.ts +28 -4
- package/web/dist/assets/{ar-DqeYPqVe.js → ar-CHJqwLbG.js} +1 -1
- package/web/dist/assets/{cs-D4zQN1oV.js → cs-CmwqmJKg.js} +1 -1
- package/web/dist/assets/{da-DKIaXNAT.js → da-Xdzrm0AY.js} +1 -1
- package/web/dist/assets/{de-D1lMnV7x.js → de-h2ntHz8Y.js} +1 -1
- package/web/dist/assets/{es-CjjnXT7v.js → es-DgbBXAxw.js} +1 -1
- package/web/dist/assets/{fi-Dp56MD45.js → fi-Bso9G00g.js} +1 -1
- package/web/dist/assets/{fr-CeifYZP1.js → fr-6Q3UZKIG.js} +1 -1
- package/web/dist/assets/{hi-DsBa_glA.js → hi-T7_WxQ11.js} +1 -1
- package/web/dist/assets/{hu-SYFZGXA2.js → hu-BjOppQVq.js} +1 -1
- package/web/dist/assets/{id-91576EGD.js → id-BIkZDUv5.js} +1 -1
- package/web/dist/assets/{it-Cjmfw8XJ.js → it-CHOmOHju.js} +1 -1
- package/web/dist/assets/{main-CBsLIRa4.js → main-BpsUYSCl.js} +2 -2
- package/web/dist/assets/{nl-XqBXZghJ.js → nl-CHQR6VFb.js} +1 -1
- package/web/dist/assets/{ru-CYT8xDxB.js → ru-Du7NQrxe.js} +1 -1
- package/web/dist/assets/station-xTLbhzsu.js +3 -0
- package/web/dist/assets/{sv-BlpGbShs.js → sv-B6AFovY0.js} +1 -1
- package/web/dist/assets/{uk-g-QbP-e_.js → uk-LXJz37Kp.js} +1 -1
- package/web/dist/assets/{vi-CF206cCy.js → vi-C4taTvjH.js} +1 -1
- package/web/dist/assets/{zh-D4QH6alE.js → zh-dBiC2V5a.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +20 -20
- package/web/dist/assets/station-DUt85zQk.js +0 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export interface FollowPageContext {
|
|
3
|
+
/** The signed-in account, or null. */
|
|
4
|
+
account: () => Promise<{
|
|
5
|
+
id: string;
|
|
6
|
+
} | null>;
|
|
7
|
+
follows: {
|
|
8
|
+
follow: (follower: string, streamer: string) => Promise<boolean>;
|
|
9
|
+
unfollow: (follower: string, streamer: string) => Promise<void>;
|
|
10
|
+
isFollowing: (follower: string, streamer: string) => Promise<boolean>;
|
|
11
|
+
followerCount: (streamer: string) => Promise<number>;
|
|
12
|
+
};
|
|
13
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare const FOLLOW_CSP: string;
|
|
16
|
+
/** What the link says, cleaned: an artist id, a name to show, and an https page to go back to. */
|
|
17
|
+
export declare function followTarget(input: URLSearchParams): {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
back: string;
|
|
21
|
+
} | null;
|
|
22
|
+
/** Answers /follow; false for any other path. */
|
|
23
|
+
export declare function handleFollowPage(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: FollowPageContext): Promise<boolean>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /follow on nixamp.com: follow an artist from a /radio page (radio-pages.ts).
|
|
3
|
+
*
|
|
4
|
+
* The /radio pages are on another site (live.streaming.pit.world), where a
|
|
5
|
+
* nixamp.com session cookie is a third-party cookie and no browser sends it.
|
|
6
|
+
* So Follow there is a link here, where the session is first-party: this page
|
|
7
|
+
* says who you are following, and a same-site form (SameSite=Lax keeps a
|
|
8
|
+
* cross-site POST from carrying the session) follows or unfollows. The follow
|
|
9
|
+
* is the same follows row as following a streamer, keyed "artist:<name>".
|
|
10
|
+
*
|
|
11
|
+
* GET /follow?id=artist:<name>&name=<shown>&back=<https page>
|
|
12
|
+
* POST /follow id, name, back, act=follow|unfollow -> 303 back to the GET
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
const ID = /^artist:[a-z0-9-]{1,90}$/;
|
|
16
|
+
function escape(text) {
|
|
17
|
+
return String(text).replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
18
|
+
}
|
|
19
|
+
const STYLE = `
|
|
20
|
+
:root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
|
|
21
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
22
|
+
main{max-width:520px;margin:0 auto;padding:40px 16px}
|
|
23
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
|
24
|
+
h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}.by{color:var(--muted);margin:0 0 16px}
|
|
25
|
+
button,a.go{display:block;text-align:center;text-decoration:none;margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
|
|
26
|
+
button.quiet{background:transparent;color:var(--fg);border:1px solid var(--line)}
|
|
27
|
+
a{color:var(--accent)}a:focus-visible,button:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
|
28
|
+
small{display:block;margin-top:18px;color:var(--muted)}
|
|
29
|
+
`;
|
|
30
|
+
const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
31
|
+
export const FOLLOW_CSP = ["default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'", "form-action 'self'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
|
|
32
|
+
function page(title, body) {
|
|
33
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
34
|
+
<title>${escape(title)} · nixamp</title><meta name="robots" content="noindex"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
|
|
35
|
+
<body><main><div class="card">${body}</div></main></body></html>`;
|
|
36
|
+
}
|
|
37
|
+
function send(response, status, html, method) {
|
|
38
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": FOLLOW_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
|
|
39
|
+
response.end(method === "HEAD" ? undefined : html);
|
|
40
|
+
}
|
|
41
|
+
/** What the link says, cleaned: an artist id, a name to show, and an https page to go back to. */
|
|
42
|
+
export function followTarget(input) {
|
|
43
|
+
const id = (input.get("id") ?? "").trim();
|
|
44
|
+
if (!ID.test(id))
|
|
45
|
+
return null;
|
|
46
|
+
const name = (input.get("name") ?? "").replace(/[\u0000-\u001f]/g, " ").trim().slice(0, 120) || id.slice("artist:".length);
|
|
47
|
+
let back = "";
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(input.get("back") ?? "");
|
|
50
|
+
if (url.protocol === "https:" && !url.username && !url.password)
|
|
51
|
+
back = url.href;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
back = "";
|
|
55
|
+
}
|
|
56
|
+
return { id, name, back };
|
|
57
|
+
}
|
|
58
|
+
/** Answers /follow; false for any other path. */
|
|
59
|
+
export async function handleFollowPage(request, response, path, url, ctx) {
|
|
60
|
+
if (path !== "/follow")
|
|
61
|
+
return false;
|
|
62
|
+
const method = request.method ?? "GET";
|
|
63
|
+
if (method === "POST") {
|
|
64
|
+
// Same site only: a form on another site gets no session (SameSite=Lax), and is told so here too.
|
|
65
|
+
const origin = String(request.headers.origin ?? "");
|
|
66
|
+
const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0].trim();
|
|
67
|
+
if (origin && origin !== "null" && new URL(origin).host !== host) {
|
|
68
|
+
send(response, 403, page("Not from here", `<h1>Not from here</h1><p class="by">Follow from nixamp.com's own page.</p>`), method);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
const form = new URLSearchParams(await ctx.readBody(request));
|
|
72
|
+
const target = followTarget(form);
|
|
73
|
+
const me = await ctx.account().catch(() => null);
|
|
74
|
+
if (target && me) {
|
|
75
|
+
if (form.get("act") === "unfollow")
|
|
76
|
+
await ctx.follows.unfollow(me.id, target.id);
|
|
77
|
+
else
|
|
78
|
+
await ctx.follows.follow(me.id, target.id);
|
|
79
|
+
}
|
|
80
|
+
const again = target ? `/follow?${new URLSearchParams({ id: target.id, name: target.name, ...(target.back ? { back: target.back } : {}) }).toString()}` : "/follow";
|
|
81
|
+
response.writeHead(303, { location: again, "cache-control": "no-store" });
|
|
82
|
+
response.end();
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
86
|
+
response.writeHead(405, { allow: "GET, POST" }).end();
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const target = followTarget(url.searchParams);
|
|
90
|
+
if (!target) {
|
|
91
|
+
send(response, 404, page("Nobody to follow", `<h1>Nobody to follow</h1><p class="by">That link does not name an artist.</p>`), method);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const back = target.back ? `<small><a href="${escape(target.back)}">← Back to ${escape(target.name)}</a></small>` : "";
|
|
95
|
+
const me = await ctx.account().catch(() => null);
|
|
96
|
+
if (!me) {
|
|
97
|
+
send(response, 200, page(`Follow ${target.name}`, `<h1>Follow ${escape(target.name)}</h1>
|
|
98
|
+
<p class="by">Sign in to nixamp.com to follow the artists you hear on the radio.</p>
|
|
99
|
+
<a class="go" href="/radio#music">Sign in to nixamp.com</a>
|
|
100
|
+
<small>Signed in, open this page again (or press Follow on the artist's page) to follow.</small>${back}`), method);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const [following, count] = await Promise.all([ctx.follows.isFollowing(me.id, target.id), ctx.follows.followerCount(target.id).catch(() => 0)]);
|
|
104
|
+
const hidden = `<input type="hidden" name="id" value="${escape(target.id)}"><input type="hidden" name="name" value="${escape(target.name)}">${target.back ? `<input type="hidden" name="back" value="${escape(target.back)}">` : ""}`;
|
|
105
|
+
send(response, 200, page(`Follow ${target.name}`, `<h1>${escape(target.name)}</h1>
|
|
106
|
+
<p class="by">${following ? "You follow them." : "Follow them to keep them with your follows on nixamp.com."} ${count} follower${count === 1 ? "" : "s"}.</p>
|
|
107
|
+
<form method="post" action="/follow">${hidden}<input type="hidden" name="act" value="${following ? "unfollow" : "follow"}">
|
|
108
|
+
<button type="submit"${following ? ' class="quiet"' : ""}>${following ? "Unfollow" : `Follow ${escape(target.name)}`}</button></form>
|
|
109
|
+
${back}`), method);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import type { Station } from "./stations.ts";
|
|
3
|
+
/** A station's own paths, as the station link opens them. */
|
|
4
|
+
export declare const RADIO_SUB: RegExp;
|
|
5
|
+
export declare const RADIO_STATION: RegExp;
|
|
6
|
+
export declare const RADIO_ARTIST: RegExp;
|
|
7
|
+
export declare const RADIO_ALBUM: RegExp;
|
|
8
|
+
export declare function isRadioPath(path: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Public, listed stations by name. Oldest first, so a name stays with the
|
|
11
|
+
* station that had it; a second "Jazz" is jazz-2.
|
|
12
|
+
*/
|
|
13
|
+
export declare function radioStations(list: Station[]): Map<string, Station>;
|
|
14
|
+
/** A station's /radio name, or "" when it is not public and listed. */
|
|
15
|
+
export declare function radioSlugOf(stations: Map<string, Station>, id: string): string;
|
|
16
|
+
export interface RadioTrack {
|
|
17
|
+
title: string;
|
|
18
|
+
seconds: number;
|
|
19
|
+
n: number;
|
|
20
|
+
lyrics?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RadioAlbum {
|
|
23
|
+
slug: string;
|
|
24
|
+
title: string;
|
|
25
|
+
year: number;
|
|
26
|
+
artist: string;
|
|
27
|
+
artistName: string;
|
|
28
|
+
concept: string;
|
|
29
|
+
complete: boolean;
|
|
30
|
+
/** The AI label's keys: its cover and its store. */
|
|
31
|
+
label?: {
|
|
32
|
+
artist: string;
|
|
33
|
+
album: string;
|
|
34
|
+
cover: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** A file of this album's with a picture in it (never shown; read for /cover.jpg). */
|
|
37
|
+
coverFile?: string;
|
|
38
|
+
tracks: RadioTrack[];
|
|
39
|
+
stations: string[];
|
|
40
|
+
}
|
|
41
|
+
export interface RadioArtist {
|
|
42
|
+
slug: string;
|
|
43
|
+
name: string;
|
|
44
|
+
label?: {
|
|
45
|
+
key: string;
|
|
46
|
+
genre: string;
|
|
47
|
+
bio: string;
|
|
48
|
+
};
|
|
49
|
+
albums: string[];
|
|
50
|
+
/** Songs with no album in their tags. */
|
|
51
|
+
singles: RadioTrack[];
|
|
52
|
+
stations: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface RadioCatalogue {
|
|
55
|
+
artists: Map<string, RadioArtist>;
|
|
56
|
+
albums: Map<string, RadioAlbum>;
|
|
57
|
+
}
|
|
58
|
+
export interface LabelView {
|
|
59
|
+
holder: string;
|
|
60
|
+
genres: Array<{
|
|
61
|
+
genre: string;
|
|
62
|
+
station: string;
|
|
63
|
+
artists: Array<{
|
|
64
|
+
key: string;
|
|
65
|
+
name: string;
|
|
66
|
+
genre: string;
|
|
67
|
+
bio: string;
|
|
68
|
+
albums: Array<{
|
|
69
|
+
key: string;
|
|
70
|
+
title: string;
|
|
71
|
+
year: number;
|
|
72
|
+
concept: string;
|
|
73
|
+
cover: boolean;
|
|
74
|
+
complete: boolean;
|
|
75
|
+
songs: Array<{
|
|
76
|
+
n: number;
|
|
77
|
+
title: string;
|
|
78
|
+
seconds: number;
|
|
79
|
+
lyrics: string;
|
|
80
|
+
}>;
|
|
81
|
+
}>;
|
|
82
|
+
}>;
|
|
83
|
+
}>;
|
|
84
|
+
}
|
|
85
|
+
export interface FileTags {
|
|
86
|
+
title: string;
|
|
87
|
+
artist: string;
|
|
88
|
+
album: string;
|
|
89
|
+
year: number;
|
|
90
|
+
track: number;
|
|
91
|
+
duration: number;
|
|
92
|
+
hasArt: boolean;
|
|
93
|
+
}
|
|
94
|
+
export interface CatalogueInput {
|
|
95
|
+
stations: Map<string, Station>;
|
|
96
|
+
label?: LabelView;
|
|
97
|
+
/** The label song at a file (after audioFor), or null. */
|
|
98
|
+
songAt?: (file: string) => {
|
|
99
|
+
artistName: string;
|
|
100
|
+
albumTitle: string;
|
|
101
|
+
} | null;
|
|
102
|
+
/** A label music video's song file; the file itself otherwise. */
|
|
103
|
+
audioFor?: (file: string) => string;
|
|
104
|
+
/** What the library knows about a file, or null. */
|
|
105
|
+
tags: (file: string) => FileTags | null;
|
|
106
|
+
}
|
|
107
|
+
export declare function buildCatalogue(input: CatalogueInput): RadioCatalogue;
|
|
108
|
+
/**
|
|
109
|
+
* Rewrites what a station's routes answer, for /radio/<name>/...: the
|
|
110
|
+
* station's own addresses become the /radio ones and its token goes. Only
|
|
111
|
+
* text (JSON, events, a playlist); sound and pictures pass as they are.
|
|
112
|
+
*/
|
|
113
|
+
export declare function rewriteResponse(response: ServerResponse, rewrite: (text: string) => string): void;
|
|
114
|
+
/** The station's addresses and token, as /radio/<name> says them instead. */
|
|
115
|
+
export declare function radioRewriter(station: {
|
|
116
|
+
id: string;
|
|
117
|
+
listenToken: string;
|
|
118
|
+
}, slug: string): (text: string) => string;
|
|
119
|
+
/** The live.streaming header every /radio page carries, so a listener can get anywhere from one. */
|
|
120
|
+
export declare const NAV_STYLE = ".radio-nav{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;max-width:960px;margin:0 auto;padding:14px 16px;font:600 15px/1.4 system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.radio-nav a{color:inherit;text-decoration:none;opacity:.8}.radio-nav a:hover,.radio-nav a[aria-current=page]{opacity:1;text-decoration:underline}\n.radio-nav .brand{font-weight:800;opacity:1;margin-right:auto}";
|
|
121
|
+
export declare function radioNav(home: string, current?: "live" | "radio" | "label" | "vod" | ""): string;
|
|
122
|
+
/** No script at all: the pages are links and text. */
|
|
123
|
+
export declare const RADIO_CSP: string;
|
|
124
|
+
export interface PageMeta {
|
|
125
|
+
title: string;
|
|
126
|
+
description: string;
|
|
127
|
+
url: string;
|
|
128
|
+
image?: string;
|
|
129
|
+
type?: string;
|
|
130
|
+
}
|
|
131
|
+
export interface DirectoryEntry {
|
|
132
|
+
slug: string;
|
|
133
|
+
name: string;
|
|
134
|
+
description: string;
|
|
135
|
+
state: string;
|
|
136
|
+
kind: "audio" | "video";
|
|
137
|
+
group: string;
|
|
138
|
+
nowPlaying: {
|
|
139
|
+
title: string;
|
|
140
|
+
artist: string;
|
|
141
|
+
} | null;
|
|
142
|
+
}
|
|
143
|
+
export declare function renderDirectory(entries: DirectoryEntry[], base: string, nav: string): string;
|
|
144
|
+
export interface Shop {
|
|
145
|
+
/** nixamp.com, where the store and accounts are. */
|
|
146
|
+
site: string;
|
|
147
|
+
/** buyLink(...) bound to this server. */
|
|
148
|
+
buy: (kind: "song" | "album" | "artist", artist: string, album?: string, n?: number) => string;
|
|
149
|
+
prices: {
|
|
150
|
+
song: number;
|
|
151
|
+
album: number;
|
|
152
|
+
artist: number;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export declare function followLink(site: string, slug: string, name: string, back: string): string;
|
|
156
|
+
export declare function renderArtist(catalogue: RadioCatalogue, artist: RadioArtist, ctx: {
|
|
157
|
+
base: string;
|
|
158
|
+
nav: string;
|
|
159
|
+
shop: Shop | null;
|
|
160
|
+
site: string;
|
|
161
|
+
stationNames: Map<string, string>;
|
|
162
|
+
}): string;
|
|
163
|
+
export declare function renderAlbum(catalogue: RadioCatalogue, album: RadioAlbum, ctx: {
|
|
164
|
+
base: string;
|
|
165
|
+
nav: string;
|
|
166
|
+
shop: Shop | null;
|
|
167
|
+
site: string;
|
|
168
|
+
stationNames: Map<string, string>;
|
|
169
|
+
}): string;
|
|
170
|
+
export declare function renderMissing(what: string, base: string, nav: string): string;
|
|
171
|
+
/** A station page's head and header, for the built station app served at /radio/<name>. */
|
|
172
|
+
/** What a station page's head says: a station of ours or a peer's. */
|
|
173
|
+
export interface StationFace {
|
|
174
|
+
name: string;
|
|
175
|
+
description?: string;
|
|
176
|
+
video?: boolean;
|
|
177
|
+
}
|
|
178
|
+
export declare function stationHead(station: StationFace, slug: string, base: string): string;
|
|
179
|
+
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
180
|
+
export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string): string;
|
|
181
|
+
/** A station another server lists on this guide (a guide peer), as /radio reaches it. */
|
|
182
|
+
export interface PeerRadio {
|
|
183
|
+
origin: string;
|
|
184
|
+
id: string;
|
|
185
|
+
token: string;
|
|
186
|
+
name: string;
|
|
187
|
+
kind: "audio" | "video";
|
|
188
|
+
group: string;
|
|
189
|
+
state: string;
|
|
190
|
+
nowPlaying: {
|
|
191
|
+
title: string;
|
|
192
|
+
artist: string;
|
|
193
|
+
} | null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Peer stations by name, after this server's own (which keep theirs). Only a
|
|
197
|
+
* peer's own station link (its origin, /station/<id>?t=) is taken; the token
|
|
198
|
+
* stays on this server, which asks the peer for the listener.
|
|
199
|
+
*/
|
|
200
|
+
export declare function radioPeers(own: Iterable<string>, entries: Array<{
|
|
201
|
+
id?: string;
|
|
202
|
+
name: string;
|
|
203
|
+
kind: "audio" | "video";
|
|
204
|
+
group?: string;
|
|
205
|
+
state: string;
|
|
206
|
+
page: string;
|
|
207
|
+
nowPlaying: {
|
|
208
|
+
title: string;
|
|
209
|
+
artist: string;
|
|
210
|
+
} | null;
|
|
211
|
+
}>): Map<string, PeerRadio>;
|