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

@@ -63,6 +63,8 @@ dependencies {
implementation(libs.coroutines.android)
implementation(libs.media.session)
implementation(libs.material)
implementation(libs.coil.compose)
implementation(libs.coil.network)
testImplementation(libs.junit)
testImplementation(libs.mockk)
@@ -70,4 +72,5 @@ dependencies {
testImplementation(libs.turbine)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.room.testing)
testImplementation(libs.json)
}

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
}
}

View File

@@ -234,6 +234,7 @@ class IcyParserTest {
assertEquals(1, metadataEvents.size)
assertEquals("Song", metadataEvents[0].title)
assertEquals("http://art.jpg", metadataEvents[0].streamUrl)
assertTrue(
metadataEvents[0].raw.contains("StreamTitle") &&
metadataEvents[0].raw.contains("StreamUrl")

View File

@@ -0,0 +1,181 @@
package xyz.cottongin.radio247.metadata
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AlbumArtResolverTest {
private lateinit var server: MockWebServer
private lateinit var client: OkHttpClient
private lateinit var resolver: AlbumArtResolver
@Before
fun setUp() {
server = MockWebServer()
server.start()
val baseUrl = server.url("/").toString().trimEnd('/')
client = OkHttpClient.Builder().build()
resolver = AlbumArtResolver(
client = client,
artCache = ArtCache(maxSize = 100),
musicBrainzBaseUrl = baseUrl,
coverArtBaseUrl = baseUrl
)
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun returns_cached_url_on_cache_hit() = runTest {
val artCache = ArtCache(maxSize = 100)
artCache.put("Artist-Title", "https://cached.example/art.jpg")
val r = AlbumArtResolver(client, artCache)
val result = r.resolve(
artist = "Artist",
title = "Title",
icyStreamUrl = null,
stationArtworkUrl = null
)
assertEquals("https://cached.example/art.jpg", result)
}
@Test
fun queries_musicbrainz_when_artist_and_title_present() = runTest {
val mbJson = """
{
"recordings": [{
"id": "rec-123",
"releases": [{"id": "rel-456"}]
}]
}
""".trimIndent()
server.enqueue(
MockResponse()
.setBody(mbJson)
.addHeader("Content-Type", "application/json")
)
val baseUrl = server.url("/").toString().trimEnd('/')
val testResolver = AlbumArtResolver(
client = client,
musicBrainzBaseUrl = baseUrl,
coverArtBaseUrl = baseUrl,
skipArtVerification = true
)
val result = testResolver.resolve(
artist = "Test Artist",
title = "Test Title",
icyStreamUrl = null,
stationArtworkUrl = null
)
assertEquals("$baseUrl/release/rel-456/front-250", result)
}
@Test
fun skips_musicbrainz_when_artist_is_null() = runTest {
val result = resolver.resolve(
artist = null,
title = "Some Title",
icyStreamUrl = null,
stationArtworkUrl = "https://station.com/default.png"
)
assertEquals("https://station.com/default.png", result)
}
@Test
fun skips_musicbrainz_when_title_is_null() = runTest {
val result = resolver.resolve(
artist = "Some Artist",
title = null,
icyStreamUrl = null,
stationArtworkUrl = "https://station.com/art.png"
)
assertEquals("https://station.com/art.png", result)
}
@Test
fun falls_through_to_icy_stream_url() = runTest {
val result = resolver.resolve(
artist = null,
title = null,
icyStreamUrl = "https://stream.example/cover.jpg",
stationArtworkUrl = "https://station.com/fallback.png"
)
assertEquals("https://stream.example/cover.jpg", result)
}
@Test
fun falls_through_to_station_artwork() = runTest {
val result = resolver.resolve(
artist = null,
title = null,
icyStreamUrl = null,
stationArtworkUrl = "https://station.com/default.png"
)
assertEquals("https://station.com/default.png", result)
}
@Test
fun returns_null_when_nothing_found() = runTest {
val result = resolver.resolve(
artist = null,
title = null,
icyStreamUrl = null,
stationArtworkUrl = null
)
assertNull(result)
}
@Test
fun icy_stream_url_must_look_like_image() = runTest {
val result = resolver.resolve(
artist = null,
title = null,
icyStreamUrl = "https://stream.example/audio.mp3",
stationArtworkUrl = "https://station.com/default.png"
)
assertEquals("https://station.com/default.png", result)
}
@Test
fun musicbrainz_empty_recordings_falls_through() = runTest {
server.enqueue(
MockResponse()
.setBody("""{"recordings": []}""")
.addHeader("Content-Type", "application/json")
)
val result = resolver.resolve(
artist = "Unknown",
title = "Unknown",
icyStreamUrl = null,
stationArtworkUrl = "https://station.com/default.png"
)
assertEquals("https://station.com/default.png", result)
}
}

View File

@@ -13,6 +13,7 @@ material = "1.11.0"
junit = "4.13.2"
mockk = "1.13.16"
turbine = "1.2.0"
coil = "3.1.0"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
@@ -42,6 +43,9 @@ material = { group = "com.google.android.material", name = "material", version.r
junit = { group = "junit", name = "junit", version.ref = "junit" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
json = { group = "org.json", name = "json", version = "20240303" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }