A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: s3 input

+361 -640
-178
_backup/pages/constituent/blur/browser/_applet.astro
··· 1 - --- 2 - import "@styles/reset.css"; 3 - import "@styles/variables.css"; 4 - import "@styles/fonts.css"; 5 - import "@styles/animations.css"; 6 - import "@styles/icons/phosphor.css"; 7 - 8 - import "@styles/diffuse/colors.css"; 9 - import "@styles/diffuse/fonts.css"; 10 - --- 11 - 12 - <main> 13 - <blur-search></blur-search> 14 - <blur-list></blur-list> 15 - </main> 16 - 17 - <!-- STYLE --> 18 - 19 - <style> 20 - main { 21 - background: white; 22 - color: var(--color-3); 23 - display: flex; 24 - flex-direction: column; 25 - font-size: var(--fs-sm); 26 - height: 100dvh; 27 - overflow: hidden; 28 - position: relative; 29 - transition: 30 - background-color var(--transition-durition), 31 - color var(--transition-durition); 32 - } 33 - 34 - /* SEARCH */ 35 - 36 - search { 37 - & input { 38 - display: block; 39 - width: 100%; 40 - } 41 - } 42 - 43 - /* LIST */ 44 - 45 - blur-list { 46 - display: block; 47 - flex: 1; 48 - overflow: hidden; 49 - user-select: none; 50 - 51 - & ul { 52 - height: 100%; 53 - overflow-y: scroll; 54 - } 55 - 56 - & ul li { 57 - content-visibility: auto; 58 - contain-intrinsic-size: auto 21px; 59 - overflow: hidden; 60 - text-overflow: ellipsis; 61 - white-space: nowrap; 62 - } 63 - } 64 - </style> 65 - 66 - <style is:global> 67 - iframe { 68 - display: none; 69 - } 70 - </style> 71 - 72 - <script> 73 - import astroScope from "astro:scope"; 74 - import { effect, signal } from "alien-signals"; 75 - import { liftAlien } from "@lift-html/alien"; 76 - 77 - import { applet, reactive, register } from "@scripts/applet/common"; 78 - import type { ManagedOutput, Track } from "@applets/core/types"; 79 - 80 - //////////////////////////////////////////// 81 - // SETUP 82 - //////////////////////////////////////////// 83 - import type * as Search from "@applets/processor/search/types.d.ts"; 84 - 85 - // Register 86 - const context = register(); 87 - 88 - // Signals 89 - const $searchTerm = signal<string>(""); 90 - const $tracks = signal<Track[]>([]); 91 - 92 - // Is main group 93 - function isMainGroup() { 94 - return context.groupId === undefined || context.groupId.toLowerCase() === "main"; 95 - } 96 - 97 - // Applet connections 98 - const orchestrator = { 99 - processTracks: isMainGroup() ? applet("/orchestrator/process-tracks") : undefined, 100 - searchTracks: isMainGroup() 101 - ? applet("/orchestrator/search-tracks", { groupId: context.groupId }) 102 - : undefined, 103 - }; 104 - 105 - const processor = { 106 - search: applet<Search.State>("/processor/search"), 107 - }; 108 - 109 - //////////////////////////////////////////// 110 - // ✨ EFFECTS 111 - //////////////////////////////////////////// 112 - 113 - processor.search.then((search) => { 114 - async function fn() { 115 - const tracks = await search.sendAction("search", $searchTerm().trim(), { worker: true }); 116 - $tracks(tracks); 117 - } 118 - 119 - reactive(search, (data) => data.cacheId, fn); 120 - effect(fn); 121 - }); 122 - 123 - //////////////////////////////////////////// 124 - // UI 125 - //////////////////////////////////////////// 126 - const html = (strings: string[] | ArrayLike<string>, ...values: any[]) => 127 - String.raw({ raw: strings }, ...values); 128 - const scope = `data-astro-cid-${astroScope}`; 129 - 130 - //////////////////////////////////////////// 131 - // UI ░ SEARCH 132 - //////////////////////////////////////////// 133 - 134 - liftAlien("blur-search", { 135 - async init() { 136 - // const input = document.createElement("input"); 137 - // input.type = "search"; 138 - // input.placeholder = "Search"; 139 - 140 - // const form = document.createElement("form"); 141 - // // form 142 - // form.appendChild(input); 143 - 144 - // const search = document.createElement("search"); 145 - // search.appendChild(); 146 - 147 - // const search = h("search", {}, []); 148 - 149 - effect(() => { 150 - // 151 - }); 152 - }, 153 - }); 154 - 155 - //////////////////////////////////////////// 156 - // UI ░ LIST 157 - //////////////////////////////////////////// 158 - 159 - liftAlien("blur-list", { 160 - observedAttributes: ["item-height"], 161 - 162 - async init() { 163 - // const props = useAttributes(this); 164 - 165 - effect(() => { 166 - this.innerHTML = html` 167 - <ul ${scope}> 168 - ${$tracks() 169 - .map((track: Track) => { 170 - return html`<li ${scope}>${track.tags?.artist} - ${track.tags?.title}</li>`; 171 - }) 172 - .join("")} 173 - </ul> 174 - `; 175 - }); 176 - }, 177 - }); 178 - </script>
-6
_backup/pages/constituent/blur/browser/_manifest.json
··· 1 - { 2 - "name": "diffuse/constituent/blur/browser", 3 - "title": "Diffuse Blur Theme | Browser", 4 - "entrypoint": "index.html", 5 - "actions": {} 6 - }
-9
_backup/pages/constituent/blur/browser/index.astro
··· 1 - --- 2 - import Layout from "@layouts/applet.astro"; 3 - import Applet from "./_applet.astro"; 4 - import { title } from "./_manifest.json"; 5 - --- 6 - 7 - <Layout title={title}> 8 - <Applet /> 9 - </Layout>
-59
_backup/pages/demo/s3-tracks/index.astro
··· 1 - --- 2 - import Page from "../../../layouts/page.astro"; 3 - import "@styles/pico.scss"; 4 - --- 5 - 6 - <Page title="Diffuse"> 7 - <main class="container"> 8 - <h1>Add sample S3 bucket to inputs</h1> 9 - 10 - <p> 11 - Clicking the button below will add some sample music, which you can then play using the 12 - various <a href="/#themes" target="_blank">themes, abstractions and constituents</a>. 13 - 14 - <br /> 15 - <br /> 16 - 17 - <button>Add sample content</button> 18 - </p> 19 - </main> 20 - 21 - <style is:global> 22 - iframe { 23 - display: none; 24 - } 25 - </style> 26 - 27 - <script> 28 - import { applet } from "@scripts/applet/common"; 29 - import type { Bucket } from "@scripts/input/s3/types"; 30 - 31 - document.querySelector("button")?.addEventListener("click", clickHandler); 32 - 33 - async function clickHandler() { 34 - const p = document.body.querySelector("p"); 35 - const s3 = await applet("/input/s3"); 36 - 37 - // Credentials are read-only, no worries. 38 - const bucket: Bucket = { 39 - accessKey: atob("QUtJQTZPUTNFVk1BWFZDRFFINkI="), 40 - bucketName: "ongaku-ryoho-demo", 41 - host: "s3.amazonaws.com", 42 - path: "/", 43 - region: "us-east-1", 44 - secretKey: atob("Z0hPQkdHRzU1aXc0a0RDbjdjWlRJYTVTUDRZWnpERkRzQnFCYWI4Mg=="), 45 - }; 46 - 47 - await s3.sendAction("mount", bucket, { timeoutDuration: 60000 }); 48 - 49 - // Finished 50 - if (p) 51 - p.innerHTML = ` 52 - Content added! Try it out using the <a href="/constituent/blur/artwork-controller/" target="_blank">blur artwork controller</a> or any other <a href="/#themes" target="_blank">theme, abstraction or constituent</a>. 53 - `; 54 - 55 - // Additionally process inputs automatically 56 - applet("/orchestrator/process-tracks"); 57 - } 58 - </script> 59 - </Page>
-123
_backup/scripts/input/s3/common.ts
··· 1 - import { S3Client } from "@bradenmacdonald/s3-lite-client"; 2 - import * as IDB from "idb-keyval"; 3 - import * as URI from "uri-js"; 4 - import QS from "query-string"; 5 - 6 - import type { Track } from "@applets/core/types.d.ts"; 7 - import { ENCODINGS, IDB_BUCKETS, SCHEME } from "./constants"; 8 - import type { Bucket } from "./types"; 9 - 10 - //////////////////////////////////////////// 11 - // 🛠️ 12 - //////////////////////////////////////////// 13 - export function bucketsFromTracks(tracks: Track[]) { 14 - const acc: Record<string, Bucket> = {}; 15 - 16 - tracks.forEach((track: Track) => { 17 - const parsed = parseURI(track.uri); 18 - if (!parsed) return; 19 - 20 - const id = bucketId(parsed.bucket); 21 - if (acc[id]) return; 22 - 23 - acc[id] = parsed.bucket; 24 - }); 25 - 26 - return acc; 27 - } 28 - 29 - export function bucketId(bucket: Bucket) { 30 - return `${bucket.accessKey}:${bucket.secretKey}@${bucket.host}`; 31 - } 32 - 33 - export function buildURI(bucket: Bucket, path: string) { 34 - return URI.serialize({ 35 - scheme: SCHEME, 36 - userinfo: `${bucket.accessKey}:${bucket.secretKey}`, 37 - host: bucket.host.replace(/^https?:\/\//, ""), 38 - path: path, 39 - query: QS.stringify({ 40 - bucketName: bucket.bucketName, 41 - bucketPath: bucket.path, 42 - region: bucket.region, 43 - }), 44 - }); 45 - } 46 - 47 - export async function consultBucket(bucket: Bucket) { 48 - const client = createClient(bucket); 49 - return await client.bucketExists(bucket.bucketName); 50 - } 51 - 52 - export function createClient(bucket: Bucket) { 53 - return new S3Client({ 54 - bucket: bucket.bucketName, 55 - endPoint: `http${bucket.host.startsWith("localhost") ? "" : "s"}://${bucket.host}`, 56 - region: bucket.region, 57 - pathStyle: false, 58 - accessKey: bucket.accessKey, 59 - secretKey: bucket.secretKey, 60 - }); 61 - } 62 - 63 - export function encodeAwsUriComponent(a: string) { 64 - return encodeURIComponent(a).replace( 65 - /(\+|!|"|#|\$|&|'|\(|\)|\*|\+|,|:|;|=|\?|@)/gim, 66 - (match) => (ENCODINGS as any)[match] ?? match, 67 - ); 68 - } 69 - 70 - export function groupTracksByBucket(tracks: Track[]) { 71 - const acc: Record<string, { bucket: Bucket; tracks: Track[] }> = {}; 72 - 73 - tracks.forEach((track: Track) => { 74 - const parsed = parseURI(track.uri); 75 - if (!parsed) return acc; 76 - 77 - const id = bucketId(parsed.bucket); 78 - 79 - if (acc[id]) { 80 - acc[id].tracks.push(track); 81 - } else { 82 - acc[id] = { bucket: parsed.bucket, tracks: [track] }; 83 - } 84 - }); 85 - 86 - return acc; 87 - } 88 - 89 - export async function loadBuckets(): Promise<Record<string, Bucket>> { 90 - const i = await IDB.get(IDB_BUCKETS); 91 - return i ? i : {}; 92 - } 93 - 94 - export function parseURI(uriString: string): { bucket: Bucket; path: string } | undefined { 95 - const uri = URI.parse(uriString); 96 - if (uri.scheme !== SCHEME) return undefined; 97 - if (!uri.host) return undefined; 98 - 99 - const [accessKey, secretKey] = uri.userinfo?.split(":") ?? []; 100 - if (!accessKey || !secretKey) return undefined; 101 - 102 - const qs = QS.parse(uri.query || ""); 103 - 104 - const bucket = { 105 - accessKey, 106 - bucketName: typeof qs.bucketName === "string" ? qs.bucketName : "", 107 - host: uri.host, 108 - path: qs.bucketPath === "string" ? qs.bucketPath : "/", 109 - region: typeof qs.region === "string" ? qs.region : "", 110 - secretKey, 111 - }; 112 - 113 - const path = (bucket.path.replace(/\/$/, "") + URI.unescapeComponent(uri.path || "")).replace( 114 - /^\//, 115 - "", 116 - ); 117 - 118 - return { bucket, path }; 119 - } 120 - 121 - export async function saveBuckets(items: Record<string, Bucket>) { 122 - await IDB.set(IDB_BUCKETS, items); 123 - }
+3 -5
_backup/scripts/input/s3/constants.ts src/component/input/s3/constants.js
··· 1 - import manifest from "../../../pages/input/s3/_manifest.json"; 2 - 3 - export const IDB_PREFIX = "@applets/input/s3"; 1 + export const IDB_PREFIX = "@component/input/s3"; 4 2 export const IDB_BUCKETS = `${IDB_PREFIX}/buckets`; 5 - export const SCHEME = manifest.input_properties.scheme; 3 + export const SCHEME = "s3"; 6 4 7 5 export const ENCODINGS = { 8 6 "\+": "%2B", 9 7 "\!": "%21", 10 - '\"': "%22", 8 + '"': "%22", 11 9 "\#": "%23", 12 10 "\$": "%24", 13 11 "\&": "%26",
_backup/scripts/input/s3/types.d.ts src/component/input/s3/types.d.ts
-118
_backup/scripts/input/s3/ui.ts
··· 1 - import { computed, effect, type Signal, signal } from "@scripts/spellcaster"; 2 - import { type Props, repeat, tags, text } from "@scripts/spellcaster/hyperscript.js"; 3 - 4 - import type { Bucket } from "./types"; 5 - import { bucketId, loadBuckets, saveBuckets } from "./common"; 6 - 7 - //////////////////////////////////////////// 8 - // UI 9 - //////////////////////////////////////////// 10 - export const buckets = signal<Record<string, Bucket>>(await loadBuckets()); 11 - export const form = signal<{ 12 - access_key?: string; 13 - bucket_name?: string; 14 - host?: string; 15 - path?: string; 16 - region?: string; 17 - secret_key?: string; 18 - }>({}); 19 - 20 - export const bucketsMap = computed(() => { 21 - return new Map(Object.entries(buckets())); 22 - }); 23 - 24 - effect(() => { 25 - saveBuckets(buckets()); 26 - }); 27 - 28 - //////////////////////////////////////////// 29 - // UI ~ BUCKETS 30 - //////////////////////////////////////////// 31 - const Bucket = (bucket: Signal<Bucket>) => { 32 - const onclick = () => { 33 - const b = bucket(); 34 - const id = bucketId(b); 35 - 36 - const col = { ...buckets() }; 37 - delete col[id]; 38 - 39 - buckets(col); 40 - }; 41 - 42 - return tags.li({ onclick, style: "cursor: pointer" }, text(bucket().host)); 43 - }; 44 - 45 - const BucketList = computed(() => { 46 - if (bucketsMap().size === 0) { 47 - return tags.p({ id: "buckets" }, [tags.small({}, text("Nothing added so far."))]); 48 - } 49 - 50 - return tags.ul({ id: "buckets" }, repeat(bucketsMap, Bucket)); 51 - }); 52 - 53 - effect(() => { 54 - document.querySelector("#buckets")?.replaceWith(BucketList()); 55 - }); 56 - 57 - //////////////////////////////////////////// 58 - // UI ~ FORM 59 - //////////////////////////////////////////// 60 - function addBucket(event: Event) { 61 - event.preventDefault(); 62 - 63 - const f = form(); 64 - 65 - const bucket: Bucket = { 66 - accessKey: f.access_key || "", 67 - bucketName: f.bucket_name || "", 68 - host: f.host || "s3.amazonaws.com", 69 - path: f.path || "/", 70 - region: f.region || "us-east-1", 71 - secretKey: f.secret_key || "", 72 - }; 73 - 74 - buckets({ 75 - ...buckets(), 76 - [bucketId(bucket)]: bucket, 77 - }); 78 - } 79 - 80 - function Form() { 81 - return tags.form({ onsubmit: addBucket }, [ 82 - tags.fieldset({ className: "grid" }, [ 83 - Input("access_key", "Access key", "r31w7m9c", { required: true }), 84 - Input("secret_key", "Secret key", "v02g2l29", { required: true }), 85 - ]), 86 - tags.fieldset({ className: "grid" }, [ 87 - Input("bucket_name", "Bucket name", "bucket", { required: true }), 88 - Input("region", "Region", "us-east-1", { required: true }), 89 - ]), 90 - tags.fieldset({ className: "grid" }, [ 91 - Input("host", "Host", "s3.amazonaws.com", { required: true }), 92 - Input("path", "Path", "/"), 93 - ]), 94 - tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]), 95 - ]); 96 - } 97 - 98 - function Input(name: string, label: string, placeholder: string, opts: Props = {}) { 99 - return tags.label({}, [ 100 - tags.span({}, [ 101 - tags.span({}, text(label)), 102 - tags.small({}, text("required" in opts ? "" : " (optional)")), 103 - ]), 104 - tags.input({ 105 - ...opts, 106 - name, 107 - placeholder, 108 - oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value), 109 - }), 110 - ]); 111 - } 112 - 113 - function formInput(name: string, value: string) { 114 - form({ ...form(), [name]: value }); 115 - } 116 - 117 - // 🚀 118 - document.querySelector("#form")?.replaceWith(Form());
-131
_backup/scripts/input/s3/worker.ts
··· 1 - import type { Consult, ConsultGrouping, GroupConsult, Track } from "@applets/core/types.d.ts"; 2 - import { isAudioFile } from "@scripts/input/common"; 3 - import { 4 - bucketId, 5 - bucketsFromTracks, 6 - buildURI, 7 - consultBucket, 8 - createClient, 9 - groupTracksByBucket, 10 - loadBuckets, 11 - parseURI, 12 - } from "./common"; 13 - import { provide, transfer } from "@scripts/common"; 14 - import { SCHEME } from "./constants"; 15 - 16 - //////////////////////////////////////////// 17 - // TASKS 18 - //////////////////////////////////////////// 19 - const actions = { 20 - consult, 21 - contextualize, 22 - groupConsult, 23 - list, 24 - resolve, 25 - }; 26 - 27 - const { tasks } = provide({ actions, tasks: actions }); 28 - 29 - export type Actions = typeof actions; 30 - export type Tasks = typeof tasks; 31 - 32 - // Tasks 33 - 34 - async function consult(fileUriOrScheme: string): Promise<Consult> { 35 - if (!fileUriOrScheme.includes(":")) return { supported: true, consult: "undetermined" }; 36 - 37 - const parsed = parseURI(fileUriOrScheme); 38 - if (!parsed) return { supported: true, consult: "undetermined" }; 39 - 40 - const consult = await consultBucket(parsed.bucket); 41 - return { supported: true, consult }; 42 - } 43 - 44 - async function contextualize(tracks: Track[]) { 45 - return bucketsFromTracks(tracks); 46 - } 47 - 48 - async function groupConsult(tracks: Track[]): Promise<GroupConsult> { 49 - const groups = groupTracksByBucket(tracks); 50 - 51 - const promises = Object.entries(groups).map(async ([bucketId, { bucket, tracks }]) => { 52 - const available = await consultBucket(bucket); 53 - const grouping: ConsultGrouping = available 54 - ? { available, tracks } 55 - : { available, reason: "Bucket unavailable", tracks }; 56 - 57 - return { 58 - key: `${SCHEME}:${bucketId}`, 59 - grouping, 60 - }; 61 - }); 62 - 63 - const entries = (await Promise.all(promises)).map((entry) => [entry.key, entry.grouping]); 64 - const obj = Object.fromEntries(entries); 65 - 66 - return transfer(obj); 67 - } 68 - 69 - async function list(cachedTracks: Track[] = []) { 70 - const cache: Record<string, Record<string, Track>> = {}; 71 - 72 - cachedTracks.forEach((t: Track) => { 73 - const parsed = parseURI(t.uri); 74 - if (!parsed) return; 75 - 76 - const bid = bucketId(parsed?.bucket); 77 - 78 - if (cache[bid]) { 79 - cache[bid][parsed.path] = t; 80 - } else { 81 - cache[bid] = { [parsed.path]: t }; 82 - } 83 - }); 84 - 85 - const buckets = await loadBuckets(); 86 - const promises = Object.values(buckets).map(async (bucket) => { 87 - const client = createClient(bucket); 88 - const bid = bucketId(bucket); 89 - 90 - const list = await Array.fromAsync( 91 - client.listObjects({ 92 - prefix: bucket.path.replace(/^\//, ""), 93 - }), 94 - ); 95 - 96 - return list 97 - .filter((l) => isAudioFile(l.key)) 98 - .map((l) => { 99 - const cachedTrack = cache[bid]?.[l.key]; 100 - 101 - const id = cachedTrack?.id || crypto.randomUUID(); 102 - const stats = cachedTrack?.stats; 103 - const tags = cachedTrack?.tags; 104 - 105 - const track: Track = { 106 - id, 107 - stats, 108 - tags, 109 - uri: buildURI(bucket, l.key), 110 - }; 111 - 112 - return track; 113 - }); 114 - }); 115 - 116 - const tracks = (await Promise.all(promises)).flat(1); 117 - return transfer(tracks); 118 - } 119 - 120 - async function resolve({ method, uri }: { method: string; uri: string }) { 121 - const parsed = parseURI(uri); 122 - if (!parsed) return undefined; 123 - 124 - const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 125 - const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 126 - 127 - const client = createClient(parsed.bucket); 128 - const url = await client.getPresignedUrl(method.toUpperCase() as any, parsed.path); 129 - 130 - return { expiresAt: expiresAtSeconds, url }; 131 - }
+1
deno.jsonc
··· 4 4 "vendor": true, 5 5 "imports": { 6 6 "98.css": "npm:98.css@^0.1.21", 7 + "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.9.4", 7 8 "@fry69/deep-diff": "jsr:@fry69/deep-diff@^0.1.10", 8 9 "@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3", 9 10 "@mys/m-rpc": "jsr:@mys/m-rpc@^0.12.2",
+5
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@bradenmacdonald/s3-lite-client@~0.9.4": "0.9.4", 4 5 "jsr:@deno/loader@0.3.6": "0.3.6", 5 6 "jsr:@fry69/deep-diff@~0.1.10": "0.1.10", 6 7 "jsr:@mary/ds-queue@~0.1.3": "0.1.3", ··· 57 58 "npm:xxh32@^2.0.5": "2.0.5" 58 59 }, 59 60 "jsr": { 61 + "@bradenmacdonald/s3-lite-client@0.9.4": { 62 + "integrity": "f52e31c7efdaeb1ccdf65a1db995b5920d635717c96d45dcf9450c3cc47ecaaf" 63 + }, 60 64 "@deno/loader@0.3.6": { 61 65 "integrity": "98f08d837c18ece5ba15122264fb29580967407c34e6552e152b8f453a60c2be" 62 66 }, ··· 1390 1394 }, 1391 1395 "workspace": { 1392 1396 "dependencies": [ 1397 + "jsr:@bradenmacdonald/s3-lite-client@~0.9.4", 1393 1398 "jsr:@fry69/deep-diff@~0.1.10", 1394 1399 "jsr:@mary/ds-queue@~0.1.3", 1395 1400 "jsr:@mys/m-rpc@~0.12.2",
+165
src/component/input/s3/common.js
··· 1 + import { S3Client } from "@bradenmacdonald/s3-lite-client"; 2 + import * as IDB from "idb-keyval"; 3 + import * as URI from "uri-js"; 4 + import QS from "query-string"; 5 + 6 + import { ENCODINGS, IDB_BUCKETS, SCHEME } from "./constants.js"; 7 + 8 + /** 9 + * @import { Track } from "@component/core/types.d.ts"; 10 + * @import { Bucket } from "./types.d.ts"; 11 + */ 12 + 13 + //////////////////////////////////////////// 14 + // 🛠️ 15 + //////////////////////////////////////////// 16 + 17 + /** 18 + * @param {Track[]} tracks 19 + */ 20 + export function bucketsFromTracks(tracks) { 21 + /** @type {Record<string, Bucket>} */ 22 + const acc = {}; 23 + 24 + tracks.forEach((track) => { 25 + const parsed = parseURI(track.uri); 26 + if (!parsed) return; 27 + 28 + const id = bucketId(parsed.bucket); 29 + if (acc[id]) return; 30 + 31 + acc[id] = parsed.bucket; 32 + }); 33 + 34 + return acc; 35 + } 36 + 37 + /** 38 + * @param {Bucket} bucket 39 + */ 40 + export function bucketId(bucket) { 41 + return `${bucket.accessKey}:${bucket.secretKey}@${bucket.host}`; 42 + } 43 + 44 + /** 45 + * @param {Bucket} bucket 46 + * @param {string} path 47 + */ 48 + export function buildURI(bucket, path) { 49 + return URI.serialize({ 50 + scheme: SCHEME, 51 + userinfo: `${bucket.accessKey}:${bucket.secretKey}`, 52 + host: bucket.host.replace(/^https?:\/\//, ""), 53 + path: path, 54 + query: QS.stringify({ 55 + bucketName: bucket.bucketName, 56 + bucketPath: bucket.path, 57 + region: bucket.region, 58 + }), 59 + }); 60 + } 61 + 62 + /** 63 + * @param {Bucket} bucket 64 + */ 65 + export async function consultBucket(bucket) { 66 + const client = createClient(bucket); 67 + return await client.bucketExists(bucket.bucketName); 68 + } 69 + 70 + /** 71 + * @param {Bucket} bucket 72 + */ 73 + export function createClient(bucket) { 74 + return new S3Client({ 75 + bucket: bucket.bucketName, 76 + endPoint: `http${ 77 + bucket.host.startsWith("localhost") ? "" : "s" 78 + }://${bucket.host}`, 79 + region: bucket.region, 80 + pathStyle: false, 81 + accessKey: bucket.accessKey, 82 + secretKey: bucket.secretKey, 83 + }); 84 + } 85 + 86 + /** 87 + * @param {string} a 88 + */ 89 + export function encodeAwsUriComponent(a) { 90 + return encodeURIComponent(a).replace( 91 + /(\+|!|"|#|\$|&|'|\(|\)|\*|\+|,|:|;|=|\?|@)/gim, 92 + (match) => /** @type {any} */ (ENCODINGS)[match] ?? match, 93 + ); 94 + } 95 + 96 + /** 97 + * @param {Track[]} tracks 98 + */ 99 + export function groupTracksByBucket(tracks) { 100 + /** @type {Record<string, { bucket: Bucket; tracks: Track[] }>} */ 101 + const acc = {}; 102 + 103 + tracks.forEach((track) => { 104 + const parsed = parseURI(track.uri); 105 + if (!parsed) return acc; 106 + 107 + const id = bucketId(parsed.bucket); 108 + 109 + if (acc[id]) { 110 + acc[id].tracks.push(track); 111 + } else { 112 + acc[id] = { bucket: parsed.bucket, tracks: [track] }; 113 + } 114 + }); 115 + 116 + return acc; 117 + } 118 + 119 + /** 120 + * @returns {Promise<Record<string, Bucket>>} 121 + */ 122 + export async function loadBuckets() { 123 + const i = await IDB.get(IDB_BUCKETS); 124 + return i ? i : {}; 125 + } 126 + 127 + /** 128 + * @param {string} uriString 129 + * @returns {{ bucket: Bucket; path: string } | undefined} 130 + */ 131 + export function parseURI(uriString) { 132 + const uri = URI.parse(uriString); 133 + if (uri.scheme !== SCHEME) return undefined; 134 + if (!uri.host) return undefined; 135 + 136 + const [accessKey, secretKey] = uri.userinfo?.split(":") ?? []; 137 + if (!accessKey || !secretKey) return undefined; 138 + 139 + const qs = QS.parse(uri.query || ""); 140 + 141 + const bucket = { 142 + accessKey, 143 + bucketName: typeof qs.bucketName === "string" ? qs.bucketName : "", 144 + host: uri.host, 145 + path: qs.bucketPath === "string" ? qs.bucketPath : "/", 146 + region: typeof qs.region === "string" ? qs.region : "", 147 + secretKey, 148 + }; 149 + 150 + const path = 151 + (bucket.path.replace(/\/$/, "") + URI.unescapeComponent(uri.path || "")) 152 + .replace( 153 + /^\//, 154 + "", 155 + ); 156 + 157 + return { bucket, path }; 158 + } 159 + 160 + /** 161 + * @param {Record<string, Bucket>} items 162 + */ 163 + export async function saveBuckets(items) { 164 + await IDB.set(IDB_BUCKETS, items); 165 + }
+176
src/component/input/s3/worker.js
··· 1 + import { isAudioFile } from "@component/input/common.js"; 2 + import { 3 + bucketId, 4 + bucketsFromTracks, 5 + buildURI, 6 + consultBucket, 7 + createClient, 8 + groupTracksByBucket, 9 + loadBuckets, 10 + parseURI, 11 + } from "./common.js"; 12 + import { SCHEME } from "./constants.js"; 13 + 14 + /** 15 + * @import { InputActions as Actions, Track } from "@component/core/types.d.ts"; 16 + * @import { Bucket } from "./types.d.ts" 17 + */ 18 + 19 + //////////////////////////////////////////// 20 + // ACTIONS 21 + //////////////////////////////////////////// 22 + 23 + /** 24 + * @type {Actions['consult']} 25 + */ 26 + export async function consult(fileUriOrScheme) { 27 + if (!fileUriOrScheme.includes(":")) { 28 + return { supported: true, consult: "undetermined" }; 29 + } 30 + 31 + const parsed = parseURI(fileUriOrScheme); 32 + if (!parsed) return { supported: true, consult: "undetermined" }; 33 + 34 + const consult = await consultBucket(parsed.bucket); 35 + return { supported: true, consult }; 36 + } 37 + 38 + /** 39 + * @type {Actions['contextualize']} 40 + */ 41 + export async function contextualize(tracks) { 42 + bucketsFromTracks(tracks); 43 + } 44 + 45 + /** 46 + * @type {Actions['groupConsult']} 47 + */ 48 + export async function groupConsult(tracks) { 49 + const groups = groupTracksByBucket(tracks); 50 + 51 + const promises = Object.entries(groups).map( 52 + async ([bucketId, { bucket, tracks }]) => { 53 + const available = await consultBucket(bucket); 54 + const grouping = available 55 + ? { available, tracks } 56 + : { available, reason: "Bucket unavailable", tracks }; 57 + 58 + return { 59 + key: `${SCHEME}:${bucketId}`, 60 + grouping, 61 + }; 62 + }, 63 + ); 64 + 65 + const entries = (await Promise.all(promises)).map(( 66 + entry, 67 + ) => [entry.key, entry.grouping]); 68 + return Object.fromEntries(entries); 69 + } 70 + 71 + /** 72 + * @type {Actions['list']} 73 + */ 74 + export async function list(cachedTracks = []) { 75 + /** @type {Record<string, Record<string, Track>>} */ 76 + const cache = {}; 77 + 78 + cachedTracks.forEach((t) => { 79 + const parsed = parseURI(t.uri); 80 + if (!parsed) return; 81 + 82 + const bid = bucketId(parsed?.bucket); 83 + 84 + if (cache[bid]) { 85 + cache[bid][parsed.path] = t; 86 + } else { 87 + cache[bid] = { [parsed.path]: t }; 88 + } 89 + }); 90 + 91 + const buckets = await loadBuckets(); 92 + const promises = Object.values(buckets).map(async (bucket) => { 93 + const client = createClient(bucket); 94 + const bid = bucketId(bucket); 95 + 96 + const list = await Array.fromAsync( 97 + client.listObjects({ 98 + prefix: bucket.path.replace(/^\//, ""), 99 + }), 100 + ); 101 + 102 + return list 103 + .filter((l) => isAudioFile(l.key)) 104 + .map((l) => { 105 + const cachedTrack = cache[bid]?.[l.key]; 106 + 107 + const id = cachedTrack?.id || crypto.randomUUID(); 108 + const stats = cachedTrack?.stats; 109 + const tags = cachedTrack?.tags; 110 + 111 + /** @type {Track} */ 112 + const track = { 113 + id, 114 + stats, 115 + tags, 116 + uri: buildURI(bucket, l.key), 117 + }; 118 + 119 + return track; 120 + }); 121 + }); 122 + 123 + const tracks = (await Promise.all(promises)).flat(1); 124 + return tracks; 125 + } 126 + 127 + /** 128 + * @type {Actions['resolve']} 129 + */ 130 + export async function resolve( 131 + { method, uri }, 132 + ) { 133 + const parsed = parseURI(uri); 134 + if (!parsed) return undefined; 135 + 136 + const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 137 + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 138 + 139 + const client = createClient(parsed.bucket); 140 + const url = await client.getPresignedUrl( 141 + /** @type {any} */ (method.toUpperCase()), 142 + parsed.path, 143 + ); 144 + 145 + return { expiresAt: expiresAtSeconds, url }; 146 + } 147 + 148 + // ADDITIONAL ACTIONS 149 + 150 + export function demo() { 151 + // Credentials are read-only, no worries. 152 + 153 + /** @type {Bucket} */ 154 + const bucket = { 155 + accessKey: atob("QUtJQTZPUTNFVk1BWFZDRFFINkI="), 156 + bucketName: "ongaku-ryoho-demo", 157 + host: "s3.amazonaws.com", 158 + path: "/", 159 + region: "us-east-1", 160 + secretKey: atob("Z0hPQkdHRzU1aXc0a0RDbjdjWlRJYTVTUDRZWnpERkRzQnFCYWI4Mg=="), 161 + }; 162 + 163 + const uri = buildURI(bucket, ""); 164 + 165 + /** @type {Track} */ 166 + const track = { 167 + id: crypto.randomUUID(), 168 + kind: "placeholder", 169 + uri, 170 + }; 171 + 172 + return { 173 + bucket, 174 + track, 175 + }; 176 + }
+11 -11
src/index.vto
··· 8 8 title: "Webamp" 9 9 10 10 engines: 11 - - url: "engine/audio/" 11 + - url: "component/engine/audio/element.js" 12 12 title: "Audio" 13 - - url: "engine/queue/" 13 + - url: "component/engine/queue/element.js" 14 14 title: "Queue" 15 15 16 16 input: 17 - - url: "input/opensubsonic/" 17 + - url: "component/input/opensubsonic/element.js" 18 18 title: "Opensubsonic" 19 - - url: "input/s3/" 19 + - url: "component/input/s3/element.js" 20 20 title: "S3 (TODO)" 21 21 22 22 orchestrators: 23 - - url: "orchestrator/process-tracks/" 23 + - url: "component/orchestrator/process-tracks/element.js" 24 24 title: "Process inputs into tracks" 25 - - url: "orchestrator/queue-audio/" 25 + - url: "component/orchestrator/queue-audio/element.js" 26 26 title: "Queue ⭤ Audio" 27 - - url: "orchestrator/queue-tracks/" 27 + - url: "component/orchestrator/queue-tracks/element.js" 28 28 title: "Queue ⭤ Tracks" 29 29 30 30 output: 31 - - url: "output/indexed-db/" 31 + - url: "component/output/indexed-db/element.js" 32 32 title: "IndexedDB" 33 33 34 34 processors: 35 - - url: "processor/artwork/" 35 + - url: "component/processor/artwork/element.js" 36 36 title: "Artwork" 37 - - url: "processor/metadata/" 37 + - url: "component/processor/metadata/element.js" 38 38 title: "Metadata" 39 - - url: "processor/search/" 39 + - url: "component/processor/search/element.js" 40 40 title: "Search" 41 41 42 42 ---