Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

feat: only add available tracks to the queue

+455 -174
+1 -2
astro.config.js
··· 1 1 import { defineConfig } from "astro/config"; 2 2 import scope from "astro-scope"; 3 3 import wasm from "vite-plugin-wasm"; 4 - import worker from "@astropub/worker"; 5 4 6 5 import purgecss from "astro-purgecss"; 7 6 8 7 export default defineConfig({ 9 - integrations: [scope(), purgecss(), worker()], 8 + integrations: [scope(), purgecss()], 10 9 build: { 11 10 inlineStylesheets: "never", 12 11 },
-1
deno.lock
··· 22 22 "packageJson": { 23 23 "dependencies": [ 24 24 "npm:98.css@~0.1.21", 25 - "npm:@astropub/worker@0.2", 26 25 "npm:@automerge/automerge@^3.0.0-beta.0", 27 26 "npm:@js-temporal/polyfill@~0.5.1", 28 27 "npm:@jsr/bradenmacdonald__s3-lite-client@0.9",
-1
package.json
··· 1 1 { 2 2 "dependencies": { 3 - "@astropub/worker": "^0.2.0", 4 3 "@automerge/automerge": "^3.0.0-beta.0", 5 4 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 6 5 "@js-temporal/polyfill": "^0.5.1",
+48 -11
src/pages/configurator/input/_applet.astro
··· 32 32 </style> 33 33 34 34 <script> 35 - import type { Track } from "@applets/core/types.d.ts"; 35 + import type { Applet } from "@web-applets/sdk"; 36 + import * as URI from "uri-js"; 37 + 38 + import type { Consult, ConsultGrouping, GroupConsult, Track } from "@applets/core/types.d.ts"; 36 39 import { applet, register } from "@scripts/applet/common"; 37 - import type { Applet } from "@web-applets/sdk"; 40 + import { groupTracksPerScheme } from "@scripts/common"; 38 41 39 42 //////////////////////////////////////////// 40 43 // SETUP ··· 70 73 //////////////////////////////////////////// 71 74 // ACTIONS 72 75 //////////////////////////////////////////// 76 + const consult = async (fileUriOrScheme: string): Promise<Consult> => { 77 + const scheme = fileUriOrScheme.includes(":") 78 + ? URI.parse(fileUriOrScheme).scheme || fileUriOrScheme 79 + : fileUriOrScheme; 80 + 81 + if (!isSupportedScheme(scheme)) { 82 + return { supported: false, reason: "Unsupported scheme" }; 83 + } 84 + 85 + const conn = await connection(scheme); 86 + return await conn.sendAction<Consult>("consult", fileUriOrScheme, { 87 + timeoutDuration: 60000 * 5, 88 + }); 89 + }; 90 + 73 91 const contextualize = async (tracks: Track[]) => { 74 - const groups = await groupTracksPerScheme(tracks); 92 + const groups = await groupTracks(tracks); 75 93 const promises = Object.entries(groups).map( 76 94 async ([scheme, tracksGroup]: [string, Track[]]) => { 77 95 if (!isSupportedScheme(scheme) || tracksGroup.length === 0) return; ··· 83 101 await Promise.all(promises); 84 102 }; 85 103 104 + const groupConsult = async (tracks: Track[]) => { 105 + const groups = groupTracksPerScheme(tracks); 106 + const consultations = await Promise.all( 107 + Object.keys(groups).map(async (scheme) => { 108 + if (!isSupportedScheme(scheme)) { 109 + return { available: false, reason: "Unsupported scheme" }; 110 + } 111 + 112 + const conn = await connection(scheme); 113 + return await conn.sendAction("groupConsult", groups[scheme] || [], { 114 + timeoutDuration: 60000 * 5, 115 + }); 116 + }), 117 + ); 118 + 119 + return consultations.reduce((acc: GroupConsult, c: GroupConsult) => { 120 + return { ...acc, ...c }; 121 + }, {}); 122 + }; 123 + 86 124 const list = async (cachedTracks: Track[] = []) => { 87 - const groups = await groupTracksPerScheme(cachedTracks); 125 + const groups = await groupTracks(cachedTracks); 88 126 89 127 const promises = Object.entries(groups).map( 90 128 async ([scheme, cachedTracksGroup]: [string, Track[]]) => { ··· 108 146 109 147 try { 110 148 const conn = await connection(scheme); 111 - return await conn.sendAction("resolve", args); 149 + return await conn.sendAction("resolve", args, { timeoutDuration: 60000 * 5 }); 112 150 } catch (err) { 113 151 console.error(`[configuration/input] Resolve error for scheme '${scheme}'.`, err); 114 152 } 115 153 }; 116 154 155 + context.setActionHandler("consult", consult); 117 156 context.setActionHandler("contextualize", contextualize); 157 + context.setActionHandler("groupConsult", groupConsult); 118 158 context.setActionHandler("list", list); 119 159 context.setActionHandler("resolve", resolve); 120 160 121 161 //////////////////////////////////////////// 122 162 // 🛠️ 123 163 //////////////////////////////////////////// 124 - async function groupTracksPerScheme(tracks: Track[]) { 125 - return tracks.reduce( 126 - (acc: Record<string, Track[]>, track: Track) => { 127 - const scheme = track.uri.split(":", 1)[0]; 128 - return { ...acc, [scheme]: [...(acc[scheme] || []), track] }; 129 - }, 164 + async function groupTracks(tracks: Track[]) { 165 + return groupTracksPerScheme( 166 + tracks, 130 167 Object.fromEntries( 131 168 Object.entries(CONNECTIONS).map(([k, _v]) => { 132 169 return [k, []];
+7
src/pages/configurator/input/_manifest.json
··· 3 3 "title": "Diffuse Configurator | Input", 4 4 "entrypoint": "index.html", 5 5 "actions": { 6 + "consult": { 7 + "title": "Consult", 8 + "params_schema": { 9 + "type": "string", 10 + "description": "The uri to check the availability of." 11 + } 12 + }, 6 13 "contextualize": { 7 14 "title": "Contextualize", 8 15 "description": "Provide context to all inputs.",
+18
src/pages/core/types.d.ts
··· 1 + /* INPUT */ 2 + 3 + /** 4 + * Consultation. 5 + * 6 + * `consult` can be "undetermined" if only a scheme was given 7 + * instead of a full URI. 8 + */ 9 + export type Consult = 10 + | { supported: false; reason: string } 11 + | { supported: true; consult: "undetermined" | boolean }; 12 + 13 + export type ConsultGrouping = 14 + | { available: false; reason: string } 15 + | { available: true; tracks: Track[] }; 16 + 17 + export type GroupConsult = Record<string, ConsultGrouping>; 18 + 1 19 /* OUTPUT */ 2 20 3 21 export interface Output<S = TrackStats, T = TrackTags> {
+3 -1
src/pages/engine/queue/_applet.astro
··· 10 10 // SETUP 11 11 //////////////////////////////////////////// 12 12 const worker = endpoint<Actions>( 13 - new SharedWorker("../../../scripts/engine/queue/worker", { type: "module" }).port, 13 + new SharedWorker(new URL("../../../scripts/engine/queue/worker", import.meta.url), { 14 + type: "module", 15 + }).port, 14 16 ); 15 17 16 18 // Register applet
+8 -1
src/pages/input/native-fs/_applet.astro
··· 26 26 // SETUP 27 27 //////////////////////////////////////////// 28 28 const worker = endpoint<Actions>( 29 - new SharedWorker("../../../scripts/input/native-fs/worker", { type: "module" }).port, 29 + new SharedWorker(new URL("../../../scripts/input/native-fs/worker", import.meta.url), { 30 + type: "module", 31 + }).port, 30 32 ); 31 33 32 34 // Register applet ··· 43 45 return await worker.call.contextualize(cachedTracks); 44 46 }; 45 47 48 + const groupConsult = async (tracks: Track[]) => { 49 + return await worker.call.groupConsult(tracks); 50 + }; 51 + 46 52 const list = async (cachedTracks: Track[] = []) => { 47 53 return await worker.call.list(cachedTracks); 48 54 }; ··· 61 67 62 68 context.setActionHandler("consult", consult); 63 69 context.setActionHandler("contextualize", contextualize); 70 + context.setActionHandler("groupConsult", groupConsult); 64 71 context.setActionHandler("list", list); 65 72 context.setActionHandler("resolve", resolve); 66 73 context.setActionHandler("mount", mount);
+8 -1
src/pages/input/opensubsonic/_applet.astro
··· 27 27 // SETUP 28 28 //////////////////////////////////////////// 29 29 const worker = endpoint<Actions>( 30 - new SharedWorker("../../../scripts/input/opensubsonic/worker", { type: "module" }).port, 30 + new SharedWorker(new URL("../../../scripts/input/opensubsonic/worker", import.meta.url), { 31 + type: "module", 32 + }).port, 31 33 ); 32 34 33 35 // Register applet ··· 45 47 ui?.setServers({ ...ui?.servers(), ...s }); 46 48 }; 47 49 50 + const groupConsult = async (tracks: Track[]) => { 51 + return await worker.call.groupConsult(tracks); 52 + }; 53 + 48 54 const list = async (cachedTracks: Track[] = []) => { 49 55 return await worker.call.list(cachedTracks); 50 56 }; ··· 59 65 60 66 context.setActionHandler("consult", consult); 61 67 context.setActionHandler("contextualize", contextualize); 68 + context.setActionHandler("groupConsult", groupConsult); 62 69 context.setActionHandler("list", list); 63 70 context.setActionHandler("resolve", resolve); 64 71 context.setActionHandler("mount", mount);
+8 -1
src/pages/input/s3/_applet.astro
··· 47 47 // SETUP 48 48 //////////////////////////////////////////// 49 49 const worker = endpoint<Actions>( 50 - new SharedWorker("../../../scripts/input/s3/worker", { type: "module" }).port, 50 + new SharedWorker(new URL("../../../scripts/input/s3/worker", import.meta.url), { 51 + type: "module", 52 + }).port, 51 53 ); 52 54 53 55 // Register applet ··· 65 67 ui?.setBuckets({ ...ui?.buckets(), ...s }); 66 68 }; 67 69 70 + const groupConsult = async (tracks: Track[]) => { 71 + return await worker.call.groupConsult(tracks); 72 + }; 73 + 68 74 const list = async (cachedTracks: Track[] = []) => { 69 75 return await worker.call.list(cachedTracks); 70 76 }; ··· 79 85 80 86 context.setActionHandler("consult", consult); 81 87 context.setActionHandler("contextualize", contextualize); 88 + context.setActionHandler("groupConsult", groupConsult); 82 89 context.setActionHandler("list", list); 83 90 context.setActionHandler("resolve", resolve); 84 91 context.setActionHandler("mount", mount);
-3
src/pages/orchestrator/input-cache/_applet.astro
··· 44 44 45 45 const cachedTracks = output.data.tracks.collection; 46 46 47 - // TODO: Is there a better time to do this? 48 - // Goal = figure out servers/buckets/context used, 49 - // which are then used in the following `list` action. 50 47 await input.sendAction("contextualize", cachedTracks, { 51 48 timeoutDuration: 60000 * 5, 52 49 });
+18 -3
src/pages/orchestrator/queue-tracks/_applet.astro
··· 1 1 <script> 2 - import type { ManagedOutput } from "@applets/core/types.d.ts"; 2 + import type { GroupConsult, ManagedOutput, Track } from "@applets/core/types.d.ts"; 3 3 import { applet, makeConnect, register, wait } from "@scripts/applet/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // SETUP 7 7 //////////////////////////////////////////// 8 8 import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 9 + import { groupTracksPerScheme } from "@scripts/common"; 9 10 10 11 // Register applet 11 12 const context = register(); ··· 13 14 14 15 // Applet connections 15 16 const configurator = { 17 + input: await applet("/configurator/input"), 16 18 output: await applet<ManagedOutput>("/configurator/output"), 17 19 }; 18 20 ··· 28 30 // Add tracks to the queue once the tracks have been loaded; 29 31 // and every time the collection changes. 30 32 31 - wait(configurator.output, (d) => d?.tracks.state === "loaded").then(() => { 33 + wait(configurator.output, (d) => d?.tracks.state === "loaded").then(async () => { 32 34 connect( 33 35 configurator.output, 34 36 (data) => data.tracks.cacheId, 35 - () => engine.queue.sendAction("fill", configurator.output.data.tracks.collection), 37 + async () => { 38 + const groups = await configurator.input.sendAction<GroupConsult>( 39 + "groupConsult", 40 + configurator.output.data.tracks.collection, 41 + { timeoutDuration: 60000 * 5 }, 42 + ); 43 + 44 + const tracks = Object.values(groups).reduce((acc: Track[], value) => { 45 + if (value.available === false) return acc; 46 + return [...acc, ...value.tracks]; 47 + }, []); 48 + 49 + engine.queue.sendAction("fill", tracks); 50 + }, 36 51 ); 37 52 }); 38 53 </script>
+3 -1
src/pages/output/indexed-db/_applet.astro
··· 9 9 // SETUP 10 10 //////////////////////////////////////////// 11 11 const worker = endpoint<Actions>( 12 - new SharedWorker("../../../scripts/output/indexed-db/worker", { type: "module" }).port, 12 + new SharedWorker(new URL("../../../scripts/output/indexed-db/worker", import.meta.url), { 13 + type: "module", 14 + }).port, 13 15 ); 14 16 15 17 // Register applet
+3 -1
src/pages/output/native-fs/_applet.astro
··· 13 13 // SETUP 14 14 //////////////////////////////////////////// 15 15 const worker = endpoint<Actions>( 16 - new SharedWorker("../../../scripts/output/native-fs/worker", { type: "module" }).port, 16 + new SharedWorker(new URL("../../../scripts/output/native-fs/worker", import.meta.url), { 17 + type: "module", 18 + }).port, 17 19 ); 18 20 19 21 // Register applet
+3 -1
src/pages/processor/artwork/_applet.astro
··· 8 8 // SETUP 9 9 //////////////////////////////////////////// 10 10 const worker = endpoint<Actions>( 11 - new SharedWorker("../../../scripts/processor/artwork/worker", { type: "module" }).port, 11 + new SharedWorker(new URL("../../../scripts/processor/artwork/worker", import.meta.url), { 12 + type: "module", 13 + }).port, 12 14 ); 13 15 14 16 // Register
+3 -1
src/pages/processor/metadata/_applet.astro
··· 7 7 // SETUP 8 8 //////////////////////////////////////////// 9 9 const worker = endpoint<Actions>( 10 - new SharedWorker("../../../scripts/processor/metadata/worker", { type: "module" }).port, 10 + new SharedWorker(new URL("../../../scripts/processor/metadata/worker", import.meta.url), { 11 + type: "module", 12 + }).port, 11 13 ); 12 14 13 15 // Register applet
+12 -2
src/scripts/common.ts
··· 47 47 48 48 export function endpoint<T extends Record<string, any>>(port: MessagePort) { 49 49 const e = createEndpoint<T>(port); 50 - if ("start" in port) port.start(); 50 + port.start(); 51 51 return e; 52 52 } 53 53 ··· 55 55 (self as unknown as SharedWorkerGlobalScope).onconnect = (event: MessageEvent) => { 56 56 const port = event.ports[0]; 57 57 createEndpoint<T>(port).expose(actions); 58 - if ("start" in port) port.start(); 58 + port.start(); 59 59 }; 60 60 61 61 return actions; 62 + } 63 + 64 + export function groupTracksPerScheme( 65 + tracks: Track[], 66 + initial: Record<string, Track[]> = {}, 67 + ): Record<string, Track[]> { 68 + return tracks.reduce((acc: Record<string, Track[]>, track: Track) => { 69 + const scheme = track.uri.split(":", 1)[0]; 70 + return { ...acc, [scheme]: [...(acc[scheme] || []), track] }; 71 + }, initial); 62 72 } 63 73 64 74 export function inIframe() {
+4
src/scripts/engine/queue/worker.ts
··· 2 2 import { expose } from "../../../scripts/common.ts"; 3 3 import type { State } from "./types"; 4 4 5 + self.addEventListener("message", (event) => { 6 + console.log("QUEUE", event.data); 7 + }); 8 + 5 9 //////////////////////////////////////////// 6 10 // ACTIONS 7 11 ////////////////////////////////////////////
+9
src/scripts/input/native-fs/common.ts
··· 19 19 return { id, handle }; 20 20 }); 21 21 } 22 + export function groupTracksByHandle(tracks: Track[]) { 23 + return tracks.reduce((acc: Record<string, { tracks: Track[] }>, track: Track) => { 24 + const id = trackHandleId(track); 25 + if (!id) return acc; 26 + 27 + const obj = { tracks: acc[id] ? [...acc[id].tracks, track] : [track] }; 28 + return { ...acc, [id]: obj }; 29 + }, {}); 30 + } 22 31 23 32 export function isSupported() { 24 33 return !!(globalThis as any).showDirectoryPicker;
+32 -5
src/scripts/input/native-fs/worker.ts
··· 1 1 import * as URI from "uri-js"; 2 2 3 - import type { Track } from "@applets/core/types.d.ts"; 3 + import type { Consult, ConsultGrouping, GroupConsult, Track } from "@applets/core/types.d.ts"; 4 4 import { SCHEME } from "./constants"; 5 - import { fetchHandles, fetchHandlesList, recursiveList, trackHandleId } from "./common"; 5 + import { 6 + fetchHandles, 7 + fetchHandlesList, 8 + groupTracksByHandle, 9 + recursiveList, 10 + trackHandleId, 11 + } from "./common"; 6 12 import { expose } from "@scripts/common"; 7 13 8 14 //////////////////////////////////////////// ··· 11 17 const actions = expose({ 12 18 consult, 13 19 contextualize, 20 + groupConsult, 14 21 list, 15 22 resolve, 16 23 }); ··· 19 26 20 27 // Actions 21 28 22 - export async function consult(fileUriOrScheme: string) { 29 + export async function consult(fileUriOrScheme: string): Promise<Consult> { 23 30 if (!self.FileSystemDirectoryHandle) { 24 31 return { supported: false, reason: "File System Access API is not supported" }; 25 32 } 26 33 27 34 if (!fileUriOrScheme.includes(":")) { 28 35 if (fileUriOrScheme !== SCHEME) return { supported: false, reason: "Scheme does not match" }; 29 - return { supported: true }; 36 + return { supported: true, consult: "undetermined" }; 30 37 } 31 38 32 39 const handles = await fetchHandles(); 33 40 const uri = URI.parse(fileUriOrScheme); 34 41 if (uri.scheme !== SCHEME) return { supported: false, reason: "Scheme does not match" }; 35 - return { supported: true, consultation: uri.host && !!handles[uri.host] }; 42 + return { supported: true, consult: uri.host && !!handles[uri.host] ? true : false }; 36 43 } 37 44 38 45 export async function contextualize(cachedTracks: Track[]) {} 46 + 47 + async function groupConsult(tracks: Track[]): Promise<GroupConsult> { 48 + const groups = groupTracksByHandle(tracks); 49 + const handles = await fetchHandles(); 50 + 51 + const promises = Object.entries(groups).map(async ([handleId, { tracks }]) => { 52 + const handle = handles[handleId]; 53 + const grouping: ConsultGrouping = handle 54 + ? { available: true, tracks } 55 + : { available: false, reason: "Handle not available" }; 56 + 57 + return { 58 + key: URI.serialize({ scheme: SCHEME, host: handleId }), 59 + grouping, 60 + }; 61 + }); 62 + 63 + const entries = (await Promise.all(promises)).map((entry) => [entry.key, entry.grouping]); 64 + return Object.fromEntries(entries); 65 + } 39 66 40 67 export async function list(cachedTracks: Track[] = []) { 41 68 const handles = await fetchHandlesList();
+128 -3
src/scripts/input/opensubsonic/common.ts
··· 1 + import { SubsonicAPI, type Child } from "subsonic-api"; 1 2 import * as IDB from "idb-keyval"; 3 + import * as URI from "uri-js"; 4 + import QS from "query-string"; 2 5 3 6 import type { Server } from "./types"; 4 - import { IDB_SERVERS } from "./constants"; 7 + import { IDB_SERVERS, SCHEME } from "./constants"; 8 + import type { Track } from "@applets/core/types"; 9 + 10 + //////////////////////////////////////////// 11 + // 🛠️ 12 + //////////////////////////////////////////// 13 + export function autoTypeToTrackKind(type: Child["type"]): Track["kind"] { 14 + switch (type?.toLowerCase()) { 15 + case "audiobook": 16 + return "audiobook"; 17 + 18 + case "music": 19 + return "music"; 20 + 21 + case "podcast": 22 + return "podcast"; 23 + 24 + default: 25 + return "miscellaneous"; 26 + } 27 + } 28 + 29 + export function buildURI(server: Server, args: { songId: string; path?: string }) { 30 + return URI.serialize({ 31 + scheme: SCHEME, 32 + userinfo: server.apiKey 33 + ? URI.escapeComponent(server.apiKey) 34 + : `${URI.escapeComponent(server.username || "")}:${URI.escapeComponent(server.password || "")}`, 35 + host: server.host.replace(/^https?:\/\//, ""), 36 + path: args.path, 37 + query: QS.stringify({ 38 + songId: args.songId, 39 + tls: server.tls ? "t" : "f", 40 + }), 41 + }); 42 + } 43 + 44 + export async function consultServer(server: Server) { 45 + const client = createClient(server); 46 + const resp = await client.ping().catch(() => undefined); 47 + 48 + return resp?.status?.toLowerCase() === "ok"; 49 + } 50 + 51 + export function createClient(server: Server) { 52 + return new SubsonicAPI({ 53 + url: `http${server.tls ? "s" : ""}://${server.host}`, 54 + auth: server.apiKey 55 + ? { apiKey: URI.unescapeComponent(server.apiKey) } 56 + : { 57 + username: URI.unescapeComponent(server.username || ""), 58 + password: URI.unescapeComponent(server.password || ""), 59 + }, 60 + }); 61 + } 62 + 63 + export function groupTracksByServer(tracks: Track[]) { 64 + return tracks.reduce((acc: Record<string, { server: Server; tracks: Track[] }>, track: Track) => { 65 + const parsed = parseURI(track.uri); 66 + if (!parsed) return acc; 67 + 68 + const id = serverId(parsed.server); 69 + const obj = { server: parsed.server, tracks: acc[id] ? [...acc[id].tracks, track] : [track] }; 70 + 71 + return { ...acc, [id]: obj }; 72 + }, {}); 73 + } 5 74 6 75 export async function loadServers(): Promise<Record<string, Server>> { 7 76 const i = await IDB.get(IDB_SERVERS); 8 77 return i ? i : {}; 9 78 } 10 79 80 + export function parseURI( 81 + uriString: string, 82 + ): { path: string | undefined; server: Server; songId: string | undefined } | undefined { 83 + const uri = URI.parse(uriString); 84 + if (uri.scheme !== SCHEME) return undefined; 85 + if (!uri.host) return undefined; 86 + 87 + let apiKey: string | undefined = undefined; 88 + let username: string | undefined = undefined; 89 + let password: string | undefined = undefined; 90 + 91 + if (uri.userinfo?.includes(":")) { 92 + // Username + Password 93 + const [u, p] = uri.userinfo.split(":"); 94 + username = u; 95 + password = p; 96 + if (!username || !password) return undefined; 97 + } else { 98 + // API key 99 + apiKey = uri.userinfo; 100 + if (!apiKey) return undefined; 101 + } 102 + 103 + const qs = QS.parse(uri.query || ""); 104 + 105 + const server = { 106 + apiKey, 107 + host: uri.port ? `${uri.host}:${uri.port}` : uri.host, 108 + password, 109 + tls: qs.tls === "f" ? false : true, 110 + username, 111 + }; 112 + 113 + const path = uri.path; 114 + const songId = typeof qs.songId === "string" ? qs.songId : undefined; 115 + 116 + return { path, server, songId }; 117 + } 118 + 11 119 export async function saveServers(items: Record<string, Server>) { 12 120 await IDB.set(IDB_SERVERS, items); 13 121 } 14 122 123 + export function serversFromTracks(tracks: Track[]) { 124 + return tracks.reduce((acc: Record<string, Server>, track: Track) => { 125 + const parsed = parseURI(track.uri); 126 + if (!parsed) return acc; 127 + 128 + const id = serverId(parsed.server); 129 + if (acc[id]) return acc; 130 + 131 + return { ...acc, [id]: parsed.server }; 132 + }, {}); 133 + } 134 + 15 135 export function serverId(server: Server) { 16 - if (server.apiKey) return `${server.apiKey}@${server.host}`; 17 - return `${server.username}:${server.password}@${server.host}`; 136 + const parts = { 137 + host: server.host, 138 + query: `tls=${server.tls ? "t" : "f"}`, 139 + }; 140 + 141 + if (server.apiKey) return URI.serialize({ ...parts, userinfo: server.apiKey }); 142 + return URI.serialize({ ...parts, userinfo: `${server.username}:${server.password}` }); 18 143 }
+54 -107
src/scripts/input/opensubsonic/worker.ts
··· 2 2 import * as URI from "uri-js"; 3 3 import QS from "query-string"; 4 4 5 - import type { Track } from "@applets/core/types.d.ts"; 6 - import type { Server } from "./types.d.ts"; 5 + import type { Consult, ConsultGrouping, GroupConsult, Track } from "@applets/core/types.d.ts"; 7 6 import { SCHEME } from "./constants.ts"; 8 - import { loadServers, serverId } from "./common.ts"; 7 + import { 8 + autoTypeToTrackKind, 9 + buildURI, 10 + consultServer, 11 + createClient, 12 + groupTracksByServer, 13 + loadServers, 14 + parseURI, 15 + serverId, 16 + serversFromTracks, 17 + } from "./common.ts"; 9 18 import { expose } from "../../../scripts/common.ts"; 10 19 11 20 //////////////////////////////////////////// ··· 14 23 const actions = expose({ 15 24 consult, 16 25 contextualize, 26 + groupConsult, 17 27 list, 18 28 resolve, 19 29 }); ··· 22 32 23 33 // Actions 24 34 25 - async function consult(fileUriOrScheme: string) { 26 - // TODO: Check if server is available + CORS works? 27 - return { supported: true }; 35 + async function consult(fileUriOrScheme: string): Promise<Consult> { 36 + if (!fileUriOrScheme.includes(":")) return { supported: true, consult: "undetermined" }; 37 + 38 + const parsed = parseURI(fileUriOrScheme); 39 + if (!parsed) return { supported: true, consult: "undetermined" }; 40 + 41 + const consult = await consultServer(parsed.server); 42 + return { supported: true, consult }; 28 43 } 29 44 30 45 async function contextualize(tracks: Track[]) { 31 46 return serversFromTracks(tracks); 32 47 } 33 48 49 + async function groupConsult(tracks: Track[]): Promise<GroupConsult> { 50 + const groups = groupTracksByServer(tracks); 51 + 52 + const promises = Object.entries(groups).map(async ([serverId, { server, tracks }]) => { 53 + const available = await consultServer(server); 54 + const grouping: ConsultGrouping = available 55 + ? { available, tracks } 56 + : { available, reason: "Server ping failed" }; 57 + 58 + return { 59 + key: `${SCHEME}:${serverId}`, 60 + grouping, 61 + }; 62 + }); 63 + 64 + const entries = (await Promise.all(promises)).map((entry) => [entry.key, entry.grouping]); 65 + return Object.fromEntries(entries); 66 + } 67 + 34 68 async function list(cachedTracks: Track[] = []) { 35 - const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => { 36 - const uri = URI.parse(t.uri); 37 - if (!uri.path) return acc; 38 - return { ...acc, [URI.unescapeComponent(uri.path)]: t }; 69 + const cache = cachedTracks.reduce((acc: Record<string, Record<string, Track>>, t: Track) => { 70 + const parsed = parseURI(t.uri); 71 + if (!parsed || !parsed.path) return acc; 72 + 73 + const bid = serverId(parsed?.server); 74 + const trk = { [parsed.path]: t }; 75 + 76 + return { ...acc, [bid]: acc[bid] ? { ...acc[bid], ...trk } : trk }; 39 77 }, {}); 78 + 79 + // TODO 40 80 41 81 async function search(client: SubsonicAPI, offset = 0): Promise<Child[]> { 42 82 const result = await client.search3({ ··· 95 135 } 96 136 97 137 async function resolve({ uri }: { method: string; uri: string }) { 98 - const server = parseURI(uri); 99 - if (!server) return undefined; 138 + const parsed = parseURI(uri); 139 + if (!parsed) return undefined; 100 140 101 - const client = createClient(server); 102 - const parsedURI = URI.parse(uri); 103 - const qs = QS.parse(parsedURI.query || ""); 104 - 105 - const songId = typeof qs.songId === "string" ? qs.songId : undefined; 141 + const client = createClient(parsed.server); 142 + const songId = parsed.songId; 106 143 if (!songId) return undefined; 107 144 108 145 // TODO: ··· 124 161 125 162 return { expiresAt: Infinity, url }; 126 163 } 127 - 128 - //////////////////////////////////////////// 129 - // 🛠️ 130 - //////////////////////////////////////////// 131 - function autoTypeToTrackKind(type: Child["type"]): Track["kind"] { 132 - switch (type?.toLowerCase()) { 133 - case "audiobook": 134 - return "audiobook"; 135 - 136 - case "music": 137 - return "music"; 138 - 139 - case "podcast": 140 - return "podcast"; 141 - 142 - default: 143 - return "miscellaneous"; 144 - } 145 - } 146 - 147 - function buildURI(server: Server, args: { songId: string; path?: string }) { 148 - return URI.serialize({ 149 - scheme: SCHEME, 150 - userinfo: server.apiKey 151 - ? URI.escapeComponent(server.apiKey) 152 - : `${URI.escapeComponent(server.username || "")}:${URI.escapeComponent(server.password || "")}`, 153 - host: server.host.replace(/^https?:\/\//, ""), 154 - path: args.path, 155 - query: QS.stringify({ 156 - songId: args.songId, 157 - tls: server.tls ? "t" : "f", 158 - }), 159 - }); 160 - } 161 - 162 - function createClient(server: Server) { 163 - return new SubsonicAPI({ 164 - url: `http${server.tls ? "s" : ""}://${server.host}`, 165 - auth: server.apiKey 166 - ? { apiKey: URI.unescapeComponent(server.apiKey) } 167 - : { 168 - username: URI.unescapeComponent(server.username || ""), 169 - password: URI.unescapeComponent(server.password || ""), 170 - }, 171 - }); 172 - } 173 - 174 - function parseURI(uriString: string): Server | undefined { 175 - const uri = URI.parse(uriString); 176 - if (uri.scheme !== SCHEME) return undefined; 177 - if (!uri.host) return undefined; 178 - 179 - let apiKey: string | undefined = undefined; 180 - let username: string | undefined = undefined; 181 - let password: string | undefined = undefined; 182 - 183 - if (uri.userinfo?.includes(":")) { 184 - // Username + Password 185 - const [u, p] = uri.userinfo.split(":"); 186 - username = u; 187 - password = p; 188 - if (!username || !password) return undefined; 189 - } else { 190 - // API key 191 - apiKey = uri.userinfo; 192 - if (!apiKey) return undefined; 193 - } 194 - 195 - const qs = QS.parse(uri.query || ""); 196 - 197 - return { 198 - apiKey, 199 - host: uri.port ? `${uri.host}:${uri.port}` : uri.host, 200 - password, 201 - tls: qs.tls === "f" ? false : true, 202 - username, 203 - }; 204 - } 205 - 206 - function serversFromTracks(tracks: Track[]) { 207 - return tracks.reduce((acc: Record<string, Server>, track: Track) => { 208 - const server = parseURI(track.uri); 209 - if (!server) return acc; 210 - 211 - const id = serverId(server); 212 - if (acc[id]) return acc; 213 - 214 - return { ...acc, [id]: server }; 215 - }, {}); 216 - }
+30 -6
src/scripts/input/s3/common.ts
··· 12 12 //////////////////////////////////////////// 13 13 export function bucketsFromTracks(tracks: Track[]) { 14 14 return tracks.reduce((acc: Record<string, Bucket>, track: Track) => { 15 - const bucket = parseURI(track.uri); 16 - if (!bucket) return acc; 15 + const parsed = parseURI(track.uri); 16 + if (!parsed) return acc; 17 17 18 - const id = bucketId(bucket); 18 + const id = bucketId(parsed.bucket); 19 19 if (acc[id]) return acc; 20 20 21 - return { ...acc, [id]: bucket }; 21 + return { ...acc, [id]: parsed.bucket }; 22 22 }, {}); 23 23 } 24 24 ··· 40 40 }); 41 41 } 42 42 43 + export async function consultBucket(bucket: Bucket) { 44 + const client = createClient(bucket); 45 + return await client.bucketExists(bucket.bucketName); 46 + } 47 + 43 48 export function createClient(bucket: Bucket) { 44 49 return new S3Client({ 45 50 bucket: bucket.bucketName, ··· 58 63 ); 59 64 } 60 65 66 + export function groupTracksByBucket(tracks: Track[]) { 67 + return tracks.reduce((acc: Record<string, { bucket: Bucket; tracks: Track[] }>, track: Track) => { 68 + const parsed = parseURI(track.uri); 69 + if (!parsed) return acc; 70 + 71 + const id = bucketId(parsed.bucket); 72 + const obj = { bucket: parsed.bucket, tracks: acc[id] ? [...acc[id].tracks, track] : [track] }; 73 + 74 + return { ...acc, [id]: obj }; 75 + }, {}); 76 + } 77 + 61 78 export async function loadBuckets(): Promise<Record<string, Bucket>> { 62 79 const i = await IDB.get(IDB_BUCKETS); 63 80 return i ? i : {}; 64 81 } 65 82 66 - export function parseURI(uriString: string): Bucket | undefined { 83 + export function parseURI(uriString: string): { bucket: Bucket; path: string } | undefined { 67 84 const uri = URI.parse(uriString); 68 85 if (uri.scheme !== SCHEME) return undefined; 69 86 if (!uri.host) return undefined; ··· 73 90 74 91 const qs = QS.parse(uri.query || ""); 75 92 76 - return { 93 + const bucket = { 77 94 accessKey, 78 95 bucketName: typeof qs.bucketName === "string" ? qs.bucketName : "", 79 96 host: uri.host, ··· 81 98 region: typeof qs.region === "string" ? qs.region : "", 82 99 secretKey, 83 100 }; 101 + 102 + const path = (bucket.path.replace(/\/$/, "") + URI.unescapeComponent(uri.path || "")).replace( 103 + /^\//, 104 + "", 105 + ); 106 + 107 + return { bucket, path }; 84 108 } 85 109 86 110 export async function saveBuckets(items: Record<string, Bucket>) {
+54 -21
src/scripts/input/s3/worker.ts
··· 1 1 import * as URI from "uri-js"; 2 2 3 - import type { Track } from "@applets/core/types.d.ts"; 3 + import type { Consult, ConsultGrouping, GroupConsult, Track } from "@applets/core/types.d.ts"; 4 4 import { isAudioFile } from "@scripts/input/common"; 5 - import { bucketsFromTracks, buildURI, createClient, loadBuckets, parseURI } from "./common"; 5 + import { 6 + bucketId, 7 + bucketsFromTracks, 8 + buildURI, 9 + consultBucket, 10 + createClient, 11 + groupTracksByBucket, 12 + loadBuckets, 13 + parseURI, 14 + } from "./common"; 6 15 import { expose } from "@scripts/common"; 16 + import { SCHEME } from "./constants"; 7 17 8 18 //////////////////////////////////////////// 9 19 // ACTIONS ··· 11 21 const actions = expose({ 12 22 consult, 13 23 contextualize, 24 + groupConsult, 14 25 list, 15 26 resolve, 16 27 }); ··· 19 30 20 31 // Actions 21 32 22 - async function consult(fileUriOrScheme: string) { 23 - if (!navigator.onLine) 24 - return { supported: false, reason: "Internet connection is not available" }; 33 + async function consult(fileUriOrScheme: string): Promise<Consult> { 34 + if (!fileUriOrScheme.includes(":")) return { supported: true, consult: "undetermined" }; 25 35 26 - // TODO: Check if bucket is available + CORS works? 27 - return { supported: true }; 36 + const parsed = parseURI(fileUriOrScheme); 37 + if (!parsed) return { supported: true, consult: "undetermined" }; 38 + 39 + const consult = await consultBucket(parsed.bucket); 40 + return { supported: true, consult }; 28 41 } 29 42 30 43 async function contextualize(tracks: Track[]) { 31 44 return bucketsFromTracks(tracks); 32 45 } 33 46 47 + async function groupConsult(tracks: Track[]): Promise<GroupConsult> { 48 + const groups = groupTracksByBucket(tracks); 49 + 50 + const promises = Object.entries(groups).map(async ([bucketId, { bucket, tracks }]) => { 51 + const available = await consultBucket(bucket); 52 + const grouping: ConsultGrouping = available 53 + ? { available, tracks } 54 + : { available, reason: "Bucket unavailable" }; 55 + 56 + return { 57 + key: `${SCHEME}:${bucketId}`, 58 + grouping, 59 + }; 60 + }); 61 + 62 + const entries = (await Promise.all(promises)).map((entry) => [entry.key, entry.grouping]); 63 + return Object.fromEntries(entries); 64 + } 65 + 34 66 async function list(cachedTracks: Track[] = []) { 35 - const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => { 36 - const uri = URI.parse(t.uri); 37 - if (!uri.path) return acc; 38 - return { ...acc, [URI.unescapeComponent(uri.path)]: t }; 67 + const cache = cachedTracks.reduce((acc: Record<string, Record<string, Track>>, t: Track) => { 68 + const parsed = parseURI(t.uri); 69 + if (!parsed) return acc; 70 + 71 + const bid = bucketId(parsed?.bucket); 72 + const trk = { [parsed.path]: t }; 73 + 74 + return { ...acc, [bid]: acc[bid] ? { ...acc[bid], ...trk } : trk }; 39 75 }, {}); 40 76 41 77 const buckets = await loadBuckets(); 42 78 const promises = Object.values(buckets).map(async (bucket) => { 43 79 const client = createClient(bucket); 80 + const bid = bucketId(bucket); 44 81 45 82 const list = await Array.fromAsync( 46 83 client.listObjects({ ··· 51 88 return list 52 89 .filter((l) => isAudioFile(l.key)) 53 90 .map((l) => { 54 - const cachedTrack = cache[`/${l.key}`]; 91 + const cachedTrack = cache[bid][l.key]; 55 92 56 93 const id = cachedTrack?.id || crypto.randomUUID(); 57 94 const stats = cachedTrack?.stats; ··· 72 109 } 73 110 74 111 async function resolve({ method, uri }: { method: string; uri: string }) { 75 - const bucket = parseURI(uri); 76 - if (!bucket) return undefined; 77 - 78 - const client = createClient(bucket); 79 - const parsedURI = URI.parse(uri); 80 - const path = ( 81 - bucket.path.replace(/\/$/, "") + URI.unescapeComponent(parsedURI.path || "") 82 - ).replace(/^\//, ""); 112 + const parsed = parseURI(uri); 113 + if (!parsed) return undefined; 83 114 84 115 const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 85 116 const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 86 - const url = await client.getPresignedUrl(method.toUpperCase() as any, path); 117 + 118 + const client = createClient(parsed.bucket); 119 + const url = await client.getPresignedUrl(method.toUpperCase() as any, parsed.path); 87 120 88 121 return { expiresAt: expiresAtSeconds, url }; 89 122 }
+1 -1
src/scripts/processor/artwork/worker.ts
··· 11 11 12 12 // Metadata worker 13 13 const metadataWorker = endpoint<MetadataActions>( 14 - new SharedWorker("../metadata/worker", { type: "module" }).port, 14 + new SharedWorker(new URL("../metadata/worker", import.meta.url), { type: "module" }).port, 15 15 ); 16 16 17 17 ////////////////////////////////////////////