diff --git a/deno.jsonc b/deno.jsonc index 0bcd6f69..219610d8 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -65,6 +65,7 @@ "@dotenv-run/esbuild": "npm:@dotenv-run/esbuild@^1.5.1", "@std/fs": "jsr:@std/fs@^1.0.19", "@std/path": "jsr:@std/path@^1.1.2", + "@types/wicg-file-system-access": "npm:@types/wicg-file-system-access@^2023.10.7", "esbuild-plugins-node-modules-polyfill": "npm:esbuild-plugins-node-modules-polyfill@^1.8.1", "esbuild-plugin-wasm": "npm:esbuild-plugin-wasm@^1.1.0", "lume/": "https://cdn.jsdelivr.net/gh/lumeland/lume@3.1.4/", @@ -192,7 +193,7 @@ "compilerOptions": { "checkJs": true, "lib": ["deno.ns", "dom", "esnext"], - "types": ["lume/types.ts"], + "types": ["lume/types.ts", "@types/wicg-file-system-access"], "jsx": "react-jsx", "jsxImportSource": "lume", }, diff --git a/src/components/input/local/common.js b/src/components/input/local/common.js new file mode 100644 index 00000000..33a76e67 --- /dev/null +++ b/src/components/input/local/common.js @@ -0,0 +1,168 @@ +import * as IDB from "idb-keyval"; +import * as URI from "fast-uri"; + +import { isAudioFile } from "@components/input/common.js"; +import { safeDecodeURIComponent } from "@common/utils.js"; +import { IDB_HANDLES, SCHEME } from "./constants.js"; + +/** + * @import { Track } from "@definitions/types.d.ts" + */ + +//////////////////////////////////////////// +// 🛠️ +//////////////////////////////////////////// + +/** + * @param {string} uriString + * @returns {{ tid: string; path: string } | undefined} + */ +export function parseURI(uriString) { + try { + const url = new URL(uriString); + if (url.protocol !== `${SCHEME}:`) return undefined; + if (!url.host) return undefined; + + return { + tid: url.host, + path: safeDecodeURIComponent(url.pathname), + }; + } catch { + return undefined; + } +} + +/** + * @param {string} tid + * @param {string} [path] + */ +export function buildURI(tid, path = "/") { + return URI.serialize({ + scheme: SCHEME, + host: tid, + path, + }); +} + +/** + * @param {Track[]} tracks + * @returns {Record} + */ +export function groupTracksByTid(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const { tid } = parsed; + if (acc[tid]) { + acc[tid].tracks.push(track); + } else { + acc[tid] = { tid, tracks: [track] }; + } + }); + + return acc; +} + +/** + * @param {string[]} uris + * @returns {Record} + */ +export function groupUrisByTid(uris) { + /** @type {Record} */ + const acc = {}; + + uris.forEach((uri) => { + const parsed = parseURI(uri); + if (!parsed) return; + + const { tid } = parsed; + if (acc[tid]) { + acc[tid].uris.push(uri); + } else { + acc[tid] = { tid, uris: [uri] }; + } + }); + + return acc; +} + +/** + * @param {Track[]} tracks + * @returns {Record} + */ +export function tidsFromTracks(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + acc[parsed.tid] = parsed.tid; + }); + + return acc; +} + +/** + * @returns {Promise>} + */ +export async function loadHandles() { + const i = await IDB.get(IDB_HANDLES); + return i ?? {}; +} + +/** + * @param {Record} handles + */ +export async function saveHandles(handles) { + await IDB.set(IDB_HANDLES, handles); +} + +/** + * @param {FileSystemHandle} handle + * @param {string} path + * @returns {Promise} + */ +export async function getHandleFile(handle, path) { + if (handle.kind === "file") { + return /** @type {FileSystemFileHandle} */ (handle); + } + + const parts = path.replace(/^\//, "").split("/").filter(Boolean); + let current = /** @type {FileSystemDirectoryHandle} */ (handle); + + for (const part of parts.slice(0, -1)) { + current = await current.getDirectoryHandle(part); + } + + return current.getFileHandle(/** @type {string} */ (parts.at(-1))); +} + +/** + * @param {FileSystemDirectoryHandle} dirHandle + * @param {string} [basePath] + * @returns {Promise} + */ +export async function enumerateAudioFiles(dirHandle, basePath = "/") { + const results = []; + + for await (const [name, handle] of /** @type {any} */ (dirHandle).entries()) { + const entryPath = basePath + name; + + if (handle.kind === "directory") { + const sub = await enumerateAudioFiles( + /** @type {FileSystemDirectoryHandle} */ (handle), + entryPath + "/", + ); + results.push(...sub); + } else if (isAudioFile(name)) { + results.push(entryPath); + } + } + + return results; +} diff --git a/src/components/input/local/constants.js b/src/components/input/local/constants.js new file mode 100644 index 00000000..e14ba9d5 --- /dev/null +++ b/src/components/input/local/constants.js @@ -0,0 +1,2 @@ +export const SCHEME = "local"; +export const IDB_HANDLES = "local-input/handles"; diff --git a/src/components/input/local/element.js b/src/components/input/local/element.js new file mode 100644 index 00000000..2553c6bf --- /dev/null +++ b/src/components/input/local/element.js @@ -0,0 +1,120 @@ +import * as TID from "@atcute/tid"; +import { DiffuseElement } from "@common/element.js"; +import { SCHEME } from "./constants.js"; +import { + buildURI, + loadHandles, + saveHandles, + tidsFromTracks, +} 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 LocalInput extends DiffuseElement { + static NAME = "diffuse/input/local"; + static WORKER_URL = "components/input/local/worker.js"; + + SCHEME = SCHEME; + + /** @type {Map} tid → handle name */ + #names = new Map(); + + constructor() { + super(); + + /** @type {ProxiedActions} */ + this.proxy = this.workerProxy(); + + 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; + } + + // LIFECYCLE + + /** @override */ + async connectedCallback() { + super.connectedCallback(); + const handles = await loadHandles(); + for (const [tid, handle] of Object.entries(handles)) { + this.#names.set(tid, handle.name); + } + } + + // 🛠️ + + /** + * Prompts the user to pick a directory. + * Stores handle in IDB. + * Returns the URI for the track placeholder. + */ + async addDirectory() { + const dirHandle = await /** @type {any} */ (globalThis).showDirectoryPicker( + { mode: "read" }, + ); + + const tid = TID.now(); + const handles = await loadHandles(); + + handles[tid] = dirHandle; + await saveHandles(handles); + this.#names.set(tid, dirHandle.name); + + return buildURI(tid); + } + + /** + * Prompts the user to pick one or more files. + * Stores handles in IDB. + * Returns the URIs for the track placeholders. + */ + async addFiles() { + const fileHandles = await /** @type {any} */ (globalThis) + .showOpenFilePicker({ multiple: true }); + const handles = await loadHandles(); + const uris = []; + + for (const fileHandle of fileHandles) { + const tid = TID.now(); + handles[tid] = fileHandle; + this.#names.set(tid, fileHandle.name); + uris.push(buildURI(tid)); + } + + await saveHandles(handles); + return uris; + } + + /** @param {Track[]} tracks */ + sources(tracks) { + return Object.values(tidsFromTracks(tracks)).map((tid) => ({ + label: this.#names.get(tid) ?? tid, + uri: buildURI(tid), + })); + } +} + +export default LocalInput; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = LocalInput; +export const NAME = "di-local"; + +customElements.define(NAME, CLASS); diff --git a/src/components/input/local/worker.js b/src/components/input/local/worker.js new file mode 100644 index 00000000..3812bb2c --- /dev/null +++ b/src/components/input/local/worker.js @@ -0,0 +1,248 @@ +import * as TID from "@atcute/tid"; +import { ostiary, rpc } from "@common/worker.js"; +import { groupKey } from "@components/input/common.js"; +import { + buildURI, + enumerateAudioFiles, + getHandleFile, + groupTracksByTid, + groupUrisByTid, + loadHandles, + parseURI, + saveHandles, +} 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['consult']} + */ +export async function consult(fileUriOrScheme) { + if (typeof FileSystemHandle === "undefined") { + return { supported: false, reason: "No browser support" }; + } + + if (!fileUriOrScheme.includes(":")) { + return { supported: true, consult: "undetermined" }; + } + + const parsed = parseURI(fileUriOrScheme); + if (!parsed) return { supported: false, reason: "Unknown handle" }; + + const handles = await loadHandles(); + const handle = handles[parsed.tid]; + + if (!handle) return { supported: false, reason: "Unknown handle" }; + + const permission = await /** @type {any} */ (handle).queryPermission({ + mode: "read", + }); + + return { supported: true, consult: permission === "granted" }; +} + +/** + * @type {Actions['detach']} + */ +export async function detach({ fileUriOrScheme, tracks }) { + if (!fileUriOrScheme.includes("://")) { + if (fileUriOrScheme === SCHEME) return []; + return tracks; + } + + const parsed = parseURI(fileUriOrScheme); + if (!parsed) return tracks; + + const { tid } = parsed; + const groups = groupTracksByTid(tracks); + delete groups[tid]; + + const handles = await loadHandles(); + delete handles[tid]; + await saveHandles(handles); + + return Object.values(groups).map((g) => g.tracks).flat(1); +} + +/** + * @type {Actions['groupConsult']} + */ +export async function groupConsult(uris) { + const groups = groupUrisByTid(uris); + const handles = await loadHandles(); + + const promises = Object.entries(groups).flatMap(async ([tid, { uris }]) => { + const handle = handles[tid]; + if (!handle) return []; + + const available = + (await /** @type {any} */ (handle).queryPermission({ mode: "read" })) === + "granted"; + + /** @type {ConsultGrouping} */ + const grouping = available ? { available, scheme: SCHEME, uris } : { + available: false, + reason: "Permission not granted", + scheme: SCHEME, + uris, + }; + + return [{ key: groupKey(SCHEME, tid), grouping }]; + }); + + const results = (await Promise.all(promises)).flat(1); + return Object.fromEntries(results.map((e) => [e.key, e.grouping])); +} + +/** + * @type {Actions['list']} + */ +export async function list(cachedTracks = []) { + const handles = await loadHandles(); + const now = new Date().toISOString(); + + console.log(cachedTracks) + + /** @type {Record} */ + const cacheByUri = {}; + + cachedTracks.forEach((t) => { + cacheByUri[t.uri] = t; + }); + + const trackGroups = groupTracksByTid(cachedTracks); + + const allTids = new Set([ + ...Object.keys(handles), + ...Object.keys(trackGroups), + ]); + + console.log(allTids); + + const promises = [...allTids].map(async (tid) => { + const handle = handles[tid]; + if (!handle) return trackGroups[tid]?.tracks ?? /** @type {Track[]} */ ([]); + + const perm = await /** @type {any} */ (handle).queryPermission({ + mode: "read", + }); + + console.log(tid, perm, handle.kind); + + if (perm !== "granted") { + const cached = trackGroups[tid]?.tracks[0]; + + /** @type {Track} */ + const placeholder = { + $type: "sh.diffuse.output.track", + id: cached?.id ?? TID.now(), + createdAt: cached?.createdAt ?? now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(tid), + }; + + return [placeholder]; + } + + if (handle.kind === "file") { + const uri = buildURI(tid); + const cached = cacheByUri[uri]; + + /** @type {Track} */ + const track = { + $type: "sh.diffuse.output.track", + id: cached?.id ?? TID.now(), + createdAt: cached?.createdAt ?? now, + updatedAt: cached?.updatedAt ?? now, + stats: cached?.stats, + tags: cached?.tags, + uri, + }; + + return [track]; + } + + const paths = await enumerateAudioFiles( + /** @type {FileSystemDirectoryHandle} */ (handle), + ); + + if (!paths.length) { + /** @type {Track} */ + const placeholder = { + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(tid), + }; + + return [placeholder]; + } + + return paths.map((path) => { + const uri = buildURI(tid, path); + const cached = cacheByUri[uri]; + + /** @type {Track} */ + const track = { + $type: "sh.diffuse.output.track", + id: cached?.id ?? TID.now(), + createdAt: cached?.createdAt ?? now, + updatedAt: cached?.updatedAt ?? now, + stats: cached?.stats, + tags: cached?.tags, + uri, + }; + + return track; + }); + }); + + const tracks = (await Promise.all(promises)).flat(1); + return tracks; +} + +/** + * @type {Actions['resolve']} + */ +export async function resolve({ uri }) { + const parsed = parseURI(uri); + if (!parsed) return undefined; + + const handles = await loadHandles(); + const handle = handles[parsed.tid]; + const path = parsed.path.replace(/^\//, ""); + + if (!handle) return undefined; + if (handle.kind === "directory" && path === "") return undefined; + + const fileHandle = await getHandleFile(handle, path); + const file = await fileHandle.getFile(); + + const url = URL.createObjectURL(file); + return { url, expiresAt: Infinity }; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + consult, + detach, + groupConsult, + list, + resolve, + }); +}); diff --git a/src/components/orchestrator/input/element.js b/src/components/orchestrator/input/element.js index 20f0a19f..26dde854 100644 --- a/src/components/orchestrator/input/element.js +++ b/src/components/orchestrator/input/element.js @@ -2,6 +2,7 @@ import { DiffuseElement } from "@common/element.js"; import "@components/configurator/input/element.js"; import "@components/input/https/element.js"; +import "@components/input/local/element.js"; import "@components/input/opensubsonic/element.js"; import "@components/input/s3/element.js"; @@ -62,6 +63,7 @@ class InputOrchestrator extends DiffuseElement { return html` + diff --git a/src/index.vto b/src/index.vto index 2422a866..edcbf6cf 100644 --- a/src/index.vto +++ b/src/index.vto @@ -40,10 +40,6 @@ engines: Signals that could influence the scope of a set of tracks. input: - - url: "components/input/opensubsonic/element.js" - title: "Opensubsonic" - desc: > - Add any (open)subsonic server. - url: "components/input/https/element.js" title: "HTTPS" desc: > @@ -52,6 +48,14 @@ input: desc: > Generate tracks based on HTTPS servers that provide JSON (directory) listings. todo: true + - url: "components/input/local/element.js" + title: "Local" + desc: > + Audio files or directories from your local device, using the browser's File System Access API. + - url: "components/input/opensubsonic/element.js" + title: "Opensubsonic" + desc: > + Add any (open)subsonic server. - url: "components/input/s3/element.js" title: "S3" desc: > diff --git a/src/themes/webamp/configurators/input/element.js b/src/themes/webamp/configurators/input/element.js index 08eb846f..1434866b 100644 --- a/src/themes/webamp/configurators/input/element.js +++ b/src/themes/webamp/configurators/input/element.js @@ -12,6 +12,7 @@ import { buildURI as buildOpenSubsonicURI } from "@components/input/opensubsonic import { buildURI as buildS3URI } from "@components/input/s3/common.js"; import { SCHEME as HTTPS_SCHEME } from "@components/input/https/constants.js"; +import { SCHEME as LOCAL_SCHEME } from "@components/input/local/constants.js"; import { SCHEME as OPENSUBSONIC_SCHEME } from "@components/input/opensubsonic/constants.js"; import { SCHEME as S3_SCHEME } from "@components/input/s3/constants.js"; @@ -194,6 +195,26 @@ class InputConfig extends DiffuseElement { if (button) button.disabled = false; }; + #addLocalDirectory = async () => { + const localInput = /** @type {any} */ (this.$input.value)?.input + ?.inputs?.()[LOCAL_SCHEME]; + if (!localInput) return; + + const uri = await localInput.addDirectory(); + await this.addSource(uri); + }; + + #addLocalFiles = async () => { + const localInput = /** @type {any} */ (this.$input.value)?.input + ?.inputs?.()[LOCAL_SCHEME]; + if (!localInput) return; + + const uris = await localInput.addFiles(); + for (const uri of uris) { + await this.addSource(uri); + } + }; + /** * @param {Event} event */ @@ -299,6 +320,11 @@ class InputConfig extends DiffuseElement {