nixamp 0.7.39 → 0.7.40

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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.39",
3
+ "version": "0.7.40",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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.
@@ -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.
@@ -1 +1 @@
1
- import{t as e}from"./index-BmkIXt5j.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-ComwKkzf.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};