A music player that connects to your cloud/distributed storage.
1import * as TID from "@atcute/tid";
2import { ostiary, rpc } from "~/common/worker.js";
3import {
4 detach as detachUtil,
5 groupKey,
6} from "~/components/input/common.js";
7import {
8 accountId,
9 accountsFromTracks,
10 buildURI,
11 checkAccessCached,
12 getTemporaryLink,
13 groupTracksByAccount,
14 groupUrisByAccount,
15 listFiles,
16 parseURI,
17} from "./common.js";
18import { SCHEME } from "./constants.js";
19
20/**
21 * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts";
22 * @import { Track } from "~/definitions/types.d.ts"
23 */
24
25////////////////////////////////////////////
26// ACTIONS
27////////////////////////////////////////////
28
29/**
30 * @type {Actions['artwork']}
31 */
32export async function artwork(_uri) {
33 return null;
34}
35
36/**
37 * @type {Actions['consult']}
38 */
39export async function consult(fileUriOrScheme) {
40 if (!fileUriOrScheme.includes(":")) {
41 return { supported: true, consult: "undetermined" };
42 }
43
44 const parsed = parseURI(fileUriOrScheme);
45 if (!parsed) return { supported: true, consult: "undetermined" };
46
47 const accessible = await checkAccessCached(parsed.accessToken);
48 return { supported: true, consult: accessible };
49}
50
51/**
52 * @type {Actions['detach']}
53 */
54export async function detach(args) {
55 return detachUtil({
56 ...args,
57 inputScheme: SCHEME,
58 handleFileUri: ({ fileURI, tracks }) => {
59 const result = parseURI(fileURI);
60 if (!result) return tracks;
61
62 const id = accountId(result);
63 const groups = groupTracksByAccount(tracks);
64 delete groups[id];
65
66 return Object.values(groups).map((g) => g.tracks).flat(1);
67 },
68 });
69}
70
71/**
72 * @type {Actions['groupConsult']}
73 */
74export async function groupConsult(uris) {
75 const groups = groupUrisByAccount(uris);
76
77 const promises = Object.entries(groups).map(
78 async ([id, { account, uris }]) => {
79 const available = await checkAccessCached(account.accessToken);
80
81 /** @type {ConsultGrouping} */
82 const grouping = available
83 ? { available, scheme: SCHEME, uris }
84 : { available, reason: "Dropbox access denied", scheme: SCHEME, uris };
85
86 return { key: groupKey(SCHEME, id), grouping };
87 },
88 );
89
90 const entries = (await Promise.all(promises)).map((e) => [e.key, e.grouping]);
91 return Object.fromEntries(entries);
92}
93
94/**
95 * @type {Actions['list']}
96 */
97export async function list(cachedTracks = []) {
98 const accounts = accountsFromTracks(cachedTracks);
99
100 /** @type {Record<string, Record<string, Track>>} */
101 const cache = {};
102
103 cachedTracks.forEach((t) => {
104 const parsed = parseURI(t.uri);
105 if (!parsed || t.kind === "placeholder") return;
106
107 const id = accountId(parsed);
108 if (!cache[id]) cache[id] = {};
109 cache[id][parsed.path] = t;
110 });
111
112 const promises = Object.values(accounts).map(async (account) => {
113 const id = accountId(account);
114 const files = await listFiles(account.accessToken, account.directoryPath);
115
116 if (!files) {
117 const existing = cachedTracks.find((t) => {
118 const p = parseURI(t.uri);
119 return p && accountId(p) === id && t.kind === "placeholder";
120 });
121
122 const now = new Date().toISOString();
123
124 return [/** @type {Track} */ ({
125 $type: "sh.diffuse.output.track",
126 id: existing?.id ?? TID.now(),
127 createdAt: existing?.createdAt ?? now,
128 updatedAt: now,
129 kind: "placeholder",
130 uri: buildURI(account),
131 })];
132 }
133
134 if (!files.length) {
135 const now = new Date().toISOString();
136
137 return [/** @type {Track} */ ({
138 $type: "sh.diffuse.output.track",
139 id: TID.now(),
140 createdAt: now,
141 updatedAt: now,
142 kind: "placeholder",
143 uri: buildURI(account),
144 })];
145 }
146
147 return files.map((file) => {
148 const uri = buildURI(account, file.path_lower);
149 const cached = cache[id]?.[file.path_lower];
150 const now = new Date().toISOString();
151
152 /** @type {Track} */
153 const track = {
154 $type: "sh.diffuse.output.track",
155 id: cached?.id ?? TID.now(),
156 createdAt: cached?.createdAt ?? now,
157 updatedAt: cached?.updatedAt ?? now,
158 stats: cached?.stats,
159 tags: cached?.tags,
160 uri,
161 };
162
163 return track;
164 });
165 });
166
167 return (await Promise.all(promises)).flat(1);
168}
169
170/**
171 * @type {Actions['resolve']}
172 */
173export async function resolve({ uri }) {
174 const parsed = parseURI(uri);
175 if (!parsed || parsed.path === "/") return undefined;
176
177 const link = await getTemporaryLink(parsed.accessToken, parsed.path);
178 if (!link) return undefined;
179
180 // Dropbox temporary links expire after 4 hours
181 const expiresAt = Math.round(Date.now() / 1000) + 4 * 60 * 60;
182 return { url: link, expiresAt };
183}
184
185////////////////////////////////////////////
186// ⚡️
187////////////////////////////////////////////
188
189ostiary((context) => {
190 rpc(context, {
191 artwork,
192 consult,
193 detach,
194 groupConsult,
195 list,
196 resolve,
197 });
198});