A music player that connects to your cloud/distributed storage.
at main 4.5 kB view raw
1// 2// Album Covers 3// (◕‿◕✿) 4 5import * as Uint8arrays from "uint8arrays" 6 7import * as processing from "./processing" 8import { type App } from "./elm/types" 9import { transformUrl } from "../urls" 10import { toCache } from "./common" 11import { type CoverPrep } from "../common" 12 13 14// 🌳 15 16 17type CoverPrepWithUrls = CoverPrep & { 18 trackGetUrl: string 19 trackHeadUrl: string 20} 21 22 23 24// 🏔️ 25 26 27let artworkQueue: CoverPrep[] = [] 28let app: App 29 30 31 32// 🚀 33 34 35export function init(a: App) { 36 app = a 37 38 app.ports.provideArtworkTrackUrls.subscribe(provideArtworkTrackUrls) 39} 40 41 42 43// PORTS 44 45 46function provideArtworkTrackUrls(prep: CoverPrepWithUrls) { 47 find(prep).then(blob => { 48 return toCache(`coverCache.${prep.cacheKey}`, blob).then(_ => blob) 49 }) 50 .then((blob: Blob) => { 51 const url = URL.createObjectURL(blob) 52 53 self.postMessage({ 54 tag: "GOT_CACHED_COVER", 55 data: { imageType: blob.type, key: prep.cacheKey, url: url }, 56 error: null 57 }) 58 }) 59 .catch(err => { 60 if (err === "No artwork found") { 61 // Indicate that we've tried to find artwork, 62 // so that we don't try to find it each time we launch the app. 63 return toCache(`coverCache.${prep.cacheKey}`, "TRIED") 64 65 } else { 66 // Something went wrong 67 console.error(err) 68 return toCache(`coverCache.${prep.cacheKey}`, "TRIED") 69 70 } 71 }) 72 .catch(() => { 73 console.warn("Failed to download artwork for ", prep) 74 }) 75 .finally(shiftQueue) 76} 77 78 79 80// 🛠️ 81 82 83export function download(list: CoverPrep[]) { 84 const exe = !artworkQueue[0] 85 artworkQueue = artworkQueue.concat(list) 86 if (exe) shiftQueue() 87} 88 89 90function shiftQueue() { 91 const next = artworkQueue.shift() 92 93 if (next) { 94 app.ports.makeArtworkTrackUrls.send(next) 95 } else { 96 self.postMessage({ 97 action: "FINISHED_DOWNLOADING_ARTWORK", 98 data: null 99 }) 100 } 101} 102 103 104 105// ㊙️ 106 107 108const REJECT = () => Promise.reject("No artwork found") 109 110 111function decodeCacheKey(cacheKey: string) { 112 return Uint8arrays.toString( 113 Uint8arrays.fromString(cacheKey, "base64"), 114 "utf8" 115 ) 116} 117 118 119function find(prep: CoverPrepWithUrls) { 120 return findUsingTags(prep) 121 .then(a => a ? a : findUsingMusicBrainz(prep)) 122 .then(a => a ? a : findUsingLastFm(prep)) 123 .then(a => a ? a : REJECT()) 124 .then(a => a.type.startsWith("image/") ? a : REJECT()) 125} 126 127 128 129// 1. TAGS 130 131 132async function findUsingTags(prep: CoverPrepWithUrls) { 133 return Promise.all( 134 [ 135 transformUrl(prep.trackHeadUrl, app), 136 transformUrl(prep.trackGetUrl, app) 137 ] 138 139 ).then(([ headUrl, getUrl ]) => processing.getTags( 140 headUrl, 141 getUrl, 142 prep.trackFilename, 143 { covers: true } 144 145 )).then(tags => { 146 return tags?.picture 147 ? new Blob([ tags.picture.data ], { type: tags.picture.format }) 148 : null 149 150 }) 151} 152 153 154 155// 2. MUSIC BRAINZ 156 157 158function findUsingMusicBrainz(prep: CoverPrepWithUrls) { 159 if (!navigator.onLine) return null 160 161 const parts = decodeCacheKey(prep.cacheKey).split(" --- ") 162 const artist = parts[ 0 ] 163 const album = parts[ 1 ] || parts[ 0 ] 164 165 const query = `release:"${album}"` + (prep.variousArtists === "t" ? `` : ` AND artist:"${artist}"`) 166 const encodedQuery = encodeURIComponent(query) 167 168 return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 169 .then(r => r.json()) 170 .then(r => musicBrainzCover(r.releases)) 171} 172 173 174function musicBrainzCover(remainingReleases) { 175 const release = remainingReleases[ 0 ] 176 if (!release) return null 177 178 return fetch( 179 `https://coverartarchive.org/release/${release.id}/front-500` 180 ).then( 181 r => r.blob() 182 ).then( 183 r => r && r.type.startsWith("image/") 184 ? r 185 : musicBrainzCover(remainingReleases.slice(1)) 186 ).catch( 187 () => musicBrainzCover(remainingReleases.slice(1)) 188 ) 189} 190 191 192 193// 3. LAST FM 194 195 196function findUsingLastFm(prep: CoverPrepWithUrls) { 197 if (!navigator.onLine) return null 198 199 const query = encodeURIComponent( 200 decodeCacheKey(prep.cacheKey).replace(" --- ", " ") 201 ) 202 203 return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`) 204 .then(r => r.json()) 205 .then(r => lastFmCover(r.results.albummatches.album)) 206} 207 208 209function lastFmCover(remainingMatches) { 210 const album = remainingMatches[ 0 ] 211 const url = album ? album.image[ album.image.length - 1 ][ "#text" ] : null 212 213 return url && url !== "" 214 ? fetch(url) 215 .then(r => r.blob()) 216 .catch(_ => lastFmCover(remainingMatches.slice(1))) 217 : album && lastFmCover(remainingMatches.slice(1)) 218}