forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import * as TID from "@atcute/tid";
2import { ostiary, rpc } from "~/common/worker.js";
3import {
4 detach as detachUtil,
5 groupKey,
6 isAudioFile,
7} from "~/components/input/common.js";
8import {
9 bucketId,
10 buildURI,
11 consultBucketCached,
12 createClient,
13 groupTracksByBucket,
14 groupUrisByBucket,
15 parseURI,
16} from "./common.js";
17import { SCHEME } from "./constants.js";
18
19/**
20 * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts";
21 * @import { Track } from "~/definitions/types.d.ts"
22 * @import { Bucket, Demo } from "./types.d.ts"
23 */
24
25////////////////////////////////////////////
26// ACTIONS
27////////////////////////////////////////////
28
29/**
30 * @type {Actions['artwork']}
31 */
32export async function artwork(_uri) {
33 return null;
34}
35
36/**
37 * @type {Actions['consult']}
38 */
39export async function consult(fileUriOrScheme) {
40 if (!fileUriOrScheme.includes(":")) {
41 return { supported: true, consult: "undetermined" };
42 }
43
44 const parsed = parseURI(fileUriOrScheme);
45 if (!parsed) return { supported: true, consult: "undetermined" };
46
47 const consult = await consultBucketCached(parsed.bucket);
48 return { supported: true, consult };
49}
50
51/**
52 * @type {Actions['detach']}
53 */
54export async function detach(args) {
55 return detachUtil({
56 ...args,
57
58 inputScheme: SCHEME,
59 handleFileUri: ({ fileURI, tracks }) => {
60 const result = parseURI(fileURI);
61 if (!result) return tracks;
62
63 const bid = bucketId(result.bucket);
64 const groups = groupTracksByBucket(tracks);
65
66 delete groups[bid];
67
68 return Object.values(groups).map((a) => a.tracks).flat(1);
69 },
70 });
71}
72
73/**
74 * @type {Actions['groupConsult']}
75 */
76export async function groupConsult(uris) {
77 const groups = groupUrisByBucket(uris);
78
79 const promises = Object.entries(groups).map(
80 async ([bucketId, { bucket, uris }]) => {
81 const available = await consultBucketCached(bucket);
82
83 /** @type {ConsultGrouping} */
84 const grouping = available
85 ? { available, scheme: SCHEME, uris }
86 : { available, reason: "Bucket unavailable", scheme: SCHEME, uris };
87
88 return {
89 key: groupKey(SCHEME, bucketId),
90 grouping,
91 };
92 },
93 );
94
95 const entries = (await Promise.all(promises)).map((
96 entry,
97 ) => [entry.key, entry.grouping]);
98
99 return Object.fromEntries(entries);
100}
101
102/**
103 * @type {Actions['list']}
104 */
105export async function list(cachedTracks = []) {
106 /** @type {Record<string, Record<string, Track>>} */
107 const cache = {};
108
109 /** @type {Record<string, Bucket>} */
110 const buckets = {};
111
112 cachedTracks.forEach((t) => {
113 const parsed = parseURI(t.uri);
114 if (!parsed) return;
115
116 const bid = bucketId(parsed.bucket);
117 buckets[bid] = parsed.bucket;
118
119 if (cache[bid]) {
120 cache[bid][parsed.path] = t;
121 } else {
122 cache[bid] = { [parsed.path]: t };
123 }
124 });
125
126 const promises = Object.values(buckets).map(async (bucket) => {
127 const client = createClient(bucket);
128 const bid = bucketId(bucket);
129
130 const list = await Array.fromAsync(
131 client.listObjects({
132 prefix: bucket.path.replace(/^\//, ""),
133 }),
134 );
135
136 let tracks = list
137 .filter((l) => isAudioFile(l.key))
138 .map((l) => {
139 const cachedTrack = cache[bid]?.[l.key];
140
141 const id = cachedTrack?.id || TID.now();
142 const stats = cachedTrack?.stats;
143 const tags = cachedTrack?.tags;
144 const now = new Date().toISOString();
145
146 /** @type {Track} */
147 const track = {
148 $type: "sh.diffuse.output.track",
149 id,
150 createdAt: cachedTrack?.createdAt ?? now,
151 updatedAt: cachedTrack?.updatedAt ?? now,
152 stats,
153 tags,
154 uri: buildURI(bucket, l.key),
155 };
156
157 return track;
158 });
159
160 // If a bucket didn't have any tracks,
161 // keep a placeholder track so the bucket gets
162 // picked up as a source.
163 if (!tracks.length) {
164 const now = new Date().toISOString();
165
166 tracks = [{
167 $type: "sh.diffuse.output.track",
168 id: TID.now(),
169 createdAt: now,
170 updatedAt: now,
171 kind: "placeholder",
172 uri: buildURI(bucket),
173 }];
174 }
175
176 return tracks;
177 });
178
179 const tracks = (await Promise.all(promises)).flat(1);
180 return tracks;
181}
182
183/**
184 * @type {Actions['resolve']}
185 */
186export async function resolve(
187 { method, uri },
188) {
189 const parsed = parseURI(uri);
190 if (!parsed) return undefined;
191
192 const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days
193 const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds;
194
195 const client = createClient(parsed.bucket);
196 const url = await client.getPresignedUrl(
197 /** @type {any} */ (method?.toUpperCase() ?? "GET"),
198 parsed.path,
199 );
200
201 return { expiresAt: expiresAtSeconds, url };
202}
203
204////////////////////////////////////////////
205// ADDITIONAL ACTIONS
206////////////////////////////////////////////
207
208/**
209 * @returns {Demo}
210 */
211export function demo() {
212 // Credentials are read-only, no worries.
213
214 /** @type {Bucket} */
215 const bucket = {
216 accessKey: atob("QUtJQTZPUTNFVk1BWFZDRFFINkI="),
217 bucketName: "ongaku-ryoho-demo",
218 host: "s3.amazonaws.com",
219 path: "/",
220 region: "us-east-1",
221 secretKey: atob("Z0hPQkdHRzU1aXc0a0RDbjdjWlRJYTVTUDRZWnpERkRzQnFCYWI4Mg=="),
222 };
223
224 const uri = buildURI(bucket);
225 const now = new Date().toISOString();
226
227 /** @type {Track} */
228 const track = {
229 $type: "sh.diffuse.output.track",
230 id: TID.now(),
231 createdAt: now,
232 updatedAt: now,
233 kind: "placeholder",
234 uri,
235 };
236
237 return {
238 bucket,
239 track,
240 };
241}
242
243////////////////////////////////////////////
244// ⚡️
245////////////////////////////////////////////
246
247ostiary((context) => {
248 // Setup RPC
249
250 rpc(context, {
251 artwork,
252 consult,
253 detach,
254 groupConsult,
255 list,
256 resolve,
257
258 // Additional actions
259 demo,
260 });
261});