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