import * as TID from "@atcute/tid"; import * as IDB from "idb-keyval"; import { html, nothing, render as litRender } from "lit-html"; import * as Output from "~/common/output.js"; import { CACHE_KEY_PREFIX, SCHEME as SCHEME_EPHEMERAL_CACHE, } from "~/components/input/ephemeral-cache/constants.js"; import { isAudioFile } from "~/components/input/common.js"; import { SCHEME as SCHEME_DROPBOX } from "~/components/input/dropbox/constants.js"; import { buildURI, parseURI as parseS3URI, } from "~/components/input/s3/common.js"; import { SCHEME as SCHEME_S3 } from "~/components/input/s3/constants.js"; import { buildURI as buildWebdavURI, parseURI as parseWebdavURI, } from "~/components/input/webdav/common.js"; import { SCHEME as SCHEME_WEBDAV } from "~/components/input/webdav/constants.js"; import { effect, signal } from "~/common/signal.js"; import { safeDecodeURIComponent } from "~/common/utils.js"; import foundation from "~/common/foundation.js"; /** * @import { TemplateResult } from "lit-html" * @import {Track} from "~/definitions/types.d.ts" * @import {UploadElement} from "@specs/components/upload/types.d.ts" * @import {Bucket} from "@specs/components/input/s3/types.d.ts" * @import {Server} from "@specs/components/input/webdav/types.d.ts" */ /** Human-readable labels for upload component schemes. */ /** @type {Record} */ const SCHEME_LABELS = { [SCHEME_DROPBOX]: "Dropbox", [SCHEME_S3]: "S3", [SCHEME_WEBDAV]: "WebDAV", }; /** Directory in the cloud where uploaded files are stored. */ const UPLOAD_DIRECTORY = "/Diffuse"; /** IDB key for persisting the sync scheme across sessions. */ const SYNC_SCHEME_KEY = "file-manager:sync-scheme"; /** IDB key for storing a pending delete path (retried after reconnection). */ const PENDING_DELETE_KEY = "file-manager:pending-delete"; /** IDB key for a persisted map of CID → original filename, so the * "Artist - Title" label survives upload (remote tracks) and reloads. */ const CID_NAMES_KEY = "file-manager:cid-names"; foundation.setup({ title: "File Manager" }); //////////////////////////////////////////// // SETUP //////////////////////////////////////////// const [ inputConfigurator, outputOrchestrator, uploadConfigurator, sourcesOrchestrator, processOrchestrator, ] = await Promise.all([ foundation.configurator.input(), foundation.orchestrator.output(), foundation.configurator.upload(), foundation.orchestrator.sources(), foundation.orchestrator.processTracks({ disableWhenReady: true }), ]); await Promise.all([ customElements.whenDefined(inputConfigurator.localName), customElements.whenDefined(outputOrchestrator.localName), customElements.whenDefined(uploadConfigurator.localName), customElements.whenDefined(sourcesOrchestrator.localName), customElements.whenDefined(processOrchestrator.localName), ]); /** Maps ephemeral cache URIs to their original filename. */ const ephemeralNames = new Map(); /** Maps a content CID to its original filename. Persisted to IDB so the label * is available for remote tracks (after upload) and across reloads. */ const cidNames = new Map(); /** Bumped whenever `ephemeralNames` or `cidNames` is updated so the reactive * effect re-fires (they're plain Maps, not signals). */ const namesVersion = signal(0); /** Tracks the upload button state: "idle", "caching", or "uploading". */ const uploadState = signal( /** @type {"idle" | "caching" | "uploading"} */ ("idle"), ); /** The scheme currently being synced with (e.g. "dropbox"), or null. */ const syncScheme = signal(/** @type {string | null} */ (null)); //////////////////////////////////////////// // UI //////////////////////////////////////////// /** * @typedef {{ name: string; subtitle?: string; onRemove: () => void }} FileItem */ /** * Renders the File Manager layout (left: logo, title, description; right: the * supplied content, an error callout, and reactive lists of items) and returns * helpers to update it. * * @param {Object} config * @param {string} config.title * @param {TemplateResult | string} config.description * @param {TemplateResult} [config.leftContent] * @param {TemplateResult} config.rightContent * @returns {{ setLocalItems: (items: FileItem[]) => void, setRemoteItems: (items: FileItem[]) => void, setError: (message: string | TemplateResult | null) => void }} */ function setup({ title, description, leftContent, rightContent }) { const main = document.querySelector("main"); if (!main) throw new Error("No
element"); litRender( html`
Diffuse

${title}

${description} ${leftContent}
${rightContent}
`, main, ); const cardErrorEl = /** @type {HTMLElement} */ (main.querySelector("#file-card-error")); const localSection = /** @type {HTMLElement} */ (main.querySelector("#local-section")); const remoteSection = /** @type {HTMLElement} */ (main.querySelector("#remote-section")); const localList = /** @type {HTMLElement} */ (main.querySelector("#local-list")); const remoteList = /** @type {HTMLElement} */ (main.querySelector("#remote-list")); /** * @param {string | TemplateResult | null} message */ const setError = (message) => { cardErrorEl.hidden = message === null; litRender(message ?? nothing, cardErrorEl); }; /** @param {FileItem[]} items */ const setLocalItems = (items) => { localSection.hidden = items.length === 0; renderFileList(localList, items, "local"); }; /** @param {FileItem[]} items */ const setRemoteItems = (items) => { remoteSection.hidden = items.length === 0; renderFileList(remoteList, items, "remote"); }; return { setLocalItems, setRemoteItems, setError }; } /** * Renders a list of file items into the given list element. * * @param {HTMLElement} listEl * @param {FileItem[]} items * @param {string} prefix - Unique prefix for popover IDs to avoid collisions. */ function renderFileList(listEl, items, prefix) { litRender( html` ${items.map( ({ name, subtitle, onRemove }, index) => html`
  • ${name} ${subtitle ? html`${subtitle}` : null}
  • `, )} `, listEl, ); } const { setLocalItems, setRemoteItems, setError } = setup({ title: "File Manager", description: html`

    Upload audio files to play them and optionally sync them with your cloud storage. These are automatically added as inputs (aka. sources) and then processed into playable tracks.

    Got music stored elsewhere, or don't want Diffuse to manage your music? There's a bunch more options to connect to with just read-only access.

    `, leftContent: html`

    `, rightContent: html` `, }); const dropzone = document.querySelector("#local-dropzone"); const dropzoneInput = /** @type {HTMLInputElement | null} */ (document.querySelector( "#local-dropzone-input", )); const uploadBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#upload-btn") ); const uploadIcon = /** @type {HTMLElement | null} */ (document.querySelector( "#upload-icon", )); const uploadLabel = /** @type {HTMLElement | null} */ (document.querySelector( "#upload-label", )); const uploadMenu = /** @type {HTMLElement | null} */ (document.querySelector( "#upload-menu", )); const stopSyncBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#stop-sync-btn") ); const stopSyncLabel = /** @type {HTMLElement | null} */ ( document.querySelector("#stop-sync-label") ); const uploadIndicator = /** @type {HTMLElement | null} */ ( document.querySelector("#upload-indicator") ); const processBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#process-btn") ); const processIcon = /** @type {HTMLElement | null} */ ( document.querySelector("#process-icon") ); const processLabel = /** @type {HTMLElement | null} */ ( document.querySelector("#process-label") ); dropzoneInput?.addEventListener("change", async () => { const files = Array.from(dropzoneInput.files ?? []).filter((f) => f.type.startsWith("audio/") ); dropzoneInput.value = ""; if (files.length === 0) return; await cacheFiles(files); }); dropzone?.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("dropzone--active"); }); dropzone?.addEventListener("dragleave", () => { dropzone.classList.remove("dropzone--active"); }); dropzone?.addEventListener("drop", async (e) => { e.preventDefault(); dropzone.classList.remove("dropzone--active"); const dragEvent = /** @type {DragEvent} */ (e); // `dataTransfer.files` is reliably populated in every browser, but Firefox // (and older Safari) can return `null` from `getAsFile()` for files dragged // in from the OS. Read the direct files from `files` and only use `items` to // additionally expand dropped directories via the (non-standard) entries API. const directFiles = Array.from(dragEvent.dataTransfer?.files ?? []); const items = Array.from(dragEvent.dataTransfer?.items ?? []); const files = await collectFiles(items, directFiles); if (files.length === 0) return; await cacheFiles(files); }); stopSyncBtn?.addEventListener("click", () => stopSyncing()); //////////////////////////////////////////// // CONNECT SOURCE DIALOG //////////////////////////////////////////// /** * @typedef {{ * id: string; * label: string; * required?: boolean; * placeholder?: string; * type?: "text" | "password"; * }} ConnectField */ /** * Describes how to connect a source for a scheme without an OAuth flow, * so the file manager can host the connect form itself. * * @typedef {{ * scheme: string; * fields: ConnectField[]; * build: (values: Record) => string; * caption: string; * sourceParts: (uri: string) => { name: string; detail: string }; * }} ConnectConfig */ /** @type {Record} */ const CONNECT_CONFIGS = { [SCHEME_S3]: { scheme: SCHEME_S3, fields: [ { id: "access-key", label: "Access key", required: true }, { id: "secret-key", label: "Secret key", required: true, type: "password", }, { id: "bucket-name", label: "Bucket name", required: true, placeholder: "my-bucket", }, { id: "host", label: "Host", placeholder: "s3.amazonaws.com" }, { id: "region", label: "Region", placeholder: "us-east-1" }, { id: "path", label: "Path", placeholder: "/" }, ], build: (values) => { /** @type {Bucket} */ const bucket = { accessKey: values["access-key"], bucketName: values["bucket-name"], host: values.host ? values.host.replace(/^\w+:\/\//, "") : "s3.amazonaws.com", path: values.path || "/", region: values.region || "us-east-1", secretKey: values["secret-key"], }; return buildURI(bucket); }, caption: "Uploaded files are placed inside the bucket's path.", sourceParts: (uri) => { const parsed = parseS3URI(uri); if (!parsed) return { name: uri, detail: "" }; return { name: parsed.bucket.bucketName || parsed.bucket.host, detail: parsed.bucket.host, }; }, }, [SCHEME_WEBDAV]: { scheme: SCHEME_WEBDAV, fields: [ { id: "host", label: "Host", required: true, placeholder: "music.example.com", }, { id: "dir", label: "Directory", placeholder: "/" }, { id: "username", label: "Username" }, { id: "password", label: "Password", type: "password" }, ], build: (values) => { /** @type {Server} */ const server = { dir: values.dir || "/", host: values.host, password: values.password || "", username: values.username || "", }; return buildWebdavURI(server); }, caption: "Uploaded files are placed inside the server's directory.", sourceParts: (uri) => { const parsed = parseWebdavURI(uri); if (!parsed) return { name: uri, detail: "" }; return { name: parsed.server.host, detail: parsed.server.dir, }; }, }, }; const connectSourceDialog = /** @type {HTMLDialogElement | null} */ ( document.querySelector("#connect-source-dialog") ); const connectSourceForm = /** @type {HTMLFormElement | null} */ ( document.querySelector("#connect-source-form") ); const connectSourceErrorEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-error", )); const connectSourceFieldsEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-fields", )); const connectSourceExistingEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-existing", )); const connectSourceCaptionEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-caption", )); const connectSourceTitleEl = /** @type {HTMLElement | null} */ (document.querySelector( "#connect-source-title", )); const connectSourceSubmitBtn = /** @type {HTMLButtonElement | null} */ ( document.querySelector("#connect-source-submit-btn") ); /** The config of the scheme the dialog is currently open for. */ let currentConnectConfig = /** @type {ConnectConfig | null} */ (null); /** * Shows the dialog-level error inside the connect source dialog. * * @param {string | null} message */ const setConnectSourceError = (message) => { if (!connectSourceErrorEl) return; connectSourceErrorEl.hidden = message === null; connectSourceErrorEl.textContent = message; }; /** * Enables/disables the dialog's interactive elements while an action * (consulting a source, connecting a new one) is in flight. * * @param {boolean} busy */ const setConnectSourceBusy = (busy) => { connectSourceSubmitBtn?.toggleAttribute("disabled", busy); connectSourceExistingEl?.querySelectorAll("button").forEach((btn) => { btn.toggleAttribute("disabled", busy); }); }; /** * Opens the connect source dialog for the given scheme: existing sources for * the scheme are offered as destinations to sync with (the user chooses — * none is silently preferred), plus a form to connect a new one. * * @param {ConnectConfig} config */ function openConnectSourceDialog(config) { if ( !connectSourceDialog || !connectSourceForm || !connectSourceFieldsEl || !connectSourceExistingEl ) return; currentConnectConfig = config; const label = SCHEME_LABELS[config.scheme] ?? config.scheme; if (connectSourceTitleEl) { connectSourceTitleEl.textContent = `Set up syncing (${label})`; } if (connectSourceCaptionEl) { connectSourceCaptionEl.textContent = config.caption; } // Destinations already connected for this scheme. const sources = sourcesOrchestrator.sources()[config.scheme] ?? []; const existing = sources.map((source) => ({ ...source, ...config.sourceParts(source.uri), disabled: sourcesOrchestrator.isDisabled(source.uri), })); connectSourceExistingEl.hidden = existing.length === 0; litRender( existing.length === 0 ? nothing : html`

    Sync with a connected source:

      ${existing.map(({ uri, name, detail, disabled }) => html`
    • ` )}

    Or connect a new one:

    `, connectSourceExistingEl, ); litRender( config.fields.map((field) => html` ` ), connectSourceFieldsEl, ); connectSourceForm.reset(); setConnectSourceError(null); connectSourceDialog.showModal(); } /** * Selects an existing source as the syncing destination. Consults it first — * an unreachable source can't be synced with, so the dialog stays open with * an error instead. * * @param {ConnectConfig} config * @param {string} uri * @param {boolean} disabled - Whether the source is currently disabled * (it is re-enabled when syncing starts). */ async function selectExistingSource(config, uri, disabled) { setConnectSourceError(null); setConnectSourceBusy(true); try { const result = await uploadConfigurator.consult(uri); if (result.supported && result.consult === "no") { setConnectSourceError( "This source could not be reached right now. Check its credentials (and the server's CORS settings), then try again.", ); return; } if (disabled) await sourcesOrchestrator.toggle(uri); connectSourceDialog?.close(); await beginSync(config.scheme, uri); } catch (err) { setConnectSourceError( err instanceof Error ? err.message : "Failed to start syncing", ); } finally { setConnectSourceBusy(false); } } connectSourceForm?.addEventListener("submit", async (e) => { e.preventDefault(); const config = currentConnectConfig; if (!connectSourceDialog || !config) return; // Collect the field values. const values = /** @type {Record} */ ({}); for (const field of config.fields) { values[field.id] = /** @type {HTMLInputElement | null} */ ( document.querySelector(`#connect-source-field-${field.id}`) )?.value?.trim() ?? ""; } // Required fields are enforced by the browser as well — this covers // programmatic submissions. if (config.fields.some((f) => f.required && !values[f.id])) return; setConnectSourceError(null); setConnectSourceBusy(true); if (connectSourceSubmitBtn) { connectSourceSubmitBtn.textContent = "Connecting …"; } try { const uri = config.build(values); // Verify the new source is reachable before saving it — a typo'd secret // key or missing CORS config shouldn't create a junk source. const result = await uploadConfigurator.consult(uri); if (result.supported && result.consult === "no") { setConnectSourceError( "The source could not be reached. Check the values (and the server's CORS settings), then try again.", ); return; } // Add the source, the same way the Connect page does. const now = new Date().toISOString(); /** @type {Track} */ const placeholderTrack = { $type: "sh.diffuse.output.track", id: TID.now(), createdAt: now, updatedAt: now, kind: "placeholder", uri, }; const tracks = await Output.data(outputOrchestrator.tracks); await outputOrchestrator.tracks.save([...tracks, placeholderTrack]); connectSourceDialog.close(); await beginSync(config.scheme, uri); } catch (err) { setConnectSourceError( err instanceof Error ? err.message : "Failed to connect the source", ); } finally { setConnectSourceBusy(false); if (connectSourceSubmitBtn) { connectSourceSubmitBtn.textContent = "Connect"; } } }); document.querySelector("#connect-source-cancel-btn")?.addEventListener( "click", () => { setConnectSourceError(null); connectSourceDialog?.close(); }, ); //////////////////////////////////////////// // OAUTH CALLBACK //////////////////////////////////////////// // Detect the OAuth callback: if `?uploading=` is in the URL and // `#refresh_token=...` is in the hash, we just returned from the OAuth // provider. Clean the URL (remove only the `uploading` param and the hash, // preserving any other query parameters the loader needs) and resume the // upload flow. { const url = new URL(location.href); const uploadingScheme = url.searchParams.get("uploading"); if (uploadingScheme) { const hashParams = new URLSearchParams(url.hash.slice(1)); const refreshToken = hashParams.get("refresh_token"); // Clean URL: remove the `uploading` param and the hash, keep everything else. url.searchParams.delete("uploading"); url.hash = ""; history.replaceState({}, "", url); if (refreshToken) { // Don't await — let the page render while the upload runs. resumeUpload(uploadingScheme, refreshToken); } else { setError("Authorization failed. Please try again."); } } } //////////////////////////////////////////// // REACTIVE LISTS //////////////////////////////////////////// // Recover filenames for tracks cached in previous sessions: the cached // `File` blob retains its `.name` across IDB round-trips, so we read it back // once on load and seed the in-memory map before the first render. await (async () => { // Restore the persisted CID → filename map so remote tracks (uploaded in a // previous session) can still show their "Artist - Title" label. const persisted = await IDB.get(CID_NAMES_KEY); if (persisted) { for (const [cid, name] of Object.entries(persisted)) { cidNames.set(cid, name); } } // Recover filenames for tracks still in the ephemeral cache: the cached // `File` blob retains its `.name` across IDB round-trips, so we read it back // once on load and seed the in-memory maps before the first render. const tracks = await Output.data(outputOrchestrator.tracks); const ephemeralUris = tracks .filter((t) => t.uri.startsWith("ephemeral+cache://")) .map((t) => t.uri); await Promise.all( ephemeralUris.map(async (uri) => { if (ephemeralNames.has(uri)) return; const blob = await IDB.get(CACHE_KEY_PREFIX + uri); if (blob?.name) { ephemeralNames.set(uri, blob.name); const cid = cidFromUri(uri); if (cid) cidNames.set(cid, blob.name); } }), ); namesVersion.value++; })(); // Load persisted sync scheme. { const scheme = await IDB.get(SYNC_SCHEME_KEY); if (scheme) syncScheme.value = scheme; } effect(() => { // Re-fire when ephemeralNames/cidNames are updated (plain Maps, not signals). namesVersion.get(); const tracksCol = outputOrchestrator.tracks.collection(); const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; // Local tracks: ephemeral cache tracks (files cached locally, not yet // uploaded to the cloud). The label is the original filename without its // extension (e.g. "Artist - Title"); the CID is shown as a subtitle. const localEntries = tracks .filter((t) => t.uri.startsWith("ephemeral+cache://")) .map((t) => { const cid = cidFromUri(t.uri); const original = ephemeralNames.get(t.uri) ?? cidNames.get(cid); return { label: original ? stemOf(original) : cid, cid, uri: t.uri, }; }) .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) ); setLocalItems( localEntries.map(({ label, cid, uri }) => ({ name: label, subtitle: cid, onRemove: () => removeLocalEntry(uri, label), })), ); // Remote tracks: tracks from the selected upload method's input only. // When no upload method is selected, no remote tracks are shown. The cloud // file is named `.`, so we look up the original filename via the // CID to show "Artist - Title", falling back to the cloud filename. const scheme = syncScheme.get(); const remoteEntries = scheme ? tracks .filter( (t) => t.uri.startsWith(scheme + "://") && t.kind !== "placeholder", ) .map((t) => { const cid = cidFromUri(t.uri); const original = cidNames.get(cid); return { label: original ? stemOf(original) : trackLabel(t.uri), cid, uri: t.uri, }; }) .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) ) : []; setRemoteItems( remoteEntries.map(({ label, cid, uri }) => ({ name: label, subtitle: cid, onRemove: () => removeRemoteEntry(uri, label), })), ); }); //////////////////////////////////////////// // UPLOAD BUTTON / STOP SYNCING //////////////////////////////////////////// // When not syncing: show the "Connect" button with a dropdown of available // upload methods. When syncing: show a "Stop syncing" button that disconnects // the upload method and stops auto-uploading. effect(() => { if (!uploadBtn || !uploadIcon || !uploadLabel || !uploadMenu) return; if (!stopSyncBtn || !stopSyncLabel) return; const state = uploadState.get(); const isBusy = state !== "idle"; if (uploadIndicator) { uploadIndicator.hidden = !isBusy; uploadIndicator.title = state === "caching" ? "Processing local files …" : state === "uploading" ? "Uploading to cloud storage …" : ""; } const syncing = syncScheme.get(); if (syncing) { // Sync mode: hide upload button, show stop syncing button. uploadBtn.hidden = true; uploadMenu.hidePopover?.(); stopSyncBtn.hidden = false; stopSyncLabel.textContent = `Stop syncing (${ SCHEME_LABELS[syncing] ?? syncing })`; return; } // Idle mode: show upload button, hide stop syncing button. uploadBtn.hidden = false; uploadMenu.hidden = false; stopSyncBtn.hidden = true; const uploadComponents = uploadConfigurator.uploaders(); const uploadEntries = Object.entries(uploadComponents); const canUpload = uploadEntries.length > 0; uploadBtn.disabled = isBusy || !canUpload; uploadIcon.className = "ph-fill ph-cloud-arrow-up"; uploadLabel.textContent = isBusy ? "Uploading ..." : "Set up syncing"; litRender( html` ${uploadEntries.map(([scheme, element]) => html` ` )} `, uploadMenu, ); }); //////////////////////////////////////////// // PROCESS BUTTON //////////////////////////////////////////// effect(() => { if (!processBtn || !processIcon || !processLabel) return; const isProcessing = processOrchestrator.isProcessing(); const { processed, total } = processOrchestrator.progress(); const pct = total > 0 ? Math.round((processed / total) * 100) : null; processBtn.disabled = isProcessing; processIcon.className = isProcessing ? "ph-fill ph-arrows-clockwise animate-spin" : "ph-fill ph-arrows-clockwise"; processLabel.textContent = isProcessing ? (pct !== null ? `Processing (${pct}%)` : "Listing") : "Process"; }); processBtn?.addEventListener("click", async () => { await Output.data(outputOrchestrator.tracks); // (Re)process local files (the ephemeral cache) and — while syncing — the // connected cloud source too, so freshly uploaded tracks get metadata. // The orchestrator skips tracks that already have tags & stats (and // placeholders), so this is incremental. When not syncing, only local // files are processed: the file manager manages no cloud source then. const scheme = syncScheme.value; await processOrchestrator.process([ `${SCHEME_EPHEMERAL_CACHE}://`, ...(scheme ? [`${scheme}://`] : []), ]); }); //////////////////////////////////////////// // ACTIONS //////////////////////////////////////////// /** * Extracts a human-readable label from a track URI (the last path segment, * URL-decoded). * * @param {string} uri * @returns {string} */ function trackLabel(uri) { const withoutQuery = uri.split("?")[0]; const parts = withoutQuery.split("/"); const last = parts[parts.length - 1]; return last ? safeDecodeURIComponent(last) : uri; } /** * Extracts the file extension (without the dot) from a filename, or "" if it * has none. Used to build the cloud filename as `.`. * * @param {string} name * @returns {string} */ function extOf(name) { const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\")); const base = slash >= 0 ? name.slice(slash + 1) : name; const dot = base.lastIndexOf("."); return dot > 0 ? base.slice(dot + 1) : ""; } /** * Returns the filename without its extension — the "Artist - Title" part of * "Artist - Title.mp3". * * @param {string} name * @returns {string} */ function stemOf(name) { const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\")); const base = slash >= 0 ? name.slice(slash + 1) : name; const dot = base.lastIndexOf("."); return dot > 0 ? base.slice(0, dot) : base; } /** * Extracts the content CID from a track URI. For ephemeral tracks the URI is * `ephemeral+cache://`; for remote tracks the last path segment is the * uploaded file `.`. * * @param {string} uri * @returns {string} */ function cidFromUri(uri) { const schemeSep = uri.indexOf("://"); if (schemeSep < 0) return ""; const rest = uri.slice(schemeSep + 3).split("?")[0]; const slash = rest.lastIndexOf("/"); const file = slash >= 0 ? rest.slice(slash + 1) : rest; const dot = file.lastIndexOf("."); return dot > 0 ? file.slice(0, dot) : file; } /** * Finds the first enabled (non-disabled) source URI for the given scheme. * * @param {string} scheme * @returns {string | null} */ function enabledSourceUri(scheme) { const sourcesRecord = sourcesOrchestrator.sources(); const sources = (sourcesRecord[scheme] ?? []).filter( (s) => !sourcesOrchestrator.isDisabled(s.uri), ); return sources.length > 0 ? sources[0].uri : null; } /** * @param {string} uri * @param {string} name */ async function removeLocalEntry(uri, name) { if (!confirm(`Are you sure you want to remove "${name}"?`)) return; 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); ephemeralNames.delete(uri); const cid = cidFromUri(uri); if (cid && cidNames.delete(cid)) { await IDB.set(CID_NAMES_KEY, Object.fromEntries(cidNames)); } namesVersion.value++; } } catch (err) { setError(err instanceof Error ? err.message : "Failed to remove entry"); } } /** * Deletes a remote track: first deletes the file from the cloud, then * detaches the track from the output. * * If the access token has expired, the delete is stored as pending and the * OAuth reconnection flow is triggered automatically. After reconnection, * `resumeUpload` picks up the pending delete and retries it. * * @param {string} uri * @param {string} name */ async function removeRemoteEntry(uri, name) { if (!confirm(`Are you sure you want to remove "${name}"?`)) return; setError(null); try { // Delete the file from the cloud first. await uploadConfigurator.delete(uri); // Drop the stored label for this CID; the file is gone. const cid = cidFromUri(uri); if (cid && cidNames.delete(cid)) { await IDB.set(CID_NAMES_KEY, Object.fromEntries(cidNames)); } // Then remove just this track from the output (not the entire source — // `inputConfigurator.detach` would remove all tracks for the account). const tracks = await Output.data(outputOrchestrator.tracks); const filteredTracks = tracks.filter((t) => t.uri !== uri); await outputOrchestrator.tracks.save(filteredTracks); // Re-list from the cloud and save the result. This ensures the output // is consistent with the cloud state. Without this, the process-tracks // orchestrator's `mergeById` (which uses stale `cachedTracks` from the // start of its run) can bring back the deleted track on the next // `process()` call. const listed = await inputConfigurator.list(filteredTracks); await outputOrchestrator.tracks.save(listed); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to remove entry"; if (msg.includes("expired")) { // Token expired — store the file path so the delete can be retried // after reconnection, then trigger OAuth. try { const path = new URL(uri).pathname; await IDB.set(PENDING_DELETE_KEY, path); } catch { // If path extraction fails, the user can retry manually after // reconnection. } reconnectSync(); return; } setError(msg); } } /** * Uploads all ephemeral cache tracks to the selected source's cloud storage, * then removes them from the ephemeral cache (they're now in the cloud and * will be picked up by the matching input component's `list()` on the next * processing run). * * @param {string} sourceUri - The account/source URI to upload to. */ async function uploadTracks(sourceUri) { setError(null); uploadState.value = "uploading"; try { const tracks = await Output.data(outputOrchestrator.tracks); const ephemeralTracks = tracks.filter((t) => t.uri.startsWith("ephemeral+cache://") ); if (ephemeralTracks.length === 0) return; // Reconstruct Files from the IDB-cached blobs. The uploaded file is // named after its content ID with the original extension preserved // (e.g. "bafy…foo.mp3"), so duplicate uploads don't clobber each other // and the filename is stable across re-uploads. The cached blob is // itself a `File` and retains its `.name` across IDB round-trips, so it's // a reliable fallback when `ephemeralNames` hasn't been seeded yet (e.g. // right after an OAuth redirect). const files = await Promise.all( ephemeralTracks.map(async (track) => { const blob = await IDB.get(CACHE_KEY_PREFIX + track.uri); const original = ephemeralNames.get(track.uri) ?? blob?.name ?? "audio"; const cid = cidFromUri(track.uri); const ext = extOf(original); const name = ext ? `${cid}.${ext}` : cid; return new File([blob], name, { type: blob?.type ?? "audio/*" }); }), ); // Upload each file to the selected source's account. await Promise.all( files.map((file) => uploadConfigurator.upload({ file, uri: sourceUri })), ); // Remove the ephemeral cache tracks (they're now in the cloud). const detachedTracks = await inputConfigurator.detach({ fileUriOrScheme: SCHEME_EPHEMERAL_CACHE, tracks, }); await outputOrchestrator.tracks.save(detachedTracks); // Clean up the filename map for the now-removed ephemeral tracks. for (const track of ephemeralTracks) { ephemeralNames.delete(track.uri); } namesVersion.value++; // List tracks from all input sources so the newly uploaded files appear. // We call `list` directly (instead of relying on the process-tracks // orchestrator) for reliability — the orchestrator may not be fully // initialised yet when this runs. const listed = await inputConfigurator.list(detachedTracks); await outputOrchestrator.tracks.save(listed); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to upload tracks"; if (msg.includes("expired")) { // Token expired — trigger reconnection. Ephemeral tracks are preserved // in IDB and will be uploaded automatically after reconnection. reconnectSync(); return; } setError(msg); } finally { uploadState.value = "idle"; } } /** * Uploads all ephemeral cache tracks to the sync source, then drains: if new * ephemeral tracks appeared during the upload (e.g. from concurrent drops), * uploads them too. */ async function syncUpload() { if (uploadState.get() === "uploading") return; if (!syncScheme.value) return; const sourceUri = enabledSourceUri(syncScheme.value); if (!sourceUri) return; await uploadTracks(sourceUri); // Drain: check if there are new ephemeral tracks (e.g. from concurrent // drops) and upload them too. if (syncScheme.value) { const tracks = await Output.data(outputOrchestrator.tracks); const hasEphemeral = tracks.some((t) => t.uri.startsWith("ephemeral+cache://") ); if (hasEphemeral) { await syncUpload(); } } } /** * Enters sync mode for the given scheme: arms auto-uploading of new local * tracks and persists the scheme so it survives reloads. * * Only called once the syncing destination is chosen (and consulted) — * entering sync mode earlier would flash the "Stop syncing" button in the * UI. * * @param {string} scheme */ async function enterSyncMode(scheme) { syncScheme.value = scheme; await IDB.set(SYNC_SCHEME_KEY, scheme); } /** * Arms syncing for the given source and uploads all pending local tracks to * it. The destination must already be consulted (or have just been created). * * @param {string} scheme * @param {string} sourceUri */ async function beginSync(scheme, sourceUri) { await enterSyncMode(scheme); await uploadTracks(sourceUri); } /** * Starts the sync flow for the selected upload component. For uploaders * without an OAuth flow this opens the connect dialog, where the user picks * which existing source to sync with or connects a new one — no source is * silently preferred, since the user may have several and only want to sync * to one. OAuth uploaders reuse an existing source if its token is still * valid (re-enabling if necessary), or trigger the OAuth flow (adding * `?uploading=` to the URL so we can resume after the redirect). * * @param {string} scheme * @param {UploadElement} uploadElement */ async function startUpload(scheme, uploadElement) { setError(null); // Non-OAuth uploaders with a connect form: always ask for the destination. const connectConfig = CONNECT_CONFIGS[scheme]; if (connectConfig) { openConnectSourceDialog(connectConfig); return; } const sourcesRecord = sourcesOrchestrator.sources(); const allSources = sourcesRecord[scheme] ?? []; const enabledSources = allSources.filter( (s) => !sourcesOrchestrator.isDisabled(s.uri), ); const disabledSources = allSources.filter((s) => sourcesOrchestrator.isDisabled(s.uri) ); // Check if an existing source's token is still valid before re-using it. // If the token has expired (consult returns "no"), fall through to the // OAuth flow to get a fresh token. if (enabledSources.length > 0) { const result = await uploadConfigurator.consult(enabledSources[0].uri); if (result.supported && result.consult !== "no") { // Token is valid (or inconclusive — be optimistic): upload directly. await beginSync(scheme, enabledSources[0].uri); return; } } if (disabledSources.length > 0) { const result = await uploadConfigurator.consult(disabledSources[0].uri); if (result.supported && result.consult !== "no") { // Token is valid — re-enable the source, then upload. await sourcesOrchestrator.toggle(disabledSources[0].uri); await beginSync(scheme, disabledSources[0].uri); return; } } // No usable existing source — authenticate via OAuth. if (!uploadElement.authorize) { // Fallback for uploaders without an OAuth flow or a connect form: they // can't mint a source themselves — one must be connected first, and if // one exists but is unreachable, re-authenticating can't help. const label = SCHEME_LABELS[scheme] ?? scheme; const connectPath = uploadElement.CONNECT_PATH ?? "facets/connect/index.tile"; const connectLink = html`Connect page`; setError( allSources.length === 0 ? html`No ${label} source is connected. Connect one on the ${connectLink} first, then set up syncing again.` : html`The ${label} storage could not be reached. Check its credentials (and the bucket's CORS settings) on the ${connectLink}, then try again.`, ); return; } // Enter sync mode before the redirect so it survives the page reload // (resumeUpload sets it again as well). await enterSyncMode(scheme); const url = new URL(location.href); url.searchParams.set("uploading", scheme); history.replaceState({}, "", url); uploadElement.authorize(); // Page will redirect to the OAuth provider. } /** * Resumes the sync flow after returning from the OAuth redirect. The refresh * token is in the URL hash; we use it to build a placeholder track (via the * upload component's `createSource`), upload files to that account, then save * the placeholder track so the matching input component lists the files. * * @param {string} scheme * @param {string} refreshToken */ async function resumeUpload(scheme, refreshToken) { setError(null); uploadState.value = "uploading"; // Enter sync mode (persisted before the redirect, but set again for safety). syncScheme.value = scheme; await IDB.set(SYNC_SCHEME_KEY, scheme); try { /** @type {UploadElement | undefined} */ const uploadElement = uploadConfigurator.uploaders()[scheme]; if (!uploadElement) { setError(`Unsupported upload scheme: ${scheme}`); return; } const tracks = await Output.data(outputOrchestrator.tracks); const ephemeralTracks = tracks.filter((t) => t.uri.startsWith("ephemeral+cache://") ); // Build the placeholder track — its URI is the account URI we upload to. // Routed through the configurator so it reaches the correct upload // component by scheme. const placeholderTrack = await uploadConfigurator.createSource({ scheme, refreshToken, directoryPath: UPLOAD_DIRECTORY, }); const uri = placeholderTrack.uri; // Upload ephemeral tracks if any. if (ephemeralTracks.length > 0) { // Reconstruct Files from the IDB-cached blobs. The uploaded file is // named after its content ID with the original extension preserved // (e.g. "bafy…foo.mp3"). The cached blob is itself a `File` and retains // its `.name` across IDB round-trips, so it's a reliable fallback when // `ephemeralNames` hasn't been seeded yet (e.g. right after an OAuth // redirect). const files = await Promise.all( ephemeralTracks.map(async (track) => { const blob = await IDB.get(CACHE_KEY_PREFIX + track.uri); const original = ephemeralNames.get(track.uri) ?? blob?.name ?? "audio"; const cid = cidFromUri(track.uri); const ext = extOf(original); const name = ext ? `${cid}.${ext}` : cid; return new File([blob], name, { type: blob?.type ?? "audio/*" }); }), ); // Upload each file to the account. await Promise.all( files.map((file) => uploadConfigurator.upload({ file, uri })), ); } // Replace ALL existing tracks for this scheme (old placeholder + remote // tracks with an expired token) with the new placeholder. A new token // creates a different account ID, so old tracks won't be matched by a // fresh listing — they must be explicitly removed. const schemePrefix = scheme + "://"; const tracksWithoutOldScheme = tracks.filter( (t) => !t.uri.startsWith(schemePrefix), ); const tracksWithPlaceholder = [...tracksWithoutOldScheme, placeholderTrack]; await outputOrchestrator.tracks.save(tracksWithPlaceholder); // Remove the ephemeral cache tracks if any were uploaded. let detachedTracks = tracksWithPlaceholder; if (ephemeralTracks.length > 0) { detachedTracks = await inputConfigurator.detach({ fileUriOrScheme: SCHEME_EPHEMERAL_CACHE, tracks: tracksWithPlaceholder, }); await outputOrchestrator.tracks.save(detachedTracks); // Clean up the filename map for the now-removed ephemeral tracks. for (const track of ephemeralTracks) { ephemeralNames.delete(track.uri); } namesVersion.value++; } // List tracks from all input sources so the newly uploaded files appear. // We call `list` directly (instead of relying on the process-tracks // orchestrator) for reliability — the orchestrator may not be fully // initialised yet when this runs. const listed = await inputConfigurator.list(detachedTracks); await outputOrchestrator.tracks.save(listed); // Retry a pending delete if one was stored (e.g. the user tried to delete // a track but the token had expired — after reconnection the track is // re-listed with a fresh token, so we retry the delete now). const pendingPath = await IDB.get(PENDING_DELETE_KEY); if (pendingPath) { await IDB.del(PENDING_DELETE_KEY); const target = listed.find((t) => { try { return new URL(t.uri).pathname === pendingPath; } catch { return false; } }); if (target) { await uploadConfigurator.delete(target.uri); // Remove just this track from the output (not the entire source — // `inputConfigurator.detach` would remove all tracks for the account). const afterDelete = listed.filter((t) => t.uri !== target.uri); await outputOrchestrator.tracks.save(afterDelete); } } } catch (err) { setError(err instanceof Error ? err.message : "Failed to upload tracks"); } finally { uploadState.value = "idle"; } } /** * Stops syncing: clears the sync state so new local tracks are no longer * auto-uploaded. Does NOT disable the input source or remove the remote * tracks — they stay in the output and remain visible (and deletable) in * the remote tracks list. */ async function stopSyncing() { setError(null); const scheme = syncScheme.value; if (!scheme) return; // Clear sync state. The input source stays enabled so remote tracks remain // visible and deletable. syncScheme.value = null; await IDB.del(SYNC_SCHEME_KEY); } /** * Triggers the OAuth flow to get a fresh access token for the current sync * scheme. Called automatically when an expired token is detected during a * delete or upload operation. Bypasses the `consult()` cache (which may * still report "yes" right after expiry) and goes straight to re-authorisation. */ function reconnectSync() { const scheme = syncScheme.value; if (!scheme) return; const uploadElement = uploadConfigurator.uploaders()[scheme]; if (!uploadElement?.authorize) { setError( `Upload component "${ SCHEME_LABELS[scheme] ?? scheme }" does not support reconnection.`, ); return; } setError("Token expired. Reconnecting…"); const url = new URL(location.href); url.searchParams.set("uploading", scheme); history.replaceState({}, "", url); uploadElement.authorize(); } /** * @param {File[]} files */ async function cacheFiles(files) { setError(null); uploadState.value = "caching"; try { const uris = await Promise.all( files.map((file) => inputConfigurator.cacheBlob(file)), ); files.forEach((file, i) => { const uri = uris[i]; ephemeralNames.set(uri, file.name); const cid = cidFromUri(uri); if (cid) cidNames.set(cid, file.name); }); // Persist the CID → filename map so labels survive upload and reloads. await IDB.set(CID_NAMES_KEY, Object.fromEntries(cidNames)); namesVersion.value++; const now = new Date().toISOString(); const existingTracks = await Output.data(outputOrchestrator.tracks); const existingUris = new Set(existingTracks.map((t) => t.uri)); const newUris = uris.filter((uri) => !existingUris.has(uri)); await outputOrchestrator.tracks.save([ ...existingTracks, ...newUris.map((uri) => { /** @type {Track} */ const track = { $type: "sh.diffuse.output.track", id: TID.now(), createdAt: now, updatedAt: now, ephemeral: true, uri, }; return track; }), ]); // If in sync mode, auto-upload the newly cached tracks. if (syncScheme.value) { await syncUpload(); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to cache files"); } finally { // Only reset if we're still in "caching" state — if syncUpload ran, it // manages its own upload state ("uploading" → "idle"). if (uploadState.value === "caching") uploadState.value = "idle"; } } /** * @param {DataTransferItem[]} items * @param {File[]} directFiles * @returns {Promise} */ async function collectFiles(items, directFiles) { // `isAudioFile` matches on the filename extension. This is deliberate: it // relies on the canonical `~/components/input/common.js` list and works even // when Firefox reports an empty `File.type` for OS-dragged files. const files = directFiles.filter((file) => isAudioFile(file.name)); await Promise.all( items.map(async (item) => { if (item.kind !== "file") return; const entry = item.webkitGetAsEntry?.(); if (entry?.isDirectory) { const dirFiles = await readDirectoryEntry( /** @type {FileSystemDirectoryEntry} */ (entry), ); files.push(...dirFiles.filter((file) => isAudioFile(file.name))); } else { // `getAsFile()` can return `null` on Firefox for OS-dragged files, so // a `File` arriving here is best-effort; direct files already came via // `dataTransfer.files`. Only add when it's usable. const file = item.getAsFile(); if (file && isAudioFile(file.name)) files.push(file); } }), ); return files; } /** * @param {FileSystemDirectoryEntry} dir * @returns {Promise} */ async function readDirectoryEntry(dir) { const reader = dir.createReader(); return new Promise((resolve, reject) => { /** @type {File[]} */ const files = []; const readBatch = () => { reader.readEntries(async (entries) => { if (entries.length === 0) { resolve(files); return; } await Promise.all( entries.map(async (entry) => { if (entry.isDirectory) { const nested = await readDirectoryEntry( /** @type {FileSystemDirectoryEntry} */ (entry), ); files.push(...nested); } else { const file = await new Promise( /** @param {(f: File) => void} res */ (res, rej) => /** @type {FileSystemFileEntry} */ (entry).file(res, rej), ); if (file.type.startsWith("audio/")) files.push(file); } }), ); readBatch(); }, reject); }; readBatch(); }); } //////////////////////////////////////////// // AUTO-UPLOAD ON LOAD //////////////////////////////////////////// // If we're in sync mode on page load (persisted from a previous session) and // there are ephemeral tracks that haven't been uploaded yet, upload them now. // Skip if resumeUpload is already handling it (detected via the OAuth callback). { if (syncScheme.value && uploadState.get() !== "uploading") { const tracks = await Output.data(outputOrchestrator.tracks); const hasEphemeral = tracks.some((t) => t.uri.startsWith("ephemeral+cache://") ); if (hasEphemeral) { syncUpload(); } } } foundation.ready();