A music player that connects to your cloud/distributed storage.
at v4 291 lines 7.7 kB view raw
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});