nixamp 0.7.39 → 0.7.41
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/directory.d.ts +12 -0
- package/dist/directory.js +15 -0
- package/dist/favorites.d.ts +32 -0
- package/dist/favorites.js +65 -0
- package/dist/publish.d.ts +4 -0
- package/dist/publish.js +2 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +67 -0
- package/dist/session.js +19 -8
- package/package.json +1 -1
- package/src/directory.ts +27 -0
- package/src/favorites.ts +97 -0
- package/src/publish.ts +6 -0
- package/src/server.ts +72 -0
- package/src/session.ts +16 -7
- package/web/dist/assets/{hls-3VKVEQE3-0Qga7yPC.js → hls-3VKVEQE3-70uzupqn.js} +1 -1
- package/web/dist/assets/index-ComwKkzf.js +1 -0
- package/web/dist/assets/index-D3xGDAOd.css +1 -0
- package/web/dist/assets/{mpegts-DAqmqMgk.js → mpegts-DQqgM7pi.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-k3frrp-g.js → mpegts-LO6RVLD6-CzrQKX7m.js} +1 -1
- package/web/dist/index.html +33 -20
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-BmkIXt5j.js +0 -1
- package/web/dist/assets/index-PhllvN11.css +0 -1
package/dist/directory.d.ts
CHANGED
|
@@ -53,6 +53,14 @@ export interface Listing {
|
|
|
53
53
|
audio: string;
|
|
54
54
|
tracks: number;
|
|
55
55
|
nowPlaying: string;
|
|
56
|
+
/**
|
|
57
|
+
* Whether the server's own player is running. A listing used to say only
|
|
58
|
+
* what was loaded, so a stopped server read as a live stream of a film
|
|
59
|
+
* nobody was watching.
|
|
60
|
+
*/
|
|
61
|
+
playing: boolean;
|
|
62
|
+
/** The live channels on it, by name: what a visitor could actually watch. */
|
|
63
|
+
channels: string[];
|
|
56
64
|
/** Set by the directory from the request, never by the publisher. */
|
|
57
65
|
updatedAt: number;
|
|
58
66
|
/** When this stream first announced itself: the "started at" a caller hears. */
|
|
@@ -90,6 +98,10 @@ export interface Announcement {
|
|
|
90
98
|
audio?: string;
|
|
91
99
|
tracks: number;
|
|
92
100
|
nowPlaying: string;
|
|
101
|
+
/** Absent from an older publisher, which is read as "unknown, say playing". */
|
|
102
|
+
playing?: boolean;
|
|
103
|
+
/** Names of the live channels on it. Absent from an older publisher. */
|
|
104
|
+
channels?: string[];
|
|
93
105
|
}
|
|
94
106
|
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
95
107
|
export declare function clean(value: unknown, max: number): string;
|
package/dist/directory.js
CHANGED
|
@@ -20,6 +20,8 @@ export const DEFAULT_DIRECTORY = "https://nixamp.com";
|
|
|
20
20
|
export const ENDED_TTL_MS = 24 * 60 * 60 * 1000;
|
|
21
21
|
const MAX_NAME = 60;
|
|
22
22
|
const MAX_TRACK = 120;
|
|
23
|
+
/** How many channel names a listing carries. A multiview is four; eight is plenty. */
|
|
24
|
+
const MAX_CHANNELS = 8;
|
|
23
25
|
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
24
26
|
export function clean(value, max) {
|
|
25
27
|
if (typeof value !== "string")
|
|
@@ -75,6 +77,15 @@ export function parseAnnouncement(input) {
|
|
|
75
77
|
...(audio ? { audio } : {}),
|
|
76
78
|
tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
|
|
77
79
|
nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
|
|
80
|
+
...(typeof record["playing"] === "boolean" ? { playing: record["playing"] } : {}),
|
|
81
|
+
...(Array.isArray(record["channels"])
|
|
82
|
+
? {
|
|
83
|
+
channels: record["channels"]
|
|
84
|
+
.map((one) => clean(one, MAX_NAME))
|
|
85
|
+
.filter((one) => one !== "")
|
|
86
|
+
.slice(0, MAX_CHANNELS),
|
|
87
|
+
}
|
|
88
|
+
: {}),
|
|
78
89
|
};
|
|
79
90
|
}
|
|
80
91
|
/**
|
|
@@ -158,6 +169,10 @@ export class Directory {
|
|
|
158
169
|
audio: announcement.audio ?? existing?.audio ?? "",
|
|
159
170
|
tracks: announcement.tracks,
|
|
160
171
|
nowPlaying: announcement.nowPlaying,
|
|
172
|
+
// An older publisher says nothing about either; "playing" keeps what a
|
|
173
|
+
// listing always meant, and no channels is the honest empty list.
|
|
174
|
+
playing: announcement.playing ?? true,
|
|
175
|
+
channels: announcement.channels ?? [],
|
|
161
176
|
updatedAt: this.now(),
|
|
162
177
|
// A stream that never stopped keeps its original start. One that did
|
|
163
178
|
// starts again now, because that is what a caller is being told about.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The servers an account has hearted.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from following, which is about a person -- being told when they go
|
|
5
|
+
* live -- and from "your servers", which are the machines you run. A
|
|
6
|
+
* favourite is a place you like to go back to: somebody else's server, kept by
|
|
7
|
+
* its address, with the name it had when you hearted it so the list reads as
|
|
8
|
+
* names rather than URLs.
|
|
9
|
+
*
|
|
10
|
+
* Kept on nixamp.com against the account, like follows, and reached by the
|
|
11
|
+
* same session. Listening needs no account; remembering where you listened
|
|
12
|
+
* does.
|
|
13
|
+
*/
|
|
14
|
+
import type { Queryable } from "./follows.ts";
|
|
15
|
+
export interface Favorite {
|
|
16
|
+
url: string;
|
|
17
|
+
name: string;
|
|
18
|
+
addedAt: number;
|
|
19
|
+
}
|
|
20
|
+
/** A server address worth keeping: http(s), and nothing a browser cannot open. */
|
|
21
|
+
export declare function favoriteUrl(raw: unknown): string;
|
|
22
|
+
export declare class Favorites {
|
|
23
|
+
private readonly db;
|
|
24
|
+
private ready;
|
|
25
|
+
constructor(db: Queryable);
|
|
26
|
+
private ensure;
|
|
27
|
+
/** Heart it. Hearting it again keeps the newer name. */
|
|
28
|
+
add(accountId: string, url: string, name: string): Promise<boolean>;
|
|
29
|
+
remove(accountId: string, url: string): Promise<void>;
|
|
30
|
+
list(accountId: string): Promise<Favorite[]>;
|
|
31
|
+
has(accountId: string, url: string): Promise<boolean>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const SCHEMA = `
|
|
2
|
+
CREATE TABLE IF NOT EXISTS favorites (
|
|
3
|
+
account_id TEXT NOT NULL,
|
|
4
|
+
url TEXT NOT NULL,
|
|
5
|
+
name TEXT NOT NULL DEFAULT '',
|
|
6
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
7
|
+
PRIMARY KEY (account_id, url)
|
|
8
|
+
);
|
|
9
|
+
`;
|
|
10
|
+
const MAX_URL = 500;
|
|
11
|
+
const MAX_NAME = 60;
|
|
12
|
+
/** A server address worth keeping: http(s), and nothing a browser cannot open. */
|
|
13
|
+
export function favoriteUrl(raw) {
|
|
14
|
+
if (typeof raw !== "string")
|
|
15
|
+
return "";
|
|
16
|
+
const text = raw.trim().slice(0, MAX_URL);
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(text);
|
|
19
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
20
|
+
return "";
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class Favorites {
|
|
28
|
+
db;
|
|
29
|
+
ready = null;
|
|
30
|
+
constructor(db) {
|
|
31
|
+
this.db = db;
|
|
32
|
+
}
|
|
33
|
+
async ensure() {
|
|
34
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
35
|
+
await this.ready;
|
|
36
|
+
}
|
|
37
|
+
/** Heart it. Hearting it again keeps the newer name. */
|
|
38
|
+
async add(accountId, url, name) {
|
|
39
|
+
const address = favoriteUrl(url);
|
|
40
|
+
if (!accountId || !address)
|
|
41
|
+
return false;
|
|
42
|
+
await this.ensure();
|
|
43
|
+
await this.db.query(`INSERT INTO favorites (account_id, url, name) VALUES ($1, $2, $3)
|
|
44
|
+
ON CONFLICT (account_id, url) DO UPDATE SET name = EXCLUDED.name`, [accountId, address, String(name ?? "").trim().slice(0, MAX_NAME)]);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
async remove(accountId, url) {
|
|
48
|
+
await this.ensure();
|
|
49
|
+
await this.db.query("DELETE FROM favorites WHERE account_id = $1 AND url = $2", [accountId, url]);
|
|
50
|
+
}
|
|
51
|
+
async list(accountId) {
|
|
52
|
+
await this.ensure();
|
|
53
|
+
const { rows } = await this.db.query("SELECT url, name, created_at FROM favorites WHERE account_id = $1 ORDER BY created_at", [accountId]);
|
|
54
|
+
return rows.map((row) => ({
|
|
55
|
+
url: String(row["url"] ?? ""),
|
|
56
|
+
name: String(row["name"] ?? ""),
|
|
57
|
+
addedAt: new Date(String(row["created_at"] ?? 0)).getTime() || 0,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
async has(accountId, url) {
|
|
61
|
+
await this.ensure();
|
|
62
|
+
const { rows } = await this.db.query("SELECT 1 FROM favorites WHERE account_id = $1 AND url = $2", [accountId, url]);
|
|
63
|
+
return rows.length > 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/publish.d.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface PublishTarget {
|
|
|
21
21
|
*/
|
|
22
22
|
tracks: () => number;
|
|
23
23
|
nowPlaying: () => string;
|
|
24
|
+
/** Whether the player is actually running, so a stopped server is not listed as live. */
|
|
25
|
+
playing?: () => boolean;
|
|
26
|
+
/** The live channels on this server, by name, for the listing to show. */
|
|
27
|
+
channels?: () => string[];
|
|
24
28
|
/**
|
|
25
29
|
* The account this stream belongs to, from `nixamp login`.
|
|
26
30
|
*
|
package/dist/publish.js
CHANGED
|
@@ -60,6 +60,8 @@ export class Publisher {
|
|
|
60
60
|
...(this.target.audio ? { audio: this.target.audio } : {}),
|
|
61
61
|
tracks: this.target.tracks(),
|
|
62
62
|
nowPlaying: this.target.nowPlaying(),
|
|
63
|
+
...(this.target.playing ? { playing: this.target.playing() } : {}),
|
|
64
|
+
...(this.target.channels ? { channels: this.target.channels() } : {}),
|
|
63
65
|
}),
|
|
64
66
|
});
|
|
65
67
|
if (!response.ok) {
|
package/dist/server.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Owner } from "./owner.ts";
|
|
|
12
12
|
import { Directory } from "./directory.ts";
|
|
13
13
|
import { PartyLine } from "./partyline.ts";
|
|
14
14
|
import { Follows } from "./follows.ts";
|
|
15
|
+
import { Favorites } from "./favorites.ts";
|
|
15
16
|
import { type Tools, type Track } from "./audio.ts";
|
|
16
17
|
import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
|
|
17
18
|
export declare const SERVE_BAND_COUNT = 24;
|
|
@@ -508,6 +509,8 @@ export interface HandlerOptions {
|
|
|
508
509
|
* the stream.
|
|
509
510
|
*/
|
|
510
511
|
follows?: Follows;
|
|
512
|
+
/** The servers an account hearted. nixamp.com only, like follows. */
|
|
513
|
+
favorites?: Favorites;
|
|
511
514
|
/** The VAPID public key a browser needs before it can subscribe. */
|
|
512
515
|
vapidPublicKey?: string;
|
|
513
516
|
}
|
package/dist/server.js
CHANGED
|
@@ -37,6 +37,7 @@ import { PartyLine, telnyxSms } from "./partyline.js";
|
|
|
37
37
|
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
|
|
38
38
|
import pg from "pg";
|
|
39
39
|
import { Follows, phoneFrom } from "./follows.js";
|
|
40
|
+
import { Favorites, favoriteUrl } from "./favorites.js";
|
|
40
41
|
import { Durable } from "./durable.js";
|
|
41
42
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
42
43
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
@@ -910,6 +911,65 @@ export function createHandler(engine, options) {
|
|
|
910
911
|
// Behind the sign-in rather than the share key: a follow belongs to an
|
|
911
912
|
// account, and an account is the only thing that makes "notify me on my
|
|
912
913
|
// other device" mean anything.
|
|
914
|
+
// Favourites: the servers you hearted, kept against your account. Reading
|
|
915
|
+
// the directory and listening need no account; remembering where you
|
|
916
|
+
// listened does, because there has to be somebody to remember it for.
|
|
917
|
+
if (path === "/api/v1/favorites" && options.favorites && options.accounts) {
|
|
918
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
919
|
+
if (me === null) {
|
|
920
|
+
json(response, 401, { error: "sign in to keep favourites" });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const favorites = options.favorites;
|
|
924
|
+
if (request.method === "GET") {
|
|
925
|
+
const list = await favorites.list(me.id);
|
|
926
|
+
// Whether each is on right now, from the directory: a favourite that
|
|
927
|
+
// is live is the one to click first.
|
|
928
|
+
const live = new Map((options.directory?.list() ?? []).map((one) => [one.url, one]));
|
|
929
|
+
json(response, 200, {
|
|
930
|
+
favorites: list.map((one) => {
|
|
931
|
+
const on = live.get(one.url);
|
|
932
|
+
return {
|
|
933
|
+
...one,
|
|
934
|
+
live: on !== undefined,
|
|
935
|
+
nowPlaying: on?.playing ? on.nowPlaying : "",
|
|
936
|
+
channels: on?.channels ?? [],
|
|
937
|
+
};
|
|
938
|
+
}),
|
|
939
|
+
});
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
943
|
+
let body = {};
|
|
944
|
+
try {
|
|
945
|
+
body = JSON.parse(await readBody(request));
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
json(response, 400, { error: "bad JSON" });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const address = favoriteUrl(body.url);
|
|
952
|
+
if (!address) {
|
|
953
|
+
json(response, 400, { error: "give the server's address" });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
await favorites.add(me.id, address, typeof body.name === "string" ? body.name : "");
|
|
957
|
+
json(response, 200, { favorite: true });
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (request.method === "DELETE") {
|
|
961
|
+
const address = favoriteUrl(url.searchParams.get("url"));
|
|
962
|
+
if (!address) {
|
|
963
|
+
json(response, 400, { error: "give the server's address" });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
await favorites.remove(me.id, address);
|
|
967
|
+
json(response, 200, { favorite: false });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
json(response, 405, { error: "GET, PUT or DELETE" });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
913
973
|
if (path.startsWith("/api/v1/follows") && options.follows && options.accounts) {
|
|
914
974
|
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
915
975
|
if (me === null) {
|
|
@@ -2865,6 +2925,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2865
2925
|
? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
|
|
2866
2926
|
: undefined;
|
|
2867
2927
|
const follows = pool ? new Follows(pool) : undefined;
|
|
2928
|
+
const favorites = pool ? new Favorites(pool) : undefined;
|
|
2868
2929
|
// The two things that were promises kept only in memory: a caller who was
|
|
2869
2930
|
// told they would be texted, and the ended stream a code still points at.
|
|
2870
2931
|
const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
|
|
@@ -3088,6 +3149,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3088
3149
|
tag: (next) => loadTagged(tools, next),
|
|
3089
3150
|
...(directory ? { directory } : {}),
|
|
3090
3151
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
3152
|
+
...(favorites ? { favorites } : {}),
|
|
3091
3153
|
...(partyLine ? { partyLine } : {}),
|
|
3092
3154
|
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
3093
3155
|
// laptop has nobody to be an account of.
|
|
@@ -3338,6 +3400,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3338
3400
|
// Asked at every heartbeat rather than once, because the library is
|
|
3339
3401
|
// read after the port opens and is still arriving when this is made.
|
|
3340
3402
|
tracks: () => engine.snapshot(false).trackCount,
|
|
3403
|
+
// What a visitor would find here: whether the player is running, and
|
|
3404
|
+
// which channels are on. A directory row that says "5,717 files,
|
|
3405
|
+
// live: CNN" is one somebody can decide about; "5717 tracks" was not.
|
|
3406
|
+
playing: () => engine.snapshot(false).playing,
|
|
3407
|
+
channels: () => channels.list().map((one) => one.name),
|
|
3341
3408
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
3342
3409
|
// attribute to somebody, because a listing is now a phone code that
|
|
3343
3410
|
// costs money to answer.
|
package/dist/session.js
CHANGED
|
@@ -357,20 +357,31 @@ export async function chooseWay(ways, options) {
|
|
|
357
357
|
if (options.device)
|
|
358
358
|
return ways.device ? "device" : null;
|
|
359
359
|
// Naming an address is asking for the password flow by implication.
|
|
360
|
-
if (!ways.device || options.email
|
|
361
|
-
return "password";
|
|
362
|
-
if (ways.providers.length === 0)
|
|
360
|
+
if (!ways.device || options.email)
|
|
363
361
|
return "password";
|
|
362
|
+
// A site with no providers configured still has a browser to approve in,
|
|
363
|
+
// and that is the way in from a terminal: a code, a page, a click. This
|
|
364
|
+
// used to fall through to a password prompt the moment the provider list
|
|
365
|
+
// was empty, which is what nixamp.com answers, so nobody ever saw the
|
|
366
|
+
// browser flow that was built for exactly this. With no terminal to draw
|
|
367
|
+
// a menu on, the browser page offers the providers instead.
|
|
368
|
+
if (ways.providers.length === 0 || !process.stdin.isTTY)
|
|
369
|
+
return "device";
|
|
364
370
|
console.log("How would you like to sign in?");
|
|
365
371
|
ways.providers.forEach((provider, index) => console.log(` ${index + 1}) ${provider.name}`));
|
|
366
|
-
console.log(` ${ways.providers.length + 1})
|
|
372
|
+
console.log(` ${ways.providers.length + 1}) In a browser you are already signed in to`);
|
|
373
|
+
console.log(` ${ways.providers.length + 2}) Email and password`);
|
|
367
374
|
const typed = await ask(`Choose [1]: `);
|
|
368
375
|
const picked = typed === "" ? 1 : Number(typed);
|
|
369
|
-
if (!Number.isInteger(picked) || picked < 1 || picked > ways.providers.length +
|
|
370
|
-
console.log("Not one of those, so:
|
|
371
|
-
return "
|
|
376
|
+
if (!Number.isInteger(picked) || picked < 1 || picked > ways.providers.length + 2) {
|
|
377
|
+
console.log("Not one of those, so: in a browser.");
|
|
378
|
+
return "device";
|
|
372
379
|
}
|
|
373
|
-
|
|
380
|
+
if (picked === ways.providers.length + 1)
|
|
381
|
+
return "device";
|
|
382
|
+
if (picked === ways.providers.length + 2)
|
|
383
|
+
return "password";
|
|
384
|
+
return ways.providers[picked - 1]?.id ?? "device";
|
|
374
385
|
}
|
|
375
386
|
const day = (at) => (at ? new Date(at).toISOString().slice(0, 10) : "never");
|
|
376
387
|
/**
|
package/package.json
CHANGED
package/src/directory.ts
CHANGED
|
@@ -57,6 +57,14 @@ export interface Listing {
|
|
|
57
57
|
audio: string;
|
|
58
58
|
tracks: number;
|
|
59
59
|
nowPlaying: string;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the server's own player is running. A listing used to say only
|
|
62
|
+
* what was loaded, so a stopped server read as a live stream of a film
|
|
63
|
+
* nobody was watching.
|
|
64
|
+
*/
|
|
65
|
+
playing: boolean;
|
|
66
|
+
/** The live channels on it, by name: what a visitor could actually watch. */
|
|
67
|
+
channels: string[];
|
|
60
68
|
/** Set by the directory from the request, never by the publisher. */
|
|
61
69
|
updatedAt: number;
|
|
62
70
|
/** When this stream first announced itself: the "started at" a caller hears. */
|
|
@@ -97,10 +105,16 @@ export interface Announcement {
|
|
|
97
105
|
audio?: string;
|
|
98
106
|
tracks: number;
|
|
99
107
|
nowPlaying: string;
|
|
108
|
+
/** Absent from an older publisher, which is read as "unknown, say playing". */
|
|
109
|
+
playing?: boolean;
|
|
110
|
+
/** Names of the live channels on it. Absent from an older publisher. */
|
|
111
|
+
channels?: string[];
|
|
100
112
|
}
|
|
101
113
|
|
|
102
114
|
const MAX_NAME = 60;
|
|
103
115
|
const MAX_TRACK = 120;
|
|
116
|
+
/** How many channel names a listing carries. A multiview is four; eight is plenty. */
|
|
117
|
+
const MAX_CHANNELS = 8;
|
|
104
118
|
|
|
105
119
|
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
106
120
|
export function clean(value: unknown, max: number): string {
|
|
@@ -156,6 +170,15 @@ export function parseAnnouncement(input: unknown): Announcement | null {
|
|
|
156
170
|
...(audio ? { audio } : {}),
|
|
157
171
|
tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
|
|
158
172
|
nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
|
|
173
|
+
...(typeof record["playing"] === "boolean" ? { playing: record["playing"] } : {}),
|
|
174
|
+
...(Array.isArray(record["channels"])
|
|
175
|
+
? {
|
|
176
|
+
channels: (record["channels"] as unknown[])
|
|
177
|
+
.map((one) => clean(one, MAX_NAME))
|
|
178
|
+
.filter((one) => one !== "")
|
|
179
|
+
.slice(0, MAX_CHANNELS),
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
159
182
|
};
|
|
160
183
|
}
|
|
161
184
|
|
|
@@ -240,6 +263,10 @@ export class Directory {
|
|
|
240
263
|
audio: announcement.audio ?? existing?.audio ?? "",
|
|
241
264
|
tracks: announcement.tracks,
|
|
242
265
|
nowPlaying: announcement.nowPlaying,
|
|
266
|
+
// An older publisher says nothing about either; "playing" keeps what a
|
|
267
|
+
// listing always meant, and no channels is the honest empty list.
|
|
268
|
+
playing: announcement.playing ?? true,
|
|
269
|
+
channels: announcement.channels ?? [],
|
|
243
270
|
updatedAt: this.now(),
|
|
244
271
|
// A stream that never stopped keeps its original start. One that did
|
|
245
272
|
// starts again now, because that is what a caller is being told about.
|
package/src/favorites.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The servers an account has hearted.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from following, which is about a person -- being told when they go
|
|
5
|
+
* live -- and from "your servers", which are the machines you run. A
|
|
6
|
+
* favourite is a place you like to go back to: somebody else's server, kept by
|
|
7
|
+
* its address, with the name it had when you hearted it so the list reads as
|
|
8
|
+
* names rather than URLs.
|
|
9
|
+
*
|
|
10
|
+
* Kept on nixamp.com against the account, like follows, and reached by the
|
|
11
|
+
* same session. Listening needs no account; remembering where you listened
|
|
12
|
+
* does.
|
|
13
|
+
*/
|
|
14
|
+
import type { Queryable } from "./follows.ts";
|
|
15
|
+
|
|
16
|
+
export interface Favorite {
|
|
17
|
+
url: string;
|
|
18
|
+
name: string;
|
|
19
|
+
addedAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SCHEMA = `
|
|
23
|
+
CREATE TABLE IF NOT EXISTS favorites (
|
|
24
|
+
account_id TEXT NOT NULL,
|
|
25
|
+
url TEXT NOT NULL,
|
|
26
|
+
name TEXT NOT NULL DEFAULT '',
|
|
27
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
28
|
+
PRIMARY KEY (account_id, url)
|
|
29
|
+
);
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const MAX_URL = 500;
|
|
33
|
+
const MAX_NAME = 60;
|
|
34
|
+
|
|
35
|
+
/** A server address worth keeping: http(s), and nothing a browser cannot open. */
|
|
36
|
+
export function favoriteUrl(raw: unknown): string {
|
|
37
|
+
if (typeof raw !== "string") return "";
|
|
38
|
+
const text = raw.trim().slice(0, MAX_URL);
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(text);
|
|
41
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
42
|
+
return text;
|
|
43
|
+
} catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class Favorites {
|
|
49
|
+
private ready: Promise<void> | null = null;
|
|
50
|
+
|
|
51
|
+
constructor(private readonly db: Queryable) {}
|
|
52
|
+
|
|
53
|
+
private async ensure(): Promise<void> {
|
|
54
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
55
|
+
await this.ready;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Heart it. Hearting it again keeps the newer name. */
|
|
59
|
+
async add(accountId: string, url: string, name: string): Promise<boolean> {
|
|
60
|
+
const address = favoriteUrl(url);
|
|
61
|
+
if (!accountId || !address) return false;
|
|
62
|
+
await this.ensure();
|
|
63
|
+
await this.db.query(
|
|
64
|
+
`INSERT INTO favorites (account_id, url, name) VALUES ($1, $2, $3)
|
|
65
|
+
ON CONFLICT (account_id, url) DO UPDATE SET name = EXCLUDED.name`,
|
|
66
|
+
[accountId, address, String(name ?? "").trim().slice(0, MAX_NAME)],
|
|
67
|
+
);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async remove(accountId: string, url: string): Promise<void> {
|
|
72
|
+
await this.ensure();
|
|
73
|
+
await this.db.query("DELETE FROM favorites WHERE account_id = $1 AND url = $2", [accountId, url]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async list(accountId: string): Promise<Favorite[]> {
|
|
77
|
+
await this.ensure();
|
|
78
|
+
const { rows } = await this.db.query(
|
|
79
|
+
"SELECT url, name, created_at FROM favorites WHERE account_id = $1 ORDER BY created_at",
|
|
80
|
+
[accountId],
|
|
81
|
+
);
|
|
82
|
+
return rows.map((row) => ({
|
|
83
|
+
url: String(row["url"] ?? ""),
|
|
84
|
+
name: String(row["name"] ?? ""),
|
|
85
|
+
addedAt: new Date(String(row["created_at"] ?? 0)).getTime() || 0,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async has(accountId: string, url: string): Promise<boolean> {
|
|
90
|
+
await this.ensure();
|
|
91
|
+
const { rows } = await this.db.query(
|
|
92
|
+
"SELECT 1 FROM favorites WHERE account_id = $1 AND url = $2",
|
|
93
|
+
[accountId, url],
|
|
94
|
+
);
|
|
95
|
+
return rows.length > 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/publish.ts
CHANGED
|
@@ -30,6 +30,10 @@ export interface PublishTarget {
|
|
|
30
30
|
*/
|
|
31
31
|
tracks: () => number;
|
|
32
32
|
nowPlaying: () => string;
|
|
33
|
+
/** Whether the player is actually running, so a stopped server is not listed as live. */
|
|
34
|
+
playing?: () => boolean;
|
|
35
|
+
/** The live channels on this server, by name, for the listing to show. */
|
|
36
|
+
channels?: () => string[];
|
|
33
37
|
/**
|
|
34
38
|
* The account this stream belongs to, from `nixamp login`.
|
|
35
39
|
*
|
|
@@ -101,6 +105,8 @@ export class Publisher {
|
|
|
101
105
|
...(this.target.audio ? { audio: this.target.audio } : {}),
|
|
102
106
|
tracks: this.target.tracks(),
|
|
103
107
|
nowPlaying: this.target.nowPlaying(),
|
|
108
|
+
...(this.target.playing ? { playing: this.target.playing() } : {}),
|
|
109
|
+
...(this.target.channels ? { channels: this.target.channels() } : {}),
|
|
104
110
|
}),
|
|
105
111
|
});
|
|
106
112
|
if (!response.ok) {
|
package/src/server.ts
CHANGED
|
@@ -54,6 +54,7 @@ import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
|
54
54
|
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
|
|
55
55
|
import pg from "pg";
|
|
56
56
|
import { Follows, phoneFrom } from "./follows.ts";
|
|
57
|
+
import { Favorites, favoriteUrl } from "./favorites.ts";
|
|
57
58
|
import { Durable } from "./durable.ts";
|
|
58
59
|
import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
|
|
59
60
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
@@ -1168,6 +1169,8 @@ export interface HandlerOptions {
|
|
|
1168
1169
|
* the stream.
|
|
1169
1170
|
*/
|
|
1170
1171
|
follows?: Follows;
|
|
1172
|
+
/** The servers an account hearted. nixamp.com only, like follows. */
|
|
1173
|
+
favorites?: Favorites;
|
|
1171
1174
|
/** The VAPID public key a browser needs before it can subscribe. */
|
|
1172
1175
|
vapidPublicKey?: string;
|
|
1173
1176
|
}
|
|
@@ -1258,6 +1261,68 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1258
1261
|
// Behind the sign-in rather than the share key: a follow belongs to an
|
|
1259
1262
|
// account, and an account is the only thing that makes "notify me on my
|
|
1260
1263
|
// other device" mean anything.
|
|
1264
|
+
// Favourites: the servers you hearted, kept against your account. Reading
|
|
1265
|
+
// the directory and listening need no account; remembering where you
|
|
1266
|
+
// listened does, because there has to be somebody to remember it for.
|
|
1267
|
+
if (path === "/api/v1/favorites" && options.favorites && options.accounts) {
|
|
1268
|
+
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1269
|
+
if (me === null) {
|
|
1270
|
+
json(response, 401, { error: "sign in to keep favourites" });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
const favorites = options.favorites;
|
|
1274
|
+
|
|
1275
|
+
if (request.method === "GET") {
|
|
1276
|
+
const list = await favorites.list(me.id);
|
|
1277
|
+
// Whether each is on right now, from the directory: a favourite that
|
|
1278
|
+
// is live is the one to click first.
|
|
1279
|
+
const live = new Map((options.directory?.list() ?? []).map((one) => [one.url, one]));
|
|
1280
|
+
json(response, 200, {
|
|
1281
|
+
favorites: list.map((one) => {
|
|
1282
|
+
const on = live.get(one.url);
|
|
1283
|
+
return {
|
|
1284
|
+
...one,
|
|
1285
|
+
live: on !== undefined,
|
|
1286
|
+
nowPlaying: on?.playing ? on.nowPlaying : "",
|
|
1287
|
+
channels: on?.channels ?? [],
|
|
1288
|
+
};
|
|
1289
|
+
}),
|
|
1290
|
+
});
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
1295
|
+
let body: { url?: unknown; name?: unknown } = {};
|
|
1296
|
+
try {
|
|
1297
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
1298
|
+
} catch {
|
|
1299
|
+
json(response, 400, { error: "bad JSON" });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const address = favoriteUrl(body.url);
|
|
1303
|
+
if (!address) {
|
|
1304
|
+
json(response, 400, { error: "give the server's address" });
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
await favorites.add(me.id, address, typeof body.name === "string" ? body.name : "");
|
|
1308
|
+
json(response, 200, { favorite: true });
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (request.method === "DELETE") {
|
|
1313
|
+
const address = favoriteUrl(url.searchParams.get("url"));
|
|
1314
|
+
if (!address) {
|
|
1315
|
+
json(response, 400, { error: "give the server's address" });
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
await favorites.remove(me.id, address);
|
|
1319
|
+
json(response, 200, { favorite: false });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
json(response, 405, { error: "GET, PUT or DELETE" });
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1261
1326
|
if (path.startsWith("/api/v1/follows") && options.follows && options.accounts) {
|
|
1262
1327
|
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
1263
1328
|
if (me === null) {
|
|
@@ -3350,6 +3415,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3350
3415
|
? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
|
|
3351
3416
|
: undefined;
|
|
3352
3417
|
const follows = pool ? new Follows(pool) : undefined;
|
|
3418
|
+
const favorites = pool ? new Favorites(pool) : undefined;
|
|
3353
3419
|
// The two things that were promises kept only in memory: a caller who was
|
|
3354
3420
|
// told they would be texted, and the ended stream a code still points at.
|
|
3355
3421
|
const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
|
|
@@ -3585,6 +3651,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3585
3651
|
tag: (next) => loadTagged(tools, next),
|
|
3586
3652
|
...(directory ? { directory } : {}),
|
|
3587
3653
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
3654
|
+
...(favorites ? { favorites } : {}),
|
|
3588
3655
|
...(partyLine ? { partyLine } : {}),
|
|
3589
3656
|
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
3590
3657
|
// laptop has nobody to be an account of.
|
|
@@ -3858,6 +3925,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3858
3925
|
// Asked at every heartbeat rather than once, because the library is
|
|
3859
3926
|
// read after the port opens and is still arriving when this is made.
|
|
3860
3927
|
tracks: () => engine.snapshot(false).trackCount,
|
|
3928
|
+
// What a visitor would find here: whether the player is running, and
|
|
3929
|
+
// which channels are on. A directory row that says "5,717 files,
|
|
3930
|
+
// live: CNN" is one somebody can decide about; "5717 tracks" was not.
|
|
3931
|
+
playing: () => engine.snapshot(false).playing,
|
|
3932
|
+
channels: () => channels.list().map((one) => one.name),
|
|
3861
3933
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
3862
3934
|
// attribute to somebody, because a listing is now a phone code that
|
|
3863
3935
|
// costs money to answer.
|