A music player that connects to your cloud/distributed storage.
at v4 151 lines 3.6 kB view raw
1import { ostiary, rpc } from "~/common/worker.js"; 2import { detach as detachUtil, groupKey } from "~/components/input/common.js"; 3 4import { 5 consultStreamCached, 6 fetchMetadata, 7 groupTracksByHost, 8 groupUrisByHost, 9 parseURI, 10} from "./common.js"; 11import { SCHEME } from "./constants.js"; 12 13/** 14 * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; 15 */ 16 17//////////////////////////////////////////// 18// ACTIONS 19//////////////////////////////////////////// 20 21/** 22 * @type {Actions['consult']} 23 */ 24export async function consult(fileUriOrScheme) { 25 if (!fileUriOrScheme.includes(":")) { 26 return { supported: true, consult: "undetermined" }; 27 } 28 29 const parsed = parseURI(fileUriOrScheme); 30 if (!parsed) { 31 return { supported: false, reason: "Invalid Icecast URI" }; 32 } 33 34 const available = await consultStreamCached(fileUriOrScheme); 35 return { supported: true, consult: available }; 36} 37 38/** 39 * @type {Actions['detach']} 40 */ 41export async function detach(args) { 42 return detachUtil({ 43 ...args, 44 45 inputScheme: SCHEME, 46 handleFileUri: ({ fileURI, tracks }) => { 47 const result = parseURI(fileURI); 48 if (!result) return tracks; 49 50 const groups = groupTracksByHost(tracks); 51 delete groups[result.host]; 52 53 return Object.values(groups).map((g) => g.tracks).flat(1); 54 }, 55 }); 56} 57 58/** 59 * @type {Actions['groupConsult']} 60 */ 61export async function groupConsult(uris) { 62 const groups = groupUrisByHost(uris); 63 64 const promises = Object.entries(groups).map( 65 async ([_hostId, { host, uris }]) => { 66 const testUri = uris[0]; 67 const available = testUri ? await consultStreamCached(testUri) : false; 68 69 /** @type {ConsultGrouping} */ 70 const grouping = available 71 ? { available, scheme: SCHEME, uris } 72 : { available, reason: "Stream unreachable", scheme: SCHEME, uris }; 73 74 return { 75 key: groupKey(SCHEME, host), 76 grouping, 77 }; 78 }, 79 ); 80 81 const entries = (await Promise.all(promises)).map((entry) => [ 82 entry.key, 83 entry.grouping, 84 ]); 85 86 return Object.fromEntries(entries); 87} 88 89/** 90 * @type {Actions['list']} 91 */ 92export async function list(cachedTracks = []) { 93 const refreshed = await Promise.all( 94 cachedTracks.map(async (track) => { 95 const parsed = parseURI(track.uri); 96 if (!parsed) return track; 97 98 const metadata = await fetchMetadata(parsed.streamUrl); 99 if (!metadata) return track; 100 101 return { 102 ...track, 103 kind: /** @type {"stream"} */ ("stream"), 104 tags: { 105 ...track.tags, 106 title: metadata.name ?? track.tags?.title, 107 genres: metadata.genre ? [metadata.genre] : track.tags?.genres, 108 }, 109 stats: { 110 ...track.stats, 111 // IcyMetadata.bitrate is in kbps; stats.bitrate is in bps 112 bitrate: metadata.bitrate 113 ? metadata.bitrate * 1000 114 : track.stats?.bitrate, 115 }, 116 }; 117 }), 118 ); 119 120 return refreshed; 121} 122 123/** 124 * @type {Actions['resolve']} 125 */ 126export async function resolve({ uri }) { 127 const parsed = parseURI(uri); 128 if (!parsed) return undefined; 129 130 const expiresInSeconds = 60 * 60 * 24 * 365; // 1 year 131 const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 132 133 return { 134 url: parsed.streamUrl, 135 expiresAt: expiresAtSeconds, 136 }; 137} 138 139//////////////////////////////////////////// 140// ⚡️ 141//////////////////////////////////////////// 142 143ostiary((context) => { 144 rpc(context, { 145 consult, 146 detach, 147 groupConsult, 148 list, 149 resolve, 150 }); 151});