diff --git a/src/components/input/common.js b/src/components/input/common.js index 9bd20bde..0d744966 100644 --- a/src/components/input/common.js +++ b/src/components/input/common.js @@ -172,3 +172,79 @@ export function groupKey(scheme, groupId) { export function isAudioFile(filename) { return filename.match(/\.(flac|m4a|mp3|mp4|ogg|opus|wav|webm)$/); } + +/** + * @param {string} filename + * @returns {boolean} + * + * @example Returns truthy for image extensions and falsy for non-image ones + * ```js + * import { isImageFile } from "~/components/input/common.js"; + * + * const images = ["cover.jpg", "folder.jpeg", "art.png", "scan.gif", "pic.webp", "Cover.JPG"]; + * for (const f of images) { + * if (!isImageFile(f)) throw new Error(`${f} should be recognised as an image`); + * } + * + * const nonImages = ["cover.txt", "audio.mp3", "cover", "folder"]; + * for (const f of nonImages) { + * if (isImageFile(f)) throw new Error(`${f} should not be recognised as an image`); + * } + * ``` + */ +export function isImageFile(filename) { + return /\.(jpe?g|png|gif|webp)$/i.test(filename); +} + +/** + * Fetch a URL and return its body bytes, or `null` if the request failed. + * + * @param {string} url + * @param {AbortSignal} [signal] + * @returns {Promise} + */ +export async function bytesFromUrl(url, signal) { + const response = await fetch(url, { signal }); + if (!response.ok) return null; + return new Uint8Array(await response.arrayBuffer()); +} + +/** + * Pick the preferred artwork from a list of image candidates, in fallback order: + * first one whose name contains "cover" (case-insensitive), else one containing + * "front", else the first candidate. + * + * @template T + * @param {T[]} items + * @param {(item: T) => string} nameOf + * @returns {T | undefined} + * + * @example Prefers the cover-named image over the first one + * ```js + * import { pickCoverArt } from "~/components/input/common.js"; + * + * const picked = pickCoverArt(["a.png", "cover.png", "b.png"], (n) => n); + * if (picked !== "cover.png") throw new Error("expected cover.png to win"); + * ``` + * + * @example Falls back to a front-named image when no cover is present + * ```js + * import { pickCoverArt } from "~/components/input/common.js"; + * + * const picked = pickCoverArt(["a.png", "front.jpg", "b.png"], (n) => n); + * if (picked !== "front.jpg") throw new Error("expected front.jpg to beat non-cover images"); + * ``` + * + * @example Falls back to the first image when neither cover nor front is present + * ```js + * import { pickCoverArt } from "~/components/input/common.js"; + * + * const picked = pickCoverArt(["a.png", "b.png"], (n) => n); + * if (picked !== "a.png") throw new Error("expected first image when no cover or front is present"); + * ``` + */ +export function pickCoverArt(items, nameOf) { + return items.find((item) => /cover/i.test(nameOf(item))) ?? + items.find((item) => /front/i.test(nameOf(item))) ?? + items[0]; +} diff --git a/src/components/input/dropbox/common.js b/src/components/input/dropbox/common.js index da27fdbb..06ccef12 100644 --- a/src/components/input/dropbox/common.js +++ b/src/components/input/dropbox/common.js @@ -332,6 +332,53 @@ export async function listFiles(refreshToken, directoryPath) { return entries; } +/** + * @param {string} refreshToken + * @param {string} dirPath + * @returns {Promise | null>} + */ +export async function listDirFiles(refreshToken, dirPath) { + const accessToken = await getAccessToken(refreshToken); + if (!accessToken) return null; + + const headers = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + }; + + /** @type {Array<{ name: string; path_lower: string }>} */ + const entries = []; + let cursor = /** @type {string | null} */ (null); + let hasMore = true; + + while (hasMore) { + const url = cursor + ? "https://api.dropboxapi.com/2/files/list_folder/continue" + : "https://api.dropboxapi.com/2/files/list_folder"; + + const body = cursor + ? JSON.stringify({ cursor }) + : JSON.stringify({ path: dirPath, recursive: false, limit: 2000 }); + + const resp = await fetch(url, { method: "POST", headers, body }); + if (!resp.ok) return null; + + /** @type {{ entries: Array<{ ".tag": string; name: string; path_lower: string }>; has_more: boolean; cursor: string }} */ + const data = await resp.json(); + + for (const entry of data.entries) { + if (entry[".tag"] === "file") { + entries.push({ name: entry.name, path_lower: entry.path_lower }); + } + } + + hasMore = data.has_more; + cursor = data.cursor; + } + + return entries; +} + /** * @param {string} refreshToken * @param {string} filePath diff --git a/src/components/input/dropbox/worker.js b/src/components/input/dropbox/worker.js index 351ab3c9..7e8ec0a1 100644 --- a/src/components/input/dropbox/worker.js +++ b/src/components/input/dropbox/worker.js @@ -1,8 +1,11 @@ import * as TID from "@atcute/tid"; import { ostiary, rpc } from "~/common/worker.js"; import { + bytesFromUrl, detach as detachUtil, groupKey, + isImageFile, + pickCoverArt, } from "~/components/input/common.js"; import { accountId, @@ -12,6 +15,7 @@ import { getTemporaryLink, groupTracksByAccount, groupUrisByAccount, + listDirFiles, listFiles, parseURI, } from "./common.js"; @@ -29,8 +33,28 @@ import { SCHEME } from "./constants.js"; /** * @type {Actions['artwork']} */ -export async function artwork(_uri) { - return null; +export async function artwork(uri) { + try { + const parsed = parseURI(uri); + if (!parsed || parsed.path === "/") return null; + + const parentDir = parsed.path.slice(0, parsed.path.lastIndexOf("/")); + if (!parentDir) return null; + + const files = await listDirFiles(parsed.refreshToken, parentDir); + if (!files?.length) return null; + + const images = files.filter((file) => isImageFile(file.name)); + const imageFile = pickCoverArt(images, (file) => file.name); + if (!imageFile) return null; + + const link = await getTemporaryLink(parsed.refreshToken, imageFile.path_lower); + if (!link) return null; + + return await bytesFromUrl(link); + } catch { + return null; + } } /** diff --git a/src/components/input/https-json/common.js b/src/components/input/https-json/common.js index 13af8507..39d3a869 100644 --- a/src/components/input/https-json/common.js +++ b/src/components/input/https-json/common.js @@ -277,3 +277,37 @@ async function walkEntries(server, dir, entries, paths, exclude) { } } } + +/** + * List the names of the file entries in a single directory's JSON listing. + * Only the immediate directory is fetched (no recursion); subdirectories are + * ignored. Intended for locating sibling artwork next to an audio file. + * + * Returns the plain (raw) filenames of all `type === "file"` entries, or `null` + * if the directory listing could not be fetched or is not an array. + * + * @param {Server} server + * @param {string} dir + * @returns {Promise} + */ +export async function listImageFilesInDir(server, dir) { + /** @type {string[]} */ + const fileNames = []; + + try { + const entries = await fetchDirEntries(server, dir); + for (const entry of entries) { + if (!entry || typeof /** @type {any} */ (entry).name !== "string" || + /** @type {any} */ (entry).type !== "file") { + continue; + } + fileNames.push(/** @type {any} */ (entry).name); + } + } catch { + // Directory listing unavailable (network error, timeout, non-2xx, or + // non-JSON response) — treat as "no sibling artwork found". + return null; + } + + return fileNames; +} diff --git a/src/components/input/https-json/worker.js b/src/components/input/https-json/worker.js index 29f5bcc3..b94a3d9e 100644 --- a/src/components/input/https-json/worker.js +++ b/src/components/input/https-json/worker.js @@ -1,9 +1,12 @@ import * as TID from "@atcute/tid"; import { ostiary, rpc } from "~/common/worker.js"; import { + bytesFromUrl, detach as detachUtil, groupKey, isAudioFile, + isImageFile, + pickCoverArt, } from "~/components/input/common.js"; import { safeDecodeURIComponent } from "~/common/utils.js"; @@ -13,6 +16,7 @@ import { groupTracksByServer, groupUrisByServer, listFiles, + listImageFilesInDir, parseURI, serverId, toHttpUrl, @@ -31,8 +35,34 @@ import { SCHEME } from "./constants.js"; /** * @type {Actions['artwork']} */ -export async function artwork(_uri) { - return null; +export async function artwork(uri) { + try { + const parsed = parseURI(uri); + if (!parsed || !parsed.path) return null; + + // Parent directory of the audio file, relative to server.dir. + const lastSlash = parsed.path.lastIndexOf("/"); + const dirPath = parsed.server.dir + + (lastSlash > 0 ? parsed.path.slice(0, lastSlash + 1) : ""); + + const fileNames = await listImageFilesInDir(parsed.server, dirPath); + if (fileNames === null) return null; + + const images = fileNames.filter((name) => isImageFile(name)); + const imageName = pickCoverArt(images, (name) => name); + if (!imageName) return null; + + // `dirPath` is already percent-encoded (it comes from `parsed.path`); + // encode just the image filename, mirroring how `walkEntries` builds paths. + const url = toHttpUrl( + parsed.server, + dirPath + encodeURIComponent(imageName), + ); + return await bytesFromUrl(url); + } catch { + // No sibling artwork found / fetch failed — `null` means "none". + return null; + } } /** diff --git a/src/components/input/local/common.js b/src/components/input/local/common.js index e9365438..24b7a13a 100644 --- a/src/components/input/local/common.js +++ b/src/components/input/local/common.js @@ -70,6 +70,35 @@ export async function getHandleFile(handle, path) { return current.getFileHandle(/** @type {string} */ (parts.at(-1))); } +/** + * @param {FileSystemHandle} handle + * @param {string} path + * @returns {Promise} + */ +export async function getDirectoryHandle(handle, path) { + if (handle.kind === "file") { + return undefined; + } + + let current = /** @type {FileSystemDirectoryHandle} */ (handle); + + for (const part of path.replace(/^\//, "").split("/").filter(Boolean)) { + current = await current.getDirectoryHandle(part); + } + + return current; +} + +/** + * @param {string} path + * @returns {string | undefined} + */ +export function dirname(path) { + const index = path.lastIndexOf("/"); + if (index === -1) return undefined; + return path.slice(0, index + 1); +} + /** * @param {Track[]} tracks * @returns {Record} diff --git a/src/components/input/local/worker.js b/src/components/input/local/worker.js index c0dc385e..699baf6a 100644 --- a/src/components/input/local/worker.js +++ b/src/components/input/local/worker.js @@ -1,9 +1,11 @@ import * as TID from "@atcute/tid"; import { ostiary, rpc } from "~/common/worker.js"; -import { groupKey } from "~/components/input/common.js"; +import { groupKey, isImageFile, pickCoverArt } from "~/components/input/common.js"; import { buildURI, + dirname, enumerateAudioFiles, + getDirectoryHandle, getHandleFile, groupTracksByTid, groupUrisByTid, @@ -26,8 +28,37 @@ import { SCHEME } from "./constants.js"; /** * @type {Actions['artwork']} */ -export async function artwork(_uri) { - return null; +export async function artwork(uri) { + try { + const parsed = parseURI(uri); + if (!parsed) return null; + + const directory = dirname(parsed.path); + if (!directory) return null; + + const handles = await loadHandles(); + const handle = handles[parsed.tid]; + if (!handle) return null; + + const dirHandle = await getDirectoryHandle(handle, directory); + if (!dirHandle) return null; + + // Collect sibling image files, then prefer a cover-named one. + /** @type {{ name: string; file: File }[]} */ + const images = []; + for await (const [name, entry] of dirHandle.entries()) { + if (entry.kind !== "file" || !isImageFile(name)) continue; + const file = await /** @type {FileSystemFileHandle} */ (entry).getFile(); + images.push({ name, file }); + } + + const image = pickCoverArt(images, (i) => i.name); + if (!image) return null; + + return new Uint8Array(await image.file.arrayBuffer()); + } catch { + return null; + } } /** diff --git a/src/components/input/s3/worker.js b/src/components/input/s3/worker.js index 374dfd9e..e09f43ba 100644 --- a/src/components/input/s3/worker.js +++ b/src/components/input/s3/worker.js @@ -1,9 +1,12 @@ import * as TID from "@atcute/tid"; import { ostiary, rpc } from "~/common/worker.js"; import { + bytesFromUrl, detach as detachUtil, groupKey, isAudioFile, + isImageFile, + pickCoverArt, } from "~/components/input/common.js"; import { bucketId, @@ -29,8 +32,37 @@ import { SCHEME } from "./constants.js"; /** * @type {Actions['artwork']} */ -export async function artwork(_uri) { - return null; +export async function artwork(uri) { + try { + const parsed = parseURI(uri); + if (!parsed) return null; + + // The audio object key is parsed.path. Its parent directory is + // everything before the last `/`, with a trailing `/`, or empty + // string if the audio sits at the bucket root. + const slash = parsed.path.lastIndexOf("/"); + const directoryPrefix = slash === -1 ? "" : parsed.path.slice(0, slash + 1); + + const client = createClient(parsed.bucket); + const list = await Array.fromAsync( + client.listObjects({ prefix: directoryPrefix }), + ); + + // Only images directly in this directory, not nested subdirectories. + const images = list.filter((object) => { + if (!isImageFile(object.key)) return false; + const remainder = object.key.slice(directoryPrefix.length); + return remainder !== "" && !remainder.includes("/"); + }); + + const image = pickCoverArt(images, (object) => object.key); + if (!image) return null; + + const url = await client.getPresignedUrl("GET", image.key); + return await bytesFromUrl(url); + } catch { + return null; + } } /** diff --git a/src/components/input/webdav/common.js b/src/components/input/webdav/common.js index 3ac14962..28542bc4 100644 --- a/src/components/input/webdav/common.js +++ b/src/components/input/webdav/common.js @@ -223,6 +223,101 @@ export async function listFiles(server) { return paths; } +/** + * List the files directly inside a directory (NOT recursive). + * Issues a single Depth:1 PROPFIND on `dir` and returns raw + * (percent-encoded) paths of the file entries it contains, skipping + * the directory entry itself and any subdirectories. + * + * @param {Server} server + * @param {string} dir + * @returns {Promise} + */ +export async function listFilesInDir(server, dir) { + const url = toHttpUrl(server, dir); + + const response = await fetch(url, { + method: "PROPFIND", + headers: { + "Authorization": authHeader(server), + "Depth": "1", + }, + }); + + if (response.status !== 207 && !response.ok) return []; + + const xml = await response.text(); + const files = /** @type {string[]} */ ([]); + + const doc = parseXml(xml); + const multistatus = doc.root; + if (!multistatus) return []; + + // Normalised target directory (leading slash, no trailing slash) so + // PROPFIND hrefs (which are percent-encoded) can be compared decoded. + const targetDir = ("/" + safeDecodeURIComponent(dir).replace(/^\//, "")).replace( + /\/$/, + "", + ); + + for (const node of multistatus.children ?? []) { + if (node.type !== "element" || node.name.local !== "response") continue; + + let href = ""; + let isCollection = false; + + for (const child of node.children ?? []) { + if (child.type !== "element") continue; + + if (child.name.local === "href") { + href = (child.children?.find((n) => n.type === "text")?.text ?? "") + .trim(); + } else if (child.name.local === "propstat") { + if (propstatHasCollection(child)) isCollection = true; + } + } + + if (!href) continue; + + // Trailing slash is the most reliable collection indicator in WebDAV + isCollection = isCollection || href.endsWith("/"); + + // Keep the raw (percent-encoded) pathname so that buildTrackUrl + // produces a valid URL; decode only for directory comparison. + let rawPath; + try { + rawPath = new URL(href).pathname; + } catch { + rawPath = href; + } + const path = safeDecodeURIComponent(rawPath); + + // Skip the directory entry itself. + // Normalise both sides to have a leading slash — server hrefs always + // do, but `dir` may not when the user omitted the leading slash in + // the form. + const normPath = path.replace(/\/$/, ""); + const normDir = ("/" + safeDecodeURIComponent(dir).replace(/^\//, "")).replace( + /\/$/, + "", + ); + if (normPath === normDir) continue; + + if (isCollection) continue; + + // Skip Synology extended-attribute metadata folders + if (path.split("/").includes("@eaDir")) continue; + + // Only keep files that live directly in the target directory. + const parentDir = path.slice(0, path.lastIndexOf("/")) || "/"; + if (parentDir.replace(/\/$/, "") !== targetDir) continue; + + files.push(rawPath); + } + + return files; +} + /** * @param {Server} server * @param {string} dir diff --git a/src/components/input/webdav/worker.js b/src/components/input/webdav/worker.js index a7232ac9..562c7a10 100644 --- a/src/components/input/webdav/worker.js +++ b/src/components/input/webdav/worker.js @@ -1,9 +1,12 @@ import * as TID from "@atcute/tid"; import { ostiary, rpc } from "~/common/worker.js"; import { + bytesFromUrl, detach as detachUtil, groupKey, isAudioFile, + isImageFile, + pickCoverArt, } from "~/components/input/common.js"; import { safeDecodeURIComponent } from "~/common/utils.js"; @@ -14,6 +17,7 @@ import { groupTracksByServer, groupUrisByServer, listFiles, + listFilesInDir, parseURI, serverId, } from "./common.js"; @@ -31,8 +35,26 @@ import { SCHEME } from "./constants.js"; /** * @type {Actions['artwork']} */ -export async function artwork(_uri) { - return null; +export async function artwork(uri) { + try { + const parsed = parseURI(uri); + if (!parsed || !parsed.path) return null; + + // Parent directory (raw, percent-encoded) of the audio file. + const lastSlash = parsed.path.lastIndexOf("/"); + const dir = lastSlash > 0 ? parsed.path.slice(0, lastSlash) : "/"; + + const files = await listFilesInDir(parsed.server, dir); + const images = files.filter((path) => isImageFile(path)); + const imagePath = pickCoverArt(images, (path) => path); + if (!imagePath) return null; + + const url = buildTrackUrl(parsed.server, imagePath); + return await bytesFromUrl(url); + } catch { + // Never throw: `null` means no sibling artwork found. + return null; + } } /**