A music player that connects to your cloud/distributed storage.
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);