A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 320 lines 8.4 kB view raw
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});