diff --git a/src/_data/facets.json b/src/_data/facets.json index c70a6f30..dd58011e 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -72,6 +72,12 @@ "category": "Data", "desc": "Add HTTPS URLs as audio sources." }, + { + "url": "facets/connect/https-json/index.html", + "title": "Connect / HTTPS + JSON", + "category": "Data", + "desc": "Add an HTTPS server with JSON directory listing support as an audio source." + }, { "url": "facets/connect/icecast/index.html", "title": "Connect / Icecast", diff --git a/src/components/index.js b/src/components/index.js index 6fa1965b..bff8ae04 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -14,6 +14,7 @@ import * as _EngineScope from "./engine/scope/element.js" import * as _InputDropbox from "./input/dropbox/element.js" import * as _InputEphemeralCache from "./input/ephemeral-cache/element.js" import * as _InputHttps from "./input/https/element.js" +import * as _InputHttpsJson from "./input/https-json/element.js" import * as _InputIcecast from "./input/icecast/element.js" import * as _InputLocal from "./input/local/element.js" import * as _InputOpensubsonic from "./input/opensubsonic/element.js" @@ -74,6 +75,7 @@ export const input = { dropbox: _InputDropbox, ephemeralCache: _InputEphemeralCache, https: _InputHttps, + httpsJson: _InputHttpsJson, icecast: _InputIcecast, local: _InputLocal, opensubsonic: _InputOpensubsonic, diff --git a/src/components/input/https-json/common.js b/src/components/input/https-json/common.js new file mode 100644 index 00000000..e39f3566 --- /dev/null +++ b/src/components/input/https-json/common.js @@ -0,0 +1,247 @@ +import * as URI from "fast-uri"; +import QS from "query-string"; + +import { cachedConsult } from "~/components/input/common.js"; +import { SCHEME } from "./constants.js"; + +/** + * @import {Track} from "~/definitions/types.d.ts" + */ + +//////////////////////////////////////////// +// 🛠️ +//////////////////////////////////////////// + +/** + * @typedef {{ host: string; dir: string; exclude?: string[] }} Server + */ + +/** + * @param {Server} server + * @returns {string} + */ +export function serverId(server) { + return `${server.host}${server.dir}`; +} + +/** + * Build a https-json:// URI. + * Protocol can be embedded in host (eg. http://localhost:8080) and is stored + * as a query param so the URI authority stays valid. + * + * @param {Server} server + * @param {string} [path] + * @returns {string} + */ +export function buildURI(server, path = "") { + let host = server.host; + let protocol; + + if (host.includes("://")) { + [protocol, host] = host.split("://"); + } + + const exclude = server.exclude?.length ? server.exclude.join(",") : undefined; + const query = QS.stringify({ dir: server.dir, exclude, protocol }); + return `${SCHEME}://${host}${path}${query ? `?${query}` : ""}`; +} + +/** + * @param {string} uriString + * @returns {{ server: Server; path: string } | undefined} + */ +export function parseURI(uriString) { + const uri = URI.parse(uriString); + if (uri.scheme !== SCHEME) return undefined; + if (!uri.host) return undefined; + + const qs = QS.parse(uri.query || ""); + const dir = typeof qs.dir === "string" ? qs.dir : "/"; + const protocol = typeof qs.protocol === "string" ? qs.protocol : undefined; + const exclude = typeof qs.exclude === "string" + ? qs.exclude.split(",").filter(Boolean) + : undefined; + + const rawHost = uri.port ? `${uri.host}:${uri.port}` : uri.host; + const host = protocol ? `${protocol}://${rawHost}` : rawHost; + const server = { host, dir, exclude }; + const path = uri.path || ""; + + return { server, path }; +} + +/** + * @param {Server} server + * @param {string} [path] + * @returns {string} + */ +export function toHttpUrl(server, path = "") { + const base = server.host.includes("://") + ? server.host + : `${ + server.host.split(":")[0] === "localhost" || + server.host.split(":")[0] === "127.0.0.1" + ? "http" + : "https" + }://${server.host}`; + + return base.replace(/\/$/, "") + (path ? "/" + path.replace(/^\//, "") : ""); +} + +/** + * @param {Track[]} tracks + * @returns {Record} + */ +export function serversFromTracks(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const id = serverId(parsed.server); + if (!acc[id]) acc[id] = parsed.server; + }); + + return acc; +} + +/** + * @param {Track[]} tracks + * @returns {Record} + */ +export function groupTracksByServer(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const id = serverId(parsed.server); + + if (acc[id]) { + acc[id].tracks.push(track); + } else { + acc[id] = { server: parsed.server, tracks: [track] }; + } + }); + + return acc; +} + +/** + * @param {string[]} uris + * @returns {Record} + */ +export function groupUrisByServer(uris) { + /** @type {Record} */ + const acc = {}; + + uris.forEach((uri) => { + const parsed = parseURI(uri); + if (!parsed) return; + + const id = serverId(parsed.server); + + if (acc[id]) { + acc[id].uris.push(uri); + } else { + acc[id] = { server: parsed.server, uris: [uri] }; + } + }); + + return acc; +} + +/** + * @param {Server} server + * @returns {Promise} + */ +async function checkAccess(server) { + try { + const url = toHttpUrl(server, server.dir); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + headers: { "Accept": "application/json" }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return response.ok; + } catch { + return false; + } +} + +export const checkAccessCached = cachedConsult(checkAccess, serverId); + +/** + * List all files on the server under server.dir using JSON directory listing. + * Fetches each directory with `Accept: application/json` and recurses into subdirs. + * + * @param {Server} server + * @returns {Promise} + */ +export async function listFiles(server) { + const paths = /** @type {string[]} */ ([]); + const exclude = new Set(server.exclude ?? []); + await listDir(server, server.dir, paths, exclude); + return paths; +} + +/** + * @param {Server} server + * @param {string} dir + * @param {string[]} paths + * @param {Set} exclude + */ +async function listDir(server, dir, paths, exclude) { + const url = toHttpUrl(server, dir); + + let response; + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + response = await fetch(url, { + headers: { "Accept": "application/json" }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + } catch { + return; + } + + if (!response.ok) return; + + /** @type {unknown} */ + let data; + try { + data = await response.json(); + } catch { + return; + } + + if (!Array.isArray(data)) return; + + const basePath = dir.endsWith("/") ? dir : dir + "/"; + + for (const entry of data) { + if (!entry || typeof entry.name !== "string" || !entry.type) continue; + + // Encode each path segment so URIs stay valid for non-ASCII filenames. + const encodedName = encodeURIComponent(entry.name); + const entryPath = basePath + encodedName; + + if (entry.type === "directory") { + if (!exclude.has(entry.name)) await listDir(server, entryPath, paths, exclude); + } else if (entry.type === "file") { + paths.push(entryPath); + } + } +} diff --git a/src/components/input/https-json/constants.js b/src/components/input/https-json/constants.js new file mode 100644 index 00000000..229c46c6 --- /dev/null +++ b/src/components/input/https-json/constants.js @@ -0,0 +1 @@ +export const SCHEME = "https-json"; diff --git a/src/components/input/https-json/element.js b/src/components/input/https-json/element.js new file mode 100644 index 00000000..8ae4edac --- /dev/null +++ b/src/components/input/https-json/element.js @@ -0,0 +1,59 @@ +import { defineElement, DiffuseElement } from "~/common/element.js"; +import { SCHEME } from "./constants.js"; +import { buildURI, serversFromTracks } from "./common.js"; + +/** + * @import {InputActions, InputSchemeProvider} from "~/components/input/types.d.ts" + * @import {ProxiedActions} from "~/common/worker.d.ts" + * @import {Track} from "~/definitions/types.d.ts" + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ProxiedActions} + * @implements {InputSchemeProvider} + */ +class HttpsJsonInput extends DiffuseElement { + static NAME = "diffuse/input/https-json"; + static WORKER_URL = "components/input/https-json/worker.js"; + + SCHEME = SCHEME; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + this.proxy = this.workerProxy(); + + this.artwork = this.proxy.artwork; + this.consult = this.proxy.consult; + this.detach = this.proxy.detach; + this.groupConsult = this.proxy.groupConsult; + this.list = this.proxy.list; + this.resolve = this.proxy.resolve; + } + + // 🛠️ + + /** @param {Track[]} tracks */ + sources(tracks) { + return Object.values(serversFromTracks(tracks)).map((server) => ({ + label: `${server.host}${server.dir}`, + uri: buildURI(server), + })); + } +} + +export default HttpsJsonInput; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = HttpsJsonInput; +export const NAME = "di-https-json"; + +defineElement(NAME, CLASS); diff --git a/src/components/input/https-json/worker.js b/src/components/input/https-json/worker.js new file mode 100644 index 00000000..0c7cf311 --- /dev/null +++ b/src/components/input/https-json/worker.js @@ -0,0 +1,189 @@ +import * as TID from "@atcute/tid"; +import { ostiary, rpc } from "~/common/worker.js"; +import { + detach as detachUtil, + groupKey, + isAudioFile, +} from "~/components/input/common.js"; +import { safeDecodeURIComponent } from "~/common/utils.js"; + +import { + buildURI, + checkAccessCached, + groupTracksByServer, + groupUrisByServer, + listFiles, + parseURI, + serverId, + toHttpUrl, +} from "./common.js"; +import { SCHEME } from "./constants.js"; + +/** + * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; + * @import { Track } from "~/definitions/types.d.ts"; + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + +/** + * @type {Actions['consult']} + */ +export async function consult(fileUriOrScheme) { + if (!fileUriOrScheme.includes(":")) { + return { supported: true, consult: "undetermined" }; + } + + const parsed = parseURI(fileUriOrScheme); + if (!parsed) return { supported: true, consult: "undetermined" }; + + const accessible = await checkAccessCached(parsed.server); + return { supported: true, consult: accessible }; +} + +/** + * @type {Actions['detach']} + */ +export async function detach(args) { + return detachUtil({ + ...args, + + inputScheme: SCHEME, + handleFileUri: ({ fileURI, tracks }) => { + const result = parseURI(fileURI); + if (!result) return tracks; + + const id = serverId(result.server); + const groups = groupTracksByServer(tracks); + + delete groups[id]; + + return Object.values(groups).map((g) => g.tracks).flat(1); + }, + }); +} + +/** + * @type {Actions['groupConsult']} + */ +export async function groupConsult(uris) { + const groups = groupUrisByServer(uris); + + const promises = Object.entries(groups).map( + async ([id, { server, uris }]) => { + const available = await checkAccessCached(server); + + /** @type {ConsultGrouping} */ + const grouping = available + ? { available, scheme: SCHEME, uris } + : { available, reason: "Server unreachable", scheme: SCHEME, uris }; + + return { key: groupKey(SCHEME, id), grouping }; + }, + ); + + const entries = (await Promise.all(promises)).map((e) => [e.key, e.grouping]); + return Object.fromEntries(entries); +} + +/** + * @type {Actions['list']} + */ +export async function list(cachedTracks = []) { + /** @type {Record>} */ + const cache = {}; + + const groups = groupTracksByServer(cachedTracks); + + Object.entries(groups).forEach(([id, { tracks }]) => { + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + if (!cache[id]) cache[id] = {}; + cache[id][safeDecodeURIComponent(parsed.path)] = track; + }); + }); + + const promises = Object.entries(groups).map(async ([id, { server }]) => { + const files = await listFiles(server); + + let tracks = files + .filter((path) => isAudioFile(path)) + .map((path) => { + const cachedTrack = cache[id]?.[safeDecodeURIComponent(path)]; + + const trackId = cachedTrack?.id || TID.now(); + const stats = cachedTrack?.stats; + const tags = cachedTrack?.tags; + const now = new Date().toISOString(); + + /** @type {Track} */ + const track = { + $type: "sh.diffuse.output.track", + id: trackId, + createdAt: cachedTrack?.createdAt ?? now, + updatedAt: cachedTrack?.updatedAt ?? now, + stats, + tags, + uri: buildURI(server, path), + }; + + return track; + }); + + if (!tracks.length) { + const now = new Date().toISOString(); + + tracks = [{ + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(server), + }]; + } + + return tracks; + }); + + return (await Promise.all(promises)).flat(1); +} + +/** + * @type {Actions['resolve']} + */ +export async function resolve({ uri }) { + const parsed = parseURI(uri); + if (!parsed || !parsed.path) return undefined; + + const url = toHttpUrl(parsed.server, parsed.path); + const expiresAt = Math.round(Date.now() / 1000) + 60 * 60 * 24 * 365; + + return { url, expiresAt }; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + artwork, + consult, + detach, + groupConsult, + list, + resolve, + }); +}); diff --git a/src/facets/connect/https-json/index.html b/src/facets/connect/https-json/index.html new file mode 100644 index 00000000..c8f73c0a --- /dev/null +++ b/src/facets/connect/https-json/index.html @@ -0,0 +1,12 @@ + + +
+ + diff --git a/src/facets/connect/https-json/index.inline.js b/src/facets/connect/https-json/index.inline.js new file mode 100644 index 00000000..9dbbacaa --- /dev/null +++ b/src/facets/connect/https-json/index.inline.js @@ -0,0 +1,155 @@ +import * as TID from "@atcute/tid"; +import { html } from "lit-html"; + +import * as Output from "~/common/output.js"; +import { SCHEME } from "~/components/input/https-json/constants.js"; +import { buildURI, parseURI, serverId } from "~/components/input/https-json/common.js"; +import { effect } from "~/common/signal.js"; +import foundation from "~/common/foundation.js"; + +import { setup } from "~/facets/connect/common.js"; + +/** + * @import { Server } from "~/components/input/https-json/common.js" + */ + +foundation.setup({ title: "Connect HTTPS / JSON Listing | Diffuse" }); + +//////////////////////////////////////////// +// SETUP +//////////////////////////////////////////// + +const [inputConfigurator, outputOrchestrator, sourcesOrchestrator] = + await Promise.all([ + foundation.configurator.input(), + foundation.orchestrator.output(), + foundation.orchestrator.sources(), + ]); + +await Promise.all([ + customElements.whenDefined(inputConfigurator.localName), + customElements.whenDefined(outputOrchestrator.localName), + customElements.whenDefined(sourcesOrchestrator.localName), +]); + +//////////////////////////////////////////// +// UI +//////////////////////////////////////////// + +const { setItems, setError } = setup({ + title: "HTTPS + JSON", + hasOutput: false, + + description: html` +

+ Connect to an HTTPS server that serves JSON directory listings + (eg. static-web-server with --directory-listing-format=json). +

+ +

Uses the format: [{ "name": "Example folder", "type": "directory" }, { "name": "file.mp3", "type": "file" }]

+ `, + + formFields: html` + + + +

* Required.
Excluded directories are matched by name at any depth, comma-separated.

+ `, + + onSubmit: () => addServer(), +}); + +const hostInput = + /** @type {HTMLInputElement} */ (document.querySelector("#https-json-host")); +const dirInput = + /** @type {HTMLInputElement} */ (document.querySelector("#https-json-dir")); +const excludeInput = + /** @type {HTMLInputElement} */ (document.querySelector("#https-json-exclude")); + +//////////////////////////////////////////// +// REACTIVE LIST +//////////////////////////////////////////// + +effect(() => { + const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? []; + + /** @type {Map} */ + const allServers = new Map(); + + for (const source of inputSources) { + const parsed = parseURI(source.uri); + if (!parsed) continue; + + const id = serverId(parsed.server); + if (!allServers.has(id)) { + allServers.set(id, { server: parsed.server, uri: source.uri }); + } + } + + setItems( + [...allServers.values()].map(({ server, uri }) => ({ + name: server.host, + detail: server.dir, + isInput: true, + isOutput: false, + isSelectedOutput: false, + isDisabled: sourcesOrchestrator.isDisabled(uri), + onRemove: () => removeServer(uri), + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), + })), + ); +}); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** @param {string} uri */ +async function removeServer(uri) { + setError(null); + try { + const tracks = await Output.data(outputOrchestrator.tracks); + const detachedTracks = await inputConfigurator.detach({ + fileUriOrScheme: uri, + tracks, + }); + + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to remove server"); + } +} + +async function addServer() { + const host = hostInput.value?.trim(); + const dir = dirInput.value?.trim() || "/"; + const exclude = (excludeInput.value ?? "").split(",").map((s) => s.trim()).filter(Boolean); + + if (!host) return; + + /** @type {Server} */ + const server = { host, dir, exclude: exclude.length ? exclude : undefined }; + const uri = buildURI(server); + + const now = new Date().toISOString(); + const tracksCol = outputOrchestrator.tracks.collection(); + const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : []; + + await outputOrchestrator.tracks.save([ + ...existingTracks, + { + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri, + }, + ]); +} + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); diff --git a/src/facets/connect/index.inline.js b/src/facets/connect/index.inline.js index 2b30f2c0..95beaf8a 100644 --- a/src/facets/connect/index.inline.js +++ b/src/facets/connect/index.inline.js @@ -13,6 +13,7 @@ const icons = { "facets/connect/atproto/index.html": "at", "facets/connect/dropbox/index.html": "cloud", "facets/connect/https/index.html": "globe", + "facets/connect/https-json/index.html": "list-bullets", "facets/connect/icecast/index.html": "radio", "facets/connect/local/index.html": "folder-open", "facets/connect/opensubsonic/index.html": "broadcast", @@ -24,6 +25,7 @@ const recommended = new Set([ "facets/connect/atproto/index.html", "facets/connect/dropbox/index.html", "facets/connect/local/index.html", + "facets/connect/s3/index.html", ]); const facets = facetsData @@ -39,6 +41,7 @@ const facets = facetsData href: loaderHref(f.url), isRecommended: recommended.has(f.url), })) + .sort((a, b) => (b.isRecommended ? 1 : 0) - (a.isRecommended ? 1 : 0)) ; const main = document.querySelector("main"); diff --git a/src/facets/data/input-bundle/index.inline.js b/src/facets/data/input-bundle/index.inline.js index c9dab608..30c7a1f1 100644 --- a/src/facets/data/input-bundle/index.inline.js +++ b/src/facets/data/input-bundle/index.inline.js @@ -4,6 +4,7 @@ import { effect } from "~/common/signal.js"; import { NAME as DROPBOX_NAME } from "~/components/input/dropbox/element.js"; import { NAME as EPHEMERAL_CACHE_NAME } from "~/components/input/ephemeral-cache/element.js"; import { NAME as HTTPS_NAME } from "~/components/input/https/element.js"; +import { NAME as HTTPS_JSON_NAME } from "~/components/input/https-json/element.js"; import { NAME as ICECAST_NAME } from "~/components/input/icecast/element.js"; import { NAME as LOCAL_NAME } from "~/components/input/local/element.js"; import { NAME as OPENSUBSONIC_NAME } from "~/components/input/opensubsonic/element.js"; @@ -25,6 +26,7 @@ effect(() => { dropbox(input); ephemeralCache(input); https(input); + httpsJson(input); icecast(input); local(input); opensubsonic(input); @@ -65,6 +67,17 @@ export function https(input) { input.append(document.createElement(HTTPS_NAME)); } +//////////////////////////////////////////// +// HTTPS JSON LISTING +//////////////////////////////////////////// + +/** + * @param {InputConfigurator} input + */ +export function httpsJson(input) { + input.append(document.createElement(HTTPS_JSON_NAME)); +} + //////////////////////////////////////////// // ICECAST ////////////////////////////////////////////