forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import * as URI from "fast-uri";
2import QS from "query-string";
3
4import { SCHEME } from "./constants.js";
5import { cachedConsult } from "~/components/input/common.js";
6import { safeDecodeURIComponent } from "~/common/utils.js";
7import { SubsonicAPIWithoutFetch } from "./class.js";
8
9/**
10 * @import {Child} from "subsonic-api"
11 * @import {Track} from "~/definitions/types.d.ts";
12 * @import {Server} from "./types.d.ts";
13 */
14
15/**
16 * @param {Child["type"]} type
17 * @returns {Track["kind"]}
18 */
19export function autoTypeToTrackKind(type) {
20 switch (type?.toLowerCase()) {
21 case "audiobook":
22 return "audiobook";
23
24 case "music":
25 return "music";
26
27 case "podcast":
28 return "podcast";
29
30 default:
31 return "miscellaneous";
32 }
33}
34
35/**
36 * @param {Server} server
37 * @param {{ songId: string; path?: string }} [args]
38 */
39export function buildURI(server, args) {
40 return URI.serialize({
41 scheme: SCHEME,
42 userinfo: server.apiKey
43 ? encodeURIComponent(server.apiKey)
44 : `${encodeURIComponent(server.username || "")}:${
45 encodeURIComponent(server.password || "")
46 }`,
47 host: server.host.replace(/^https?:\/\//, ""),
48 path: args?.path,
49 query: QS.stringify({
50 songId: args?.songId,
51 tls: server.tls ? "t" : "f",
52 }),
53 });
54}
55
56/**
57 * @param {Server} server
58 */
59export async function consultServer(server) {
60 const client = createClient(server);
61 const resp = await client.ping().catch(() => undefined);
62 return resp?.status?.toLowerCase() === "ok";
63}
64
65export const consultServerCached = cachedConsult(consultServer, serverId);
66
67/**
68 * @param {Server} server
69 */
70export function createClient(server) {
71 return new SubsonicAPIWithoutFetch({
72 url: `http${server.tls ? "s" : ""}://${server.host}`,
73 auth: server.apiKey ? { apiKey: safeDecodeURIComponent(server.apiKey) } : {
74 username: safeDecodeURIComponent(server.username || ""),
75 password: safeDecodeURIComponent(server.password || ""),
76 },
77 });
78}
79
80/**
81 * @param {Track[]} tracks
82 */
83export function groupTracksByServer(tracks) {
84 /** @type {Record<string, { server: Server; tracks: Track[] }>} */
85 const acc = {};
86
87 tracks.forEach((track) => {
88 const parsed = parseURI(track.uri);
89 if (!parsed) return;
90
91 const id = serverId(parsed.server);
92
93 if (acc[id]) {
94 acc[id].tracks.push(track);
95 } else {
96 acc[id] = { server: parsed.server, tracks: [track] };
97 }
98 });
99
100 return acc;
101}
102
103/**
104 * @param {string[]} uris
105 */
106export function groupUrisByServer(uris) {
107 /** @type {Record<string, { server: Server; uris: string[] }>} */
108 const acc = {};
109
110 uris.forEach((uri) => {
111 const parsed = parseURI(uri);
112 if (!parsed) return;
113
114 const id = serverId(parsed.server);
115
116 if (acc[id]) {
117 acc[id].uris.push(uri);
118 } else {
119 acc[id] = { server: parsed.server, uris: [uri] };
120 }
121 });
122
123 return acc;
124}
125
126/**
127 * Parse an opensubsonic URI.
128 *
129 * ```
130 * opensubsonic://username:password@server-host:port/path?tls=f
131 * ```
132 *
133 * @param {string} uriString
134 * @returns {{ path: string | undefined; server: Server; songId: string | undefined } | undefined}
135 */
136export function parseURI(uriString) {
137 const uri = URI.parse(uriString);
138 if (uri.scheme !== SCHEME) return undefined;
139 if (!uri.host) return undefined;
140
141 let apiKey = undefined;
142 let username = undefined;
143 let password = undefined;
144
145 if (uri.userinfo?.includes(":")) {
146 // Username + Password
147 const [u, p] = uri.userinfo.split(":");
148 username = u;
149 password = p;
150 if (!username || !password) return undefined;
151 } else {
152 // API key
153 apiKey = uri.userinfo;
154 if (!apiKey) return undefined;
155 }
156
157 const qs = QS.parse(uri.query || "");
158
159 const server = {
160 apiKey,
161 host: uri.port ? `${uri.host}:${uri.port}` : uri.host,
162 password,
163 tls: qs.tls === "f" ? false : true,
164 username,
165 };
166
167 const path = uri.path;
168 const songId = typeof qs.songId === "string" ? qs.songId : undefined;
169
170 return { path, server, songId };
171}
172
173/**
174 * @param {Track[]} tracks
175 */
176export function serversFromTracks(tracks) {
177 /** @type {Record<string, Server>} */
178 const acc = {};
179
180 tracks.forEach((track) => {
181 const parsed = parseURI(track.uri);
182 if (!parsed) return;
183
184 const id = serverId(parsed.server);
185 if (acc[id]) return;
186
187 acc[id] = parsed.server;
188 });
189
190 return acc;
191}
192
193/**
194 * @param {Server} server
195 */
196export function serverId(server) {
197 const parts = {
198 host: server.host,
199 query: `tls=${server.tls ? "t" : "f"}`,
200 };
201
202 const uri = server.apiKey
203 ? URI.serialize({ ...parts, userinfo: server.apiKey })
204 : URI.serialize({
205 ...parts,
206 userinfo: `${server.username}:${server.password}`,
207 });
208
209 return btoa(uri);
210}