diff --git a/src/_data/facets.json b/src/_data/facets.json index 0affd709..c3706a09 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -61,6 +61,12 @@ "category": "Data", "desc": "Use your AT Protocol identity for user-data storage." }, + { + "url": "facets/connect/dropbox/index.html", + "title": "Connect / Dropbox", + "category": "Data", + "desc": "Add Dropbox as an audio source." + }, { "url": "facets/connect/https/index.html", "title": "Connect / HTTPS", @@ -105,7 +111,7 @@ "kind": "prelude", "category": "Data", "tags": ["base"], - "desc": "The default setup for audio input sources. Adds support for: HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." + "desc": "The default setup for audio input sources. Adds support for: Dropbox, HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." }, { "url": "facets/data/metadata-bundle/index.html", diff --git a/src/components/input/dropbox/common.js b/src/components/input/dropbox/common.js new file mode 100644 index 00000000..4e84b60b --- /dev/null +++ b/src/components/input/dropbox/common.js @@ -0,0 +1,226 @@ +import * as URI from "fast-uri"; +import QS from "query-string"; + +import { cachedConsult, isAudioFile } from "~/components/input/common.js"; +import { safeDecodeURIComponent } from "~/common/utils.js"; +import { SCHEME } from "./constants.js"; + +/** + * @import { Track } from "~/definitions/types.d.ts" + */ + +/** + * @typedef {{ accessToken: string; directoryPath: string }} Account + */ + +//////////////////////////////////////////// +// URI +//////////////////////////////////////////// + +/** + * @param {Account} account + * @param {string} [filePath] + */ +export function buildURI(account, filePath) { + return URI.serialize({ + scheme: SCHEME, + userinfo: encodeURIComponent(account.accessToken), + host: "dropbox.com", + path: filePath || "/", + query: QS.stringify({ dir: account.directoryPath || "/" }), + }); +} + +/** + * @param {string} uriString + * @returns {{ accessToken: string; path: string; directoryPath: string } | undefined} + */ +export function parseURI(uriString) { + const uri = URI.parse(uriString); + if (uri.scheme !== SCHEME) return undefined; + if (!uri.userinfo) return undefined; + + const accessToken = decodeURIComponent(uri.userinfo); + const path = safeDecodeURIComponent(uri.path || "/"); + const qs = QS.parse(uri.query || ""); + const directoryPath = typeof qs.dir === "string" ? safeDecodeURIComponent(qs.dir) : "/"; + + return { accessToken, path, directoryPath }; +} + +//////////////////////////////////////////// +// ACCOUNT HELPERS +//////////////////////////////////////////// + +/** + * @param {Account} account + */ +export function accountId(account) { + return `${account.accessToken}:${account.directoryPath}`; +} + +/** + * @param {Track[]} tracks + * @returns {Record} + */ +export function accountsFromTracks(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const id = accountId(parsed); + if (acc[id]) return; + + acc[id] = { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }; + }); + + return acc; +} + +/** + * @param {Track[]} tracks + * @returns {Record} + */ +export function groupTracksByAccount(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const id = accountId(parsed); + + if (acc[id]) { + acc[id].tracks.push(track); + } else { + acc[id] = { + account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, + tracks: [track], + }; + } + }); + + return acc; +} + +/** + * @param {string[]} uris + * @returns {Record} + */ +export function groupUrisByAccount(uris) { + /** @type {Record} */ + const acc = {}; + + uris.forEach((uri) => { + const parsed = parseURI(uri); + if (!parsed) return; + + const id = accountId(parsed); + + if (acc[id]) { + acc[id].uris.push(uri); + } else { + acc[id] = { + account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, + uris: [uri], + }; + } + }); + + return acc; +} + +//////////////////////////////////////////// +// DROPBOX API +//////////////////////////////////////////// + +/** + * @param {string} accessToken + * @param {string} directoryPath + * @returns {Promise | null>} + */ +export async function listFiles(accessToken, directoryPath) { + const apiPath = directoryPath === "/" ? "" : directoryPath; + 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: apiPath, recursive: true, 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" && isAudioFile(entry.name)) { + entries.push({ name: entry.name, path_lower: entry.path_lower }); + } + } + + hasMore = data.has_more; + cursor = data.cursor; + } + + return entries; +} + +/** + * @param {string} accessToken + * @param {string} filePath + * @returns {Promise} + */ +export async function getTemporaryLink(accessToken, filePath) { + const resp = await fetch( + "https://api.dropboxapi.com/2/files/get_temporary_link", + { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ path: filePath }), + }, + ); + + if (!resp.ok) return null; + + /** @type {{ link: string }} */ + const data = await resp.json(); + return data.link ?? null; +} + +/** + * @param {string} accessToken + * @returns {Promise} + */ +export async function checkAccess(accessToken) { + const resp = await fetch( + "https://api.dropboxapi.com/2/users/get_current_account", + { + method: "POST", + headers: { "Authorization": `Bearer ${accessToken}` }, + }, + ); + return resp.ok; +} + +export const checkAccessCached = cachedConsult(checkAccess, (token) => token); diff --git a/src/components/input/dropbox/constants.js b/src/components/input/dropbox/constants.js new file mode 100644 index 00000000..1703a0b0 --- /dev/null +++ b/src/components/input/dropbox/constants.js @@ -0,0 +1,2 @@ +export const SCHEME = "dropbox"; +export const APP_KEY = "kwsydtrzban41zr"; diff --git a/src/components/input/dropbox/element.js b/src/components/input/dropbox/element.js new file mode 100644 index 00000000..22ce1f5f --- /dev/null +++ b/src/components/input/dropbox/element.js @@ -0,0 +1,59 @@ +import { defineElement, DiffuseElement } from "~/common/element.js"; +import { SCHEME } from "./constants.js"; +import { accountsFromTracks, buildURI } 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 DropboxInput extends DiffuseElement { + static NAME = "diffuse/input/dropbox"; + static WORKER_URL = "components/input/dropbox/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(accountsFromTracks(tracks)).map((account) => ({ + label: `Dropbox (${account.directoryPath})`, + uri: buildURI(account), + })); + } +} + +export default DropboxInput; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = DropboxInput; +export const NAME = "di-dropbox"; + +defineElement(NAME, CLASS); diff --git a/src/components/input/dropbox/worker.js b/src/components/input/dropbox/worker.js new file mode 100644 index 00000000..2dcce77c --- /dev/null +++ b/src/components/input/dropbox/worker.js @@ -0,0 +1,198 @@ +import * as TID from "@atcute/tid"; +import { ostiary, rpc } from "~/common/worker.js"; +import { + detach as detachUtil, + groupKey, +} from "~/components/input/common.js"; +import { + accountId, + accountsFromTracks, + buildURI, + checkAccessCached, + getTemporaryLink, + groupTracksByAccount, + groupUrisByAccount, + listFiles, + parseURI, +} 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.accessToken); + 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 = accountId(result); + const groups = groupTracksByAccount(tracks); + delete groups[id]; + + return Object.values(groups).map((g) => g.tracks).flat(1); + }, + }); +} + +/** + * @type {Actions['groupConsult']} + */ +export async function groupConsult(uris) { + const groups = groupUrisByAccount(uris); + + const promises = Object.entries(groups).map( + async ([id, { account, uris }]) => { + const available = await checkAccessCached(account.accessToken); + + /** @type {ConsultGrouping} */ + const grouping = available + ? { available, scheme: SCHEME, uris } + : { available, reason: "Dropbox access denied", 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 = []) { + const accounts = accountsFromTracks(cachedTracks); + + /** @type {Record>} */ + const cache = {}; + + cachedTracks.forEach((t) => { + const parsed = parseURI(t.uri); + if (!parsed || t.kind === "placeholder") return; + + const id = accountId(parsed); + if (!cache[id]) cache[id] = {}; + cache[id][parsed.path] = t; + }); + + const promises = Object.values(accounts).map(async (account) => { + const id = accountId(account); + const files = await listFiles(account.accessToken, account.directoryPath); + + if (!files) { + const existing = cachedTracks.find((t) => { + const p = parseURI(t.uri); + return p && accountId(p) === id && t.kind === "placeholder"; + }); + + const now = new Date().toISOString(); + + return [/** @type {Track} */ ({ + $type: "sh.diffuse.output.track", + id: existing?.id ?? TID.now(), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(account), + })]; + } + + if (!files.length) { + const now = new Date().toISOString(); + + return [/** @type {Track} */ ({ + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(account), + })]; + } + + return files.map((file) => { + const uri = buildURI(account, file.path_lower); + const cached = cache[id]?.[file.path_lower]; + const now = new Date().toISOString(); + + /** @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; + }); + }); + + 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 link = await getTemporaryLink(parsed.accessToken, parsed.path); + if (!link) return undefined; + + // Dropbox temporary links expire after 4 hours + const expiresAt = Math.round(Date.now() / 1000) + 4 * 60 * 60; + return { url: link, expiresAt }; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + artwork, + consult, + detach, + groupConsult, + list, + resolve, + }); +}); diff --git a/src/components/metadata/common.js b/src/components/metadata/common.js index 09a82c59..0f1b0760 100644 --- a/src/components/metadata/common.js +++ b/src/components/metadata/common.js @@ -40,8 +40,14 @@ export async function musicMetadataTags({ // FUCKAROUND: Not sure of the downsides of this httpClient.getHeadInfo = async () => { - const info = await getHeadInfo.call(httpClient); - return { ...info, acceptPartialRequests: true }; + try { + const info = await getHeadInfo.call(httpClient); + return { ...info, acceptPartialRequests: true }; + } catch { + // Some servers (e.g. Dropbox temporary links) don't return Content-Length. + // Fall back to downloading the full file without range requests. + return { size: undefined, acceptPartialRequests: false }; + } }; /** @type {any} */ diff --git a/src/elements.vto b/src/elements.vto index 081e535f..0a3cecc6 100644 --- a/src/elements.vto +++ b/src/elements.vto @@ -55,6 +55,10 @@ engines: Signals that could influence the scope of a set of tracks. input: + - url: "components/input/dropbox/element.js" + title: "Dropbox" + desc: > + Audio files from Dropbox, using the Dropbox v2 HTTP API. - url: "components/input/https/element.js" title: "HTTPS" desc: > diff --git a/src/facets/connect/dropbox/index.html b/src/facets/connect/dropbox/index.html new file mode 100644 index 00000000..05033e76 --- /dev/null +++ b/src/facets/connect/dropbox/index.html @@ -0,0 +1,12 @@ + + +
+ + diff --git a/src/facets/connect/dropbox/index.inline.js b/src/facets/connect/dropbox/index.inline.js new file mode 100644 index 00000000..0154c1b6 --- /dev/null +++ b/src/facets/connect/dropbox/index.inline.js @@ -0,0 +1,202 @@ +import * as TID from "@atcute/tid"; +import { html, nothing } from "lit-html"; + +import * as Output from "~/common/output.js"; +import { SCHEME } from "~/components/input/dropbox/constants.js"; +import { accountId, buildURI, parseURI } from "~/components/input/dropbox/common.js"; +import { APP_KEY } from "~/components/input/dropbox/constants.js"; +import { effect } from "~/common/signal.js"; +import foundation from "~/common/foundation.js"; + +import { setup } from "~/facets/connect/common.js"; + +foundation.setup({ title: "Connect Dropbox | 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), +]); + +//////////////////////////////////////////// +// OAUTH TOKEN FROM HASH +//////////////////////////////////////////// + +const hashParams = new URLSearchParams(location.hash.slice(1)); +let currentToken = hashParams.get("access_token"); + +if (currentToken) { + history.replaceState({}, "", location.pathname + location.search); +} + +//////////////////////////////////////////// +// UI +//////////////////////////////////////////// + +const { setItems, setError } = setup({ + title: "Dropbox", + hasInput: false, + hasOutput: false, + + description: html` +

+ Add your Dropbox as an audio source. Authorize with Dropbox, then + optionally specify which directory to scan for music. +

+ `, + + rightContent: html` +
+ +
+ + + `, + + formFields: html``, + onSubmit: async () => {}, +}); + +const authSection = /** @type {HTMLElement} */ (document.querySelector("#dropbox-auth-section")); +const addSection = /** @type {HTMLElement} */ (document.querySelector("#dropbox-add-section")); +const dirInput = /** @type {HTMLInputElement} */ (document.querySelector("#dropbox-dir")); + +if (currentToken) { + authSection.hidden = true; + addSection.hidden = false; +} + +//////////////////////////////////////////// +// REACTIVE LIST +//////////////////////////////////////////// + +effect(() => { + const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? []; + + /** @type {Map} */ + const allSources = new Map(); + + for (const source of inputSources) { + const parsed = parseURI(source.uri); + if (!parsed) continue; + + const id = accountId(parsed); + if (!allSources.has(id)) { + allSources.set(id, { label: source.label, uri: source.uri }); + } + } + + setItems( + [...allSources.values()].map(({ label, uri }) => ({ + name: label, + detail: "Dropbox", + isInput: true, + isOutput: false, + isSelectedOutput: false, + isDisabled: sourcesOrchestrator.isDisabled(uri), + onRemove: () => removeSource(uri), + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), + })), + ); +}); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +document.querySelector("#dropbox-auth-btn")?.addEventListener("click", () => { + sessionStorage.setItem("oauth/callback/redirect_path", location.pathname + location.search); + + const params = new URLSearchParams({ + response_type: "token", + client_id: APP_KEY, + redirect_uri: location.origin + "/oauth/callback/", + }); + + location.assign(`https://www.dropbox.com/oauth2/authorize?${params}`); +}); + +document.querySelector("#dropbox-add-btn")?.addEventListener("click", async () => { + if (!currentToken) return; + + setError(null); + try { + const rawDir = dirInput?.value?.trim() || "/"; + const directoryPath = rawDir.startsWith("/") ? rawDir : "/" + rawDir; + + const account = { accessToken: currentToken, directoryPath }; + const uri = buildURI(account); + 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, + }, + ]); + + // Reset UI after adding + if (dirInput) dirInput.value = ""; + currentToken = null; + authSection.hidden = false; + addSection.hidden = true; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add Dropbox source"); + } +}); + +/** @param {string} uri */ +async function removeSource(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 source"); + } +} + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); diff --git a/src/facets/connect/index.inline.js b/src/facets/connect/index.inline.js index 27fa7a1f..c1b2d09e 100644 --- a/src/facets/connect/index.inline.js +++ b/src/facets/connect/index.inline.js @@ -11,6 +11,7 @@ const loaderHref = (path) => `l/?path=${encodeURIComponent(path)}`; /** @type {Record} */ const icons = { "facets/connect/atproto/index.html": "at", + "facets/connect/dropbox/index.html": "cloud", "facets/connect/https/index.html": "globe", "facets/connect/icecast/index.html": "radio", "facets/connect/local/index.html": "folder-open", diff --git a/src/facets/data/input-bundle/index.inline.js b/src/facets/data/input-bundle/index.inline.js index e14c16bd..704bbd04 100644 --- a/src/facets/data/input-bundle/index.inline.js +++ b/src/facets/data/input-bundle/index.inline.js @@ -1,6 +1,7 @@ import foundation from "~/common/foundation.js"; 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 ICECAST_NAME } from "~/components/input/icecast/element.js"; @@ -20,6 +21,7 @@ effect(() => { const input = foundation.signals.configurator.input(); if (!input) return; + dropbox(input); ephemeralCache(input); https(input); icecast(input); @@ -28,6 +30,17 @@ effect(() => { s3(input); }); +//////////////////////////////////////////// +// DROPBOX +//////////////////////////////////////////// + +/** + * @param {InputConfigurator} input + */ +export function dropbox(input) { + input.append(document.createElement(DROPBOX_NAME)); +} + //////////////////////////////////////////// // EPHEMERAL CACHE //////////////////////////////////////////// diff --git a/src/facets/data/sources/index.inline.js b/src/facets/data/sources/index.inline.js index 0f332bd5..72005a7f 100644 --- a/src/facets/data/sources/index.inline.js +++ b/src/facets/data/sources/index.inline.js @@ -4,6 +4,7 @@ import * as Output from "~/common/output.js"; import foundation from "~/common/foundation.js"; import { effect } from "~/common/signal.js"; +import { SCHEME as SCHEME_DROPBOX } from "~/components/input/dropbox/constants.js"; import { SCHEME as SCHEME_EPHEMERAL_CACHE } from "~/components/input/ephemeral-cache/constants.js"; import { SCHEME as SCHEME_HTTPS } from "~/components/input/https/constants.js"; import { SCHEME as SCHEME_ICECAST } from "~/components/input/icecast/constants.js"; @@ -13,6 +14,7 @@ import { SCHEME as SCHEME_S3 } from "~/components/input/s3/constants.js"; /** @type {Record} */ const SCHEME_NAMES = { + [SCHEME_DROPBOX]: "Dropbox", [SCHEME_EPHEMERAL_CACHE]: "Browser storage", [SCHEME_HTTPS]: "HTTPS", [SCHEME_ICECAST]: "Icecast", diff --git a/src/themes/blur/browser/element.js b/src/themes/blur/browser/element.js index 868fc0ec..d47efb7a 100644 --- a/src/themes/blur/browser/element.js +++ b/src/themes/blur/browser/element.js @@ -23,6 +23,16 @@ import * as Playlist from "~/common/playlist.js"; * @import {ArtworkElement} from "~/components/artwork/types.d.ts" */ +/** + * @param {Track} track + */ +function trackTitle(track) { + if (track.tags?.title) return track.tags.title; + const path = track.uri.split("?")[0]; + const filename = path.split("/").filter(Boolean).at(-1); + return filename ? decodeURIComponent(filename) : track.uri; +} + const MAX_ART_CONCURRENT = 8; const TRACK_ROW_HEIGHT = 40; @@ -780,7 +790,7 @@ class Browser extends DiffuseElement {
- ${track.tags?.title} + ${trackTitle(track)}
${track.tags?.artist} @@ -1012,7 +1022,7 @@ class Browser extends DiffuseElement {
- ${t.tags?.title} + ${trackTitle(t)}
${t.tags?.artist}