feat: add album art resolution with MusicBrainz and fallback chain

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 03:07:32 -04:00
parent 20daa86b52
commit 89b58477c9
8 changed files with 333 additions and 4 deletions

View File

@@ -3,5 +3,6 @@ package xyz.cottongin.radio247.audio
data class IcyMetadata(
val raw: String,
val title: String?,
val artist: String?
val artist: String?,
val streamUrl: String? = null
)

View File

@@ -66,18 +66,30 @@ class IcyParser(
private fun parseMetaString(raw: String): IcyMetadata {
val streamTitle = extractStreamTitle(raw)
val streamUrl = extractStreamUrl(raw)
if (streamTitle != null) {
val separatorIndex = streamTitle.indexOf(" - ")
if (separatorIndex >= 0) {
return IcyMetadata(
raw = raw,
artist = streamTitle.substring(0, separatorIndex).trim(),
title = streamTitle.substring(separatorIndex + 3).trim()
title = streamTitle.substring(separatorIndex + 3).trim(),
streamUrl = streamUrl
)
}
return IcyMetadata(raw = raw, title = streamTitle.trim(), artist = null)
return IcyMetadata(raw = raw, title = streamTitle.trim(), artist = null, streamUrl = streamUrl)
}
return IcyMetadata(raw = raw, title = null, artist = null)
return IcyMetadata(raw = raw, title = null, artist = null, streamUrl = streamUrl)
}
private fun extractStreamUrl(meta: String): String? {
val key = "StreamUrl='"
val start = meta.indexOf(key)
if (start < 0) return null
val valueStart = start + key.length
val end = meta.indexOf("';", valueStart)
if (end < 0) return null
return meta.substring(valueStart, end)
}
private fun extractStreamTitle(meta: String): String? {

View File

@@ -0,0 +1,110 @@
package xyz.cottongin.radio247.metadata
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.net.URLEncoder
class AlbumArtResolver(
private val client: OkHttpClient,
private val artCache: ArtCache = ArtCache(),
private val musicBrainzBaseUrl: String = "https://musicbrainz.org",
private val coverArtBaseUrl: String = "https://coverartarchive.org",
private val skipArtVerification: Boolean = false,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private var lastMusicBrainzCall = 0L
suspend fun resolve(
artist: String?,
title: String?,
icyStreamUrl: String?,
stationArtworkUrl: String?
): String? {
val cacheKey = "${artist.orEmpty()}-${title.orEmpty()}"
artCache.get(cacheKey)?.let { return it }
// 1. MusicBrainz — only if artist AND title are present
if (!artist.isNullOrBlank() && !title.isNullOrBlank()) {
val artUrl = queryMusicBrainz(artist, title)
if (artUrl != null) {
artCache.put(cacheKey, artUrl)
return artUrl
}
}
// 2. ICY StreamUrl — if it looks like an image URL
if (!icyStreamUrl.isNullOrBlank() && looksLikeImageUrl(icyStreamUrl)) {
artCache.put(cacheKey, icyStreamUrl)
return icyStreamUrl
}
// 3. Station default artwork (from #EXTIMG)
if (!stationArtworkUrl.isNullOrBlank()) {
return stationArtworkUrl
}
// 4. No art found
return null
}
private suspend fun queryMusicBrainz(artist: String, title: String): String? {
// Rate limit: 1 req/sec
val now = System.currentTimeMillis()
val elapsed = now - lastMusicBrainzCall
if (elapsed < 1100) {
delay(1100 - elapsed)
}
lastMusicBrainzCall = System.currentTimeMillis()
return withContext(ioDispatcher) {
try {
val query = "artist:\"${artist}\" AND recording:\"${title}\""
val encodedQuery = URLEncoder.encode(query, "UTF-8")
val url = "$musicBrainzBaseUrl/ws/2/recording?query=$encodedQuery&fmt=json&limit=1"
val request = Request.Builder()
.url(url)
.header("User-Agent", "Radio247/1.0 (personal use)")
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
}
val json = JSONObject(response.body?.string() ?: return@withContext null)
val recordings = json.optJSONArray("recordings") ?: return@withContext null
if (recordings.length() == 0) return@withContext null
val recording = recordings.getJSONObject(0)
val releases = recording.optJSONArray("releases") ?: return@withContext null
if (releases.length() == 0) return@withContext null
val releaseId = releases.getJSONObject(0).getString("id")
val artUrl = "$coverArtBaseUrl/release/$releaseId/front-250"
// Verify the art URL returns 200 (Cover Art Archive returns 404 for missing art)
if (skipArtVerification) return@withContext artUrl
val artRequest = Request.Builder().url(artUrl).head().build()
val artResponse = client.newCall(artRequest).execute()
artResponse.close()
if (artResponse.isSuccessful || artResponse.code == 307) artUrl else null
} catch (e: Exception) {
null
}
}
}
private fun looksLikeImageUrl(url: String): Boolean {
val lower = url.lowercase()
return lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
lower.endsWith(".png") || lower.endsWith(".webp") ||
lower.endsWith(".gif") || lower.contains("image")
}
}

View File

@@ -0,0 +1,17 @@
package xyz.cottongin.radio247.metadata
class ArtCache(private val maxSize: Int = 500) {
private val cache = object : LinkedHashMap<String, String>(maxSize, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean {
return size > maxSize
}
}
@Synchronized
fun get(key: String): String? = cache[key]
@Synchronized
fun put(key: String, url: String) {
cache[key] = url
}
}