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

Configure Feed

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

at v4 159 lines 3.7 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['artwork']} 23 */ 24export async function artwork(_uri) { 25 return null; 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) { 38 return { supported: false, reason: "Invalid Icecast URI" }; 39 } 40 41 const available = await consultStreamCached(fileUriOrScheme); 42 return { supported: true, consult: available }; 43} 44 45/** 46 * @type {Actions['detach']} 47 */ 48export async function detach(args) { 49 return detachUtil({ 50 ...args, 51 52 inputScheme: SCHEME, 53 handleFileUri: ({ fileURI, tracks }) => { 54 const result = parseURI(fileURI); 55 if (!result) return tracks; 56 57 const groups = groupTracksByHost(tracks); 58 delete groups[result.host]; 59 60 return Object.values(groups).map((g) => g.tracks).flat(1); 61 }, 62 }); 63} 64 65/** 66 * @type {Actions['groupConsult']} 67 */ 68export async function groupConsult(uris) { 69 const groups = groupUrisByHost(uris); 70 71 const promises = Object.entries(groups).map( 72 async ([_hostId, { host, uris }]) => { 73 const testUri = uris[0]; 74 const available = testUri ? await consultStreamCached(testUri) : false; 75 76 /** @type {ConsultGrouping} */ 77 const grouping = available 78 ? { available, scheme: SCHEME, uris } 79 : { available, reason: "Stream unreachable", scheme: SCHEME, uris }; 80 81 return { 82 key: groupKey(SCHEME, host), 83 grouping, 84 }; 85 }, 86 ); 87 88 const entries = (await Promise.all(promises)).map((entry) => [ 89 entry.key, 90 entry.grouping, 91 ]); 92 93 return Object.fromEntries(entries); 94} 95 96/** 97 * @type {Actions['list']} 98 */ 99export async function list(cachedTracks = []) { 100 const refreshed = await Promise.all( 101 cachedTracks.map(async (track) => { 102 const parsed = parseURI(track.uri); 103 if (!parsed) return track; 104 105 const metadata = await fetchMetadata(parsed.streamUrl); 106 if (!metadata) return track; 107 108 return { 109 ...track, 110 kind: /** @type {"stream"} */ ("stream"), 111 tags: { 112 ...track.tags, 113 title: metadata.name ?? track.tags?.title, 114 genres: metadata.genre ? [metadata.genre] : track.tags?.genres, 115 }, 116 stats: { 117 ...track.stats, 118 // IcyMetadata.bitrate is in kbps; stats.bitrate is in bps 119 bitrate: metadata.bitrate 120 ? metadata.bitrate * 1000 121 : track.stats?.bitrate, 122 }, 123 }; 124 }), 125 ); 126 127 return refreshed; 128} 129 130/** 131 * @type {Actions['resolve']} 132 */ 133export async function resolve({ uri }) { 134 const parsed = parseURI(uri); 135 if (!parsed) return undefined; 136 137 const expiresInSeconds = 60 * 60 * 24 * 365; // 1 year 138 const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 139 140 return { 141 url: parsed.streamUrl, 142 expiresAt: expiresAtSeconds, 143 }; 144} 145 146//////////////////////////////////////////// 147// ⚡️ 148//////////////////////////////////////////// 149 150ostiary((context) => { 151 rpc(context, { 152 artwork, 153 consult, 154 detach, 155 groupConsult, 156 list, 157 resolve, 158 }); 159});