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,