diff --git a/deno.lock b/deno.lock
index e594cd8..e619c9b 100644
--- a/deno.lock
+++ b/deno.lock
@@ -30,6 +30,7 @@
"npm:@orama/plugin-qps@^3.1.7",
"npm:@phosphor-icons/web@^2.1.2",
"npm:@picocss/pico@^2.1.1",
+ "npm:@remote-ui/rpc@^1.4.7",
"npm:@tokenizer/http@~0.9.2",
"npm:@tokenizer/range@0.13",
"npm:@types/throttle-debounce@^5.0.2",
diff --git a/package-lock.json b/package-lock.json
index f0041dd..f824f47 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@orama/plugin-qps": "^3.1.7",
"@phosphor-icons/web": "^2.1.2",
"@picocss/pico": "^2.1.1",
+ "@remote-ui/rpc": "^1.4.7",
"@std/media-types": "npm:@jsr/std__media-types@^1.1.0",
"@tokenizer/http": "^0.9.2",
"@tokenizer/range": "^0.13.0",
@@ -1454,6 +1455,12 @@
"integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==",
"license": "MIT"
},
+ "node_modules/@remote-ui/rpc": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/@remote-ui/rpc/-/rpc-1.4.7.tgz",
+ "integrity": "sha512-ORiaKsbVBSEi3Z4YWOj5Ucrp70NrkNktI1hdqqfBW7Z3o0YoxTX9MIqtLmsc6721IbjmExvLrLip5I5Y7uAbng==",
+ "license": "MIT"
+ },
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
diff --git a/package.json b/package.json
index 49f1fbd..83cb81b 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"@orama/plugin-qps": "^3.1.7",
"@phosphor-icons/web": "^2.1.2",
"@picocss/pico": "^2.1.1",
+ "@remote-ui/rpc": "^1.4.7",
"@std/media-types": "npm:@jsr/std__media-types@^1.1.0",
"@tokenizer/http": "^0.9.2",
"@tokenizer/range": "^0.13.0",
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 94fb4c5..4695ef6 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -42,6 +42,7 @@ const engines = [
const input = [
{ url: "input/native-fs/", title: "Native File System" },
+ { url: "input/opensubsonic/", title: "Opensubsonic" },
{ url: "input/s3/", title: "S3-Compatible API" },
];
diff --git a/src/pages/input/opensubsonic/_applet.astro b/src/pages/input/opensubsonic/_applet.astro
index d54a6ad..659822a 100644
--- a/src/pages/input/opensubsonic/_applet.astro
+++ b/src/pages/input/opensubsonic/_applet.astro
@@ -17,39 +17,18 @@
-
-
diff --git a/src/pages/input/opensubsonic/common.ts b/src/pages/input/opensubsonic/common.ts
new file mode 100644
index 0000000..7dc4a5c
--- /dev/null
+++ b/src/pages/input/opensubsonic/common.ts
@@ -0,0 +1,18 @@
+import * as IDB from "idb-keyval";
+
+import type { Server } from "./types";
+import { IDB_SERVERS } from "./constants";
+
+export async function loadServers(): Promise> {
+ const i = await IDB.get(IDB_SERVERS);
+ return i ? i : {};
+}
+
+export async function saveServers(items: Record) {
+ await IDB.set(IDB_SERVERS, items);
+}
+
+export function serverId(server: Server) {
+ if (server.apiKey) return `${server.apiKey}@${server.host}`;
+ return `${server.username}:${server.password}@${server.host}`;
+}
diff --git a/src/pages/input/opensubsonic/constants.ts b/src/pages/input/opensubsonic/constants.ts
new file mode 100644
index 0000000..e1232f5
--- /dev/null
+++ b/src/pages/input/opensubsonic/constants.ts
@@ -0,0 +1,5 @@
+import manifest from "./_manifest.json";
+
+export const IDB_PREFIX = "@applets/input/opensubsonic";
+export const IDB_SERVERS = `${IDB_PREFIX}/servers`;
+export const SCHEME = manifest.input_properties.scheme;
diff --git a/src/pages/input/opensubsonic/types.d.ts b/src/pages/input/opensubsonic/types.d.ts
new file mode 100644
index 0000000..457b263
--- /dev/null
+++ b/src/pages/input/opensubsonic/types.d.ts
@@ -0,0 +1,8 @@
+// https://opensubsonic.netlify.app/docs/api-reference/
+export type Server = {
+ apiKey?: string;
+ host: string;
+ password?: string;
+ tls: boolean;
+ username?: string;
+};
diff --git a/src/pages/input/opensubsonic/ui.ts b/src/pages/input/opensubsonic/ui.ts
new file mode 100644
index 0000000..eb2d526
--- /dev/null
+++ b/src/pages/input/opensubsonic/ui.ts
@@ -0,0 +1,110 @@
+import { computed, effect, type Signal, signal } from "spellcaster";
+import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js";
+
+import type { Server } from "./types.d.ts";
+import { loadServers, saveServers, serverId } from "./common";
+
+////////////////////////////////////////////
+// UI
+////////////////////////////////////////////
+export const [servers, setServers] = signal>(await loadServers());
+const [form, setForm] = signal<{
+ api_key?: string;
+ host?: string;
+ password?: string;
+ username?: string;
+}>({});
+
+const serversMap = computed(() => {
+ return new Map(Object.entries(servers()));
+});
+
+effect(() => {
+ saveServers(servers());
+});
+
+////////////////////////////////////////////
+// UI ~ SERVERS
+////////////////////////////////////////////
+const Server = (server: Signal) => {
+ const onclick = () => {
+ const b = server();
+ const id = serverId(b);
+
+ const col = { ...servers() };
+ delete col[id];
+
+ setServers(col);
+ };
+
+ return tags.li({ onclick, style: "cursor: pointer" }, text(server().host));
+};
+
+const ServerList = computed(() => {
+ if (serversMap().size === 0) {
+ return tags.p({ id: "servers" }, [tags.small({}, text("Nothing added so far."))]);
+ }
+
+ return tags.ul({ id: "servers" }, repeat(serversMap, Server));
+});
+
+effect(() => {
+ document.querySelector("#servers")?.replaceWith(ServerList());
+});
+
+////////////////////////////////////////////
+// UI ~ FORM
+////////////////////////////////////////////
+function addServer(event: Event) {
+ event.preventDefault();
+
+ const f = form();
+
+ const server: Server = {
+ apiKey: f.api_key,
+ host: f.host?.replace(/^https?:\/\//, "").replace(/\/+$/, "") || "localhost:4533",
+ username: f.username,
+ tls: f.host?.startsWith("http://") || f.host?.startsWith("localhost") ? false : true,
+ password: f.password,
+ };
+
+ setServers({
+ ...servers(),
+ [serverId(server)]: server,
+ });
+}
+
+function Form() {
+ return tags.form({ onsubmit: addServer }, [
+ tags.fieldset({ className: "grid" }, [
+ Input("host", "Server host", "my.opensubsonic.server:4747", { required: true }),
+ ]),
+ tags.fieldset({ className: "grid" }, [
+ Input("username", "Server name", "username", { required: true }),
+ Input("password", "Password", "password", { required: true, type: "password" }),
+ ]),
+ tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]),
+ ]);
+}
+
+function Input(name: string, label: string, placeholder: string, opts: Props = {}) {
+ return tags.label({}, [
+ tags.span({}, [
+ tags.span({}, text(label)),
+ tags.small({}, text("required" in opts ? "" : " (optional)")),
+ ]),
+ tags.input({
+ ...opts,
+ name,
+ placeholder,
+ oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value),
+ }),
+ ]);
+}
+
+function formInput(name: string, value: string) {
+ setForm({ ...form(), [name]: value });
+}
+
+// 🚀
+document.querySelector("#form")?.replaceWith(Form());
diff --git a/src/pages/input/opensubsonic/worker.ts b/src/pages/input/opensubsonic/worker.ts
new file mode 100644
index 0000000..31f8ae6
--- /dev/null
+++ b/src/pages/input/opensubsonic/worker.ts
@@ -0,0 +1,216 @@
+import { createEndpoint, type MessageEndpoint } from "@remote-ui/rpc";
+import { SubsonicAPI, type Child } from "subsonic-api";
+import * as URI from "uri-js";
+import QS from "query-string";
+
+import type { Track } from "@applets/core/types.d.ts";
+import type { Server } from "./types.d.ts";
+import { SCHEME } from "./constants.ts";
+import { loadServers, serverId } from "./common.ts";
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+const actions = createEndpoint(self as MessageEndpoint);
+actions.expose({ consult, contextualize, list, resolve });
+
+export type Actions = {
+ consult: typeof consult;
+ contextualize: typeof contextualize;
+ list: typeof list;
+ resolve: typeof resolve;
+};
+
+async function consult(fileUriOrScheme: string) {
+ // TODO: Check if server is available + CORS works?
+ return { supported: true };
+}
+
+async function contextualize(tracks: Track[]) {
+ return serversFromTracks(tracks);
+}
+
+async function list(cachedTracks: Track[] = []) {
+ const cache = cachedTracks.reduce((acc: Record, t: Track) => {
+ const uri = URI.parse(t.uri);
+ if (!uri.path) return acc;
+ return { ...acc, [URI.unescapeComponent(uri.path)]: t };
+ }, {});
+
+ async function search(client: SubsonicAPI, offset = 0): Promise {
+ const result = await client.search3({
+ query: "",
+ artistCount: 0,
+ albumCount: 0,
+ songCount: 1000,
+ songOffset: offset,
+ });
+
+ const songs = result.searchResult3.song || [];
+
+ if (songs.length === 1000) {
+ const moreSongs = await search(client, offset + 1000);
+ return [...songs, ...moreSongs];
+ }
+
+ return songs;
+ }
+
+ const servers = await loadServers();
+ const promises = Object.values(servers).map(async (server) => {
+ const client = createClient(server);
+ const list = await search(client, 0);
+
+ return list
+ .filter((song) => !song.isVideo)
+ .map((song) => {
+ if (song.path && cache[song.path]) return cache[song.path];
+
+ const track: Track = {
+ id: crypto.randomUUID(),
+ kind: autoTypeToTrackKind(song.type),
+ uri: buildURI(server, { songId: song.id, path: song.path }),
+
+ stats: {
+ bitrate: song.bitRate,
+ duration: song.duration,
+ },
+ tags: {
+ album: song.album,
+ artist: song.artist,
+ disc: { no: song.discNumber || 1 },
+ genre: song.genre,
+ title: song.title,
+ track: { no: song.track || 1 },
+ year: song.year,
+ },
+ };
+
+ return track;
+ });
+ });
+
+ return (await Promise.all(promises)).flat(1);
+}
+
+async function resolve({ uri }: { method: string; uri: string }) {
+ console.log("RESOLVE", uri);
+ const server = parseURI(uri);
+ if (!server) return undefined;
+
+ const client = createClient(server);
+ const parsedURI = URI.parse(uri);
+ const qs = QS.parse(parsedURI.query || "");
+
+ const songId = typeof qs.songId === "string" ? qs.songId : undefined;
+ if (!songId) return undefined;
+
+ // TODO:
+ // const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days
+ // const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds;
+
+ const url = await client
+ .download({
+ id: songId,
+ format: "raw",
+ })
+ .then((a) => a.blob())
+ .then((blob) => URL.createObjectURL(blob));
+
+ // NOTE:
+ // First idea was to get the URL for the download and use that instead.
+ // Problem is, more often than not, servers don't allow for CORS Range requests,
+ // so it's basically useless.
+
+ return { expiresAt: Infinity, url };
+}
+
+////////////////////////////////////////////
+// 🛠️
+////////////////////////////////////////////
+function autoTypeToTrackKind(type: Child["type"]): Track["kind"] {
+ switch (type?.toLowerCase()) {
+ case "audiobook":
+ return "audiobook";
+
+ case "music":
+ return "music";
+
+ case "podcast":
+ return "podcast";
+
+ default:
+ return "miscellaneous";
+ }
+}
+
+function buildURI(server: Server, args: { songId: string; path?: string }) {
+ return URI.serialize({
+ scheme: SCHEME,
+ userinfo: server.apiKey
+ ? URI.escapeComponent(server.apiKey)
+ : `${URI.escapeComponent(server.username || "")}:${URI.escapeComponent(server.password || "")}`,
+ host: server.host.replace(/^https?:\/\//, ""),
+ path: args.path,
+ query: QS.stringify({
+ songId: args.songId,
+ tls: server.tls ? "t" : "f",
+ }),
+ });
+}
+
+function createClient(server: Server) {
+ return new SubsonicAPI({
+ url: `http${server.tls ? "s" : ""}://${server.host}`,
+ auth: server.apiKey
+ ? { apiKey: URI.unescapeComponent(server.apiKey) }
+ : {
+ username: URI.unescapeComponent(server.username || ""),
+ password: URI.unescapeComponent(server.password || ""),
+ },
+ });
+}
+
+function parseURI(uriString: string): Server | undefined {
+ const uri = URI.parse(uriString);
+ if (uri.scheme !== SCHEME) return undefined;
+ if (!uri.host) return undefined;
+
+ let apiKey: string | undefined = undefined;
+ let username: string | undefined = undefined;
+ let password: string | undefined = undefined;
+
+ if (uri.userinfo?.includes(":")) {
+ // Username + Password
+ const [u, p] = uri.userinfo.split(":");
+ username = u;
+ password = p;
+ if (!username || !password) return undefined;
+ } else {
+ // API key
+ apiKey = uri.userinfo;
+ if (!apiKey) return undefined;
+ }
+
+ const qs = QS.parse(uri.query || "");
+
+ return {
+ apiKey,
+ host: uri.port ? `${uri.host}:${uri.port}` : uri.host,
+ password,
+ tls: qs.tls === "f" ? false : true,
+ username,
+ };
+}
+
+function serversFromTracks(tracks: Track[]) {
+ return tracks.reduce((acc: Record, track: Track) => {
+ const server = parseURI(track.uri);
+ if (!server) return acc;
+
+ const id = serverId(server);
+ if (acc[id]) return acc;
+
+ return { ...acc, [id]: server };
+ }, {});
+}
diff --git a/src/scripts/applet/common.ts b/src/scripts/applet/common.ts
index d8ca877..e8067ac 100644
--- a/src/scripts/applet/common.ts
+++ b/src/scripts/applet/common.ts
@@ -292,7 +292,7 @@ export function register(): BroadcastedApplet {
// 🔮 Reactive state management
////////////////////////////////////////////
export function reactive(
- applet: Applet,
+ applet: Applet | AppletScope,
dataFn: (data: D) => T,
effectFn: (t: T, setter: (t: T) => void) => void,
) {
@@ -385,6 +385,10 @@ export function comparable(value: unknown) {
return xxh32(JSON.stringify(value));
}
+export function inIframe() {
+ return window.self !== window.top;
+}
+
export function hs(
tag: string,
astroScope: string,