A music player that connects to your cloud/distributed storage.
at v4 241 lines 5.4 kB view raw
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});