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}