A music player that connects to your cloud/distributed storage.
1import { ostiary, rpc } from "~/common/worker.js";
2import { detach as detachUtil, groupKey } from "~/components/input/common.js";
3
4import {
5 consultStreamCached,
6 fetchMetadata,
7 groupTracksByHost,
8 groupUrisByHost,
9 parseURI,
10} from "./common.js";
11import { SCHEME } from "./constants.js";
12
13/**
14 * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts";
15 */
16
17////////////////////////////////////////////
18// ACTIONS
19////////////////////////////////////////////
20
21/**
22 * @type {Actions['consult']}
23 */
24export async function consult(fileUriOrScheme) {
25 if (!fileUriOrScheme.includes(":")) {
26 return { supported: true, consult: "undetermined" };
27 }
28
29 const parsed = parseURI(fileUriOrScheme);
30 if (!parsed) {
31 return { supported: false, reason: "Invalid Icecast URI" };
32 }
33
34 const available = await consultStreamCached(fileUriOrScheme);
35 return { supported: true, consult: available };
36}
37
38/**
39 * @type {Actions['detach']}
40 */
41export async function detach(args) {
42 return detachUtil({
43 ...args,
44
45 inputScheme: SCHEME,
46 handleFileUri: ({ fileURI, tracks }) => {
47 const result = parseURI(fileURI);
48 if (!result) return tracks;
49
50 const groups = groupTracksByHost(tracks);
51 delete groups[result.host];
52
53 return Object.values(groups).map((g) => g.tracks).flat(1);
54 },
55 });
56}
57
58/**
59 * @type {Actions['groupConsult']}
60 */
61export async function groupConsult(uris) {
62 const groups = groupUrisByHost(uris);
63
64 const promises = Object.entries(groups).map(
65 async ([_hostId, { host, uris }]) => {
66 const testUri = uris[0];
67 const available = testUri ? await consultStreamCached(testUri) : false;
68
69 /** @type {ConsultGrouping} */
70 const grouping = available
71 ? { available, scheme: SCHEME, uris }
72 : { available, reason: "Stream unreachable", scheme: SCHEME, uris };
73
74 return {
75 key: groupKey(SCHEME, host),
76 grouping,
77 };
78 },
79 );
80
81 const entries = (await Promise.all(promises)).map((entry) => [
82 entry.key,
83 entry.grouping,
84 ]);
85
86 return Object.fromEntries(entries);
87}
88
89/**
90 * @type {Actions['list']}
91 */
92export async function list(cachedTracks = []) {
93 const refreshed = await Promise.all(
94 cachedTracks.map(async (track) => {
95 const parsed = parseURI(track.uri);
96 if (!parsed) return track;
97
98 const metadata = await fetchMetadata(parsed.streamUrl);
99 if (!metadata) return track;
100
101 return {
102 ...track,
103 kind: /** @type {"stream"} */ ("stream"),
104 tags: {
105 ...track.tags,
106 title: metadata.name ?? track.tags?.title,
107 genres: metadata.genre ? [metadata.genre] : track.tags?.genres,
108 },
109 stats: {
110 ...track.stats,
111 // IcyMetadata.bitrate is in kbps; stats.bitrate is in bps
112 bitrate: metadata.bitrate
113 ? metadata.bitrate * 1000
114 : track.stats?.bitrate,
115 },
116 };
117 }),
118 );
119
120 return refreshed;
121}
122
123/**
124 * @type {Actions['resolve']}
125 */
126export async function resolve({ uri }) {
127 const parsed = parseURI(uri);
128 if (!parsed) return undefined;
129
130 const expiresInSeconds = 60 * 60 * 24 * 365; // 1 year
131 const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds;
132
133 return {
134 url: parsed.streamUrl,
135 expiresAt: expiresAtSeconds,
136 };
137}
138
139////////////////////////////////////////////
140// ⚡️
141////////////////////////////////////////////
142
143ostiary((context) => {
144 rpc(context, {
145 consult,
146 detach,
147 groupConsult,
148 list,
149 resolve,
150 });
151});