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";
3
4import { SCHEME } from "./constants.js";
5import {
6 removeUndefinedValuesFromRecord,
7 safeDecodeURIComponent,
8} from "~/common/utils.js";
9import { detach as detachUtil, groupKey } from "../common.js";
10import {
11 autoTypeToTrackKind,
12 buildURI,
13 consultServerCached,
14 createClient,
15 groupTracksByServer,
16 groupUrisByServer,
17 parseURI,
18 serverId,
19} from "./common.js";
20
21/**
22 * @import {Child, SubsonicAPI} from "subsonic-api"
23 * @import {Track} from "~/definitions/types.d.ts";
24 * @import {ConsultGrouping, InputActions as Actions} from "~/components/input/types.d.ts";
25 * @import {Server} from "./types.d.ts"
26 */
27
28////////////////////////////////////////////
29// ACTIONS
30////////////////////////////////////////////
31
32/**
33 * @type {Actions['artwork']}
34 */
35export async function artwork(uri) {
36 const parsed = parseURI(uri);
37 if (!parsed?.songId) return null;
38
39 const client = createClient(parsed.server);
40 const response = await client.getCoverArt({ id: parsed.songId }).catch(
41 () => null,
42 );
43 if (!response?.ok) return null;
44 if (!response.headers.get("content-type")?.startsWith("image/")) return null;
45
46 return new Uint8Array(await response.arrayBuffer());
47}
48
49/**
50 * @type {Actions['consult']}
51 */
52export async function consult(fileUriOrScheme) {
53 if (!fileUriOrScheme.includes(":")) {
54 return { supported: true, consult: "undetermined" };
55 }
56
57 const parsed = parseURI(fileUriOrScheme);
58 if (!parsed) return { supported: true, consult: "undetermined" };
59
60 const consult = await consultServerCached(parsed.server);
61 return { supported: true, consult };
62}
63
64/**
65 * @type {Actions['detach']}
66 */
67export async function detach(args) {
68 return detachUtil({
69 ...args,
70
71 inputScheme: SCHEME,
72 handleFileUri: ({ fileURI, tracks }) => {
73 const result = parseURI(fileURI);
74 if (!result) return tracks;
75
76 const sid = serverId(result.server);
77 const groups = groupTracksByServer(tracks);
78
79 delete groups[sid];
80
81 return Object.values(groups).map((a) => a.tracks).flat(1);
82 },
83 });
84}
85
86/**
87 * @type {Actions['groupConsult']}
88 */
89export async function groupConsult(uris) {
90 const groups = groupUrisByServer(uris);
91
92 const promises = Object.entries(groups).map(
93 async ([serverId, { server, uris }]) => {
94 const available = await consultServerCached(server);
95
96 /** @type {ConsultGrouping} */
97 const grouping = available
98 ? { available, scheme: SCHEME, uris }
99 : { available, reason: "Server ping failed", scheme: SCHEME, uris };
100
101 return {
102 key: groupKey(SCHEME, serverId),
103 grouping,
104 };
105 },
106 );
107
108 const entries = (await Promise.all(promises)).map((
109 entry,
110 ) => [entry.key, entry.grouping]);
111
112 return Object.fromEntries(entries);
113}
114
115/**
116 * @type {Actions['list']}
117 */
118export async function list(cachedTracks = []) {
119 /** @type {Record<string, Record<string, Track>>} */
120 const cache = {};
121
122 /** @type {Record<string, Server>} */
123 const servers = {};
124
125 cachedTracks.forEach((t) => {
126 const parsed = parseURI(t.uri);
127 if (!parsed || parsed.path === undefined) return;
128
129 const sid = serverId(parsed.server);
130 servers[sid] = parsed.server;
131
132 const path = safeDecodeURIComponent(parsed.path);
133 cache[sid] ??= {};
134 cache[sid][path] = t;
135 });
136
137 /**
138 * @param {SubsonicAPI} client
139 * @returns {Promise<Child[]>}
140 */
141 async function search(client, offset = 0) {
142 const result = await client.search3({
143 query: "",
144 artistCount: 0,
145 albumCount: 0,
146 songCount: 1000,
147 songOffset: offset,
148 });
149
150 const songs = result.searchResult3.song || [];
151
152 if (songs.length === 1000) {
153 const moreSongs = await search(client, offset + 1000);
154 return [...songs, ...moreSongs];
155 }
156
157 return songs;
158 }
159
160 const promises = Object.values(servers).map(async (server) => {
161 const client = createClient(server);
162 const sid = serverId(server);
163 const list = await search(client, 0);
164
165 let tracks = list
166 .filter((song) => !song.isVideo)
167 .map((song) => {
168 const path = song.path
169 ? song.path.startsWith("/") ? song.path : `/${song.path}`
170 : undefined;
171
172 const fromCache = path ? cache[sid]?.[path] : undefined;
173 if (fromCache) return fromCache;
174
175 const now = new Date().toISOString();
176
177 /** @type {Track} */
178 const track = {
179 $type: "sh.diffuse.output.track",
180 id: TID.now(),
181 createdAt: now,
182 updatedAt: now,
183 kind: autoTypeToTrackKind(song.type),
184 uri: buildURI(server, { songId: song.id, path }),
185
186 stats: removeUndefinedValuesFromRecord({
187 albumGain: undefined,
188 bitrate: song.bitRate ? Math.round(song.bitRate * 1000) : undefined,
189 bitsPerSample: undefined,
190 codec: undefined,
191 container: undefined,
192 duration: song.duration != null
193 ? Math.round(song.duration * 1000)
194 : undefined,
195 lossless: undefined,
196 numberOfChannels: undefined,
197 sampleRate: undefined,
198 trackGain: undefined,
199 }),
200 tags: removeUndefinedValuesFromRecord({
201 album: song.album,
202 albumartist: song.albumArtists?.[0]?.name,
203 albumartists: song.albumArtists?.map((a) => a.name),
204 albumartistsort: song.albumArtists?.[0]?.sortName,
205 albumsort: undefined,
206 arranger: undefined,
207 artist: song.artist ?? song.displayArtist,
208 artists: undefined,
209 artistsort: undefined,
210 asin: undefined,
211 averageLevel: undefined,
212 barcode: undefined,
213 bpm: song.bpm,
214 catalognumbers: undefined,
215 compilation: undefined,
216 composers: song.displayComposer
217 ? [song.displayComposer]
218 : undefined,
219 composersort: undefined,
220 conductors: undefined,
221 date: undefined,
222 disc: {
223 no: song.discNumber || 1,
224 },
225 djmixers: undefined,
226 engineers: undefined,
227 gapless: undefined,
228 genres: song.genres,
229 isrc: undefined,
230 labels: undefined,
231 lyricists: undefined,
232 media: undefined,
233 mixers: undefined,
234 moods: song.moods,
235 originaldate: undefined,
236 originalyear: undefined,
237 peakLevel: undefined,
238 producers: undefined,
239 publishers: undefined,
240 releasecountry: undefined,
241 releasedate: undefined,
242 releasestatus: undefined,
243 releasetypes: undefined,
244 remixers: undefined,
245 technicians: undefined,
246 title: song.title ?? "Unknown",
247 titlesort: undefined,
248 track: {
249 no: song.track ?? 1,
250 of: song.size,
251 },
252 work: undefined,
253 writers: undefined,
254 year: song.year,
255 }),
256 };
257
258 return track;
259 });
260
261 // If a server didn't have any tracks,
262 // keep a placeholder track so the server gets
263 // picked up as a source.
264 if (!tracks.length) {
265 const now = new Date().toISOString();
266
267 tracks = [{
268 $type: "sh.diffuse.output.track",
269 id: TID.now(),
270 createdAt: now,
271 updatedAt: now,
272 kind: "placeholder",
273 uri: buildURI(server),
274 }];
275 }
276
277 return tracks;
278 });
279
280 const tracks = (await Promise.all(promises)).flat(1);
281 return tracks;
282}
283
284/**
285 * @type {Actions['resolve']}
286 */
287export async function resolve({ uri }) {
288 const parsed = parseURI(uri);
289 if (!parsed) return undefined;
290
291 const client = createClient(parsed.server);
292 const songId = parsed.songId;
293 if (!songId) return undefined;
294
295 const url = await client
296 .stream({
297 id: songId,
298 format: "raw",
299 })
300 .then((a) => a.url);
301
302 return { expiresAt: Infinity, url };
303}
304
305////////////////////////////////////////////
306// ⚡️
307////////////////////////////////////////////
308
309ostiary((context) => {
310 // Setup RPC
311
312 rpc(context, {
313 artwork,
314 consult,
315 detach,
316 groupConsult,
317 list,
318 resolve,
319 });
320});