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