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