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