feat: add album art resolution with MusicBrainz and fallback chain
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user