A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 226 lines 5.8 kB view raw
1import * as URI from "fast-uri"; 2import QS from "query-string"; 3 4import { cachedConsult, isAudioFile } from "~/components/input/common.js"; 5import { safeDecodeURIComponent } from "~/common/utils.js"; 6import { SCHEME } from "./constants.js"; 7 8/** 9 * @import { Track } from "~/definitions/types.d.ts" 10 */ 11 12/** 13 * @typedef {{ accessToken: string; directoryPath: string }} Account 14 */ 15 16//////////////////////////////////////////// 17// URI 18//////////////////////////////////////////// 19 20/** 21 * @param {Account} account 22 * @param {string} [filePath] 23 */ 24export function buildURI(account, filePath) { 25 return URI.serialize({ 26 scheme: SCHEME, 27 userinfo: encodeURIComponent(account.accessToken), 28 host: "dropbox.com", 29 path: filePath || "/", 30 query: QS.stringify({ dir: account.directoryPath || "/" }), 31 }); 32} 33 34/** 35 * @param {string} uriString 36 * @returns {{ accessToken: string; path: string; directoryPath: string } | undefined} 37 */ 38export function parseURI(uriString) { 39 const uri = URI.parse(uriString); 40 if (uri.scheme !== SCHEME) return undefined; 41 if (!uri.userinfo) return undefined; 42 43 const accessToken = decodeURIComponent(uri.userinfo); 44 const path = safeDecodeURIComponent(uri.path || "/"); 45 const qs = QS.parse(uri.query || ""); 46 const directoryPath = typeof qs.dir === "string" ? safeDecodeURIComponent(qs.dir) : "/"; 47 48 return { accessToken, path, directoryPath }; 49} 50 51//////////////////////////////////////////// 52// ACCOUNT HELPERS 53//////////////////////////////////////////// 54 55/** 56 * @param {Account} account 57 */ 58export function accountId(account) { 59 return `${account.accessToken}:${account.directoryPath}`; 60} 61 62/** 63 * @param {Track[]} tracks 64 * @returns {Record<string, Account>} 65 */ 66export function accountsFromTracks(tracks) { 67 /** @type {Record<string, Account>} */ 68 const acc = {}; 69 70 tracks.forEach((track) => { 71 const parsed = parseURI(track.uri); 72 if (!parsed) return; 73 74 const id = accountId(parsed); 75 if (acc[id]) return; 76 77 acc[id] = { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }; 78 }); 79 80 return acc; 81} 82 83/** 84 * @param {Track[]} tracks 85 * @returns {Record<string, { account: Account; tracks: Track[] }>} 86 */ 87export function groupTracksByAccount(tracks) { 88 /** @type {Record<string, { account: Account; tracks: Track[] }>} */ 89 const acc = {}; 90 91 tracks.forEach((track) => { 92 const parsed = parseURI(track.uri); 93 if (!parsed) return; 94 95 const id = accountId(parsed); 96 97 if (acc[id]) { 98 acc[id].tracks.push(track); 99 } else { 100 acc[id] = { 101 account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, 102 tracks: [track], 103 }; 104 } 105 }); 106 107 return acc; 108} 109 110/** 111 * @param {string[]} uris 112 * @returns {Record<string, { account: Account; uris: string[] }>} 113 */ 114export function groupUrisByAccount(uris) { 115 /** @type {Record<string, { account: Account; uris: string[] }>} */ 116 const acc = {}; 117 118 uris.forEach((uri) => { 119 const parsed = parseURI(uri); 120 if (!parsed) return; 121 122 const id = accountId(parsed); 123 124 if (acc[id]) { 125 acc[id].uris.push(uri); 126 } else { 127 acc[id] = { 128 account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, 129 uris: [uri], 130 }; 131 } 132 }); 133 134 return acc; 135} 136 137//////////////////////////////////////////// 138// DROPBOX API 139//////////////////////////////////////////// 140 141/** 142 * @param {string} accessToken 143 * @param {string} directoryPath 144 * @returns {Promise<Array<{ name: string; path_lower: string }> | null>} 145 */ 146export async function listFiles(accessToken, directoryPath) { 147 const apiPath = directoryPath === "/" ? "" : directoryPath; 148 const headers = { 149 "Authorization": `Bearer ${accessToken}`, 150 "Content-Type": "application/json", 151 }; 152 153 /** @type {Array<{ name: string; path_lower: string }>} */ 154 const entries = []; 155 let cursor = /** @type {string | null} */ (null); 156 let hasMore = true; 157 158 while (hasMore) { 159 const url = cursor 160 ? "https://api.dropboxapi.com/2/files/list_folder/continue" 161 : "https://api.dropboxapi.com/2/files/list_folder"; 162 163 const body = cursor 164 ? JSON.stringify({ cursor }) 165 : JSON.stringify({ path: apiPath, recursive: true, limit: 2000 }); 166 167 const resp = await fetch(url, { method: "POST", headers, body }); 168 if (!resp.ok) return null; 169 170 /** @type {{ entries: Array<{ ".tag": string; name: string; path_lower: string }>; has_more: boolean; cursor: string }} */ 171 const data = await resp.json(); 172 173 for (const entry of data.entries) { 174 if (entry[".tag"] === "file" && isAudioFile(entry.name)) { 175 entries.push({ name: entry.name, path_lower: entry.path_lower }); 176 } 177 } 178 179 hasMore = data.has_more; 180 cursor = data.cursor; 181 } 182 183 return entries; 184} 185 186/** 187 * @param {string} accessToken 188 * @param {string} filePath 189 * @returns {Promise<string | null>} 190 */ 191export async function getTemporaryLink(accessToken, filePath) { 192 const resp = await fetch( 193 "https://api.dropboxapi.com/2/files/get_temporary_link", 194 { 195 method: "POST", 196 headers: { 197 "Authorization": `Bearer ${accessToken}`, 198 "Content-Type": "application/json", 199 }, 200 body: JSON.stringify({ path: filePath }), 201 }, 202 ); 203 204 if (!resp.ok) return null; 205 206 /** @type {{ link: string }} */ 207 const data = await resp.json(); 208 return data.link ?? null; 209} 210 211/** 212 * @param {string} accessToken 213 * @returns {Promise<boolean>} 214 */ 215export async function checkAccess(accessToken) { 216 const resp = await fetch( 217 "https://api.dropboxapi.com/2/users/get_current_account", 218 { 219 method: "POST", 220 headers: { "Authorization": `Bearer ${accessToken}` }, 221 }, 222 ); 223 return resp.ok; 224} 225 226export const checkAccessCached = cachedConsult(checkAccess, (token) => token);