From 89b58477c9d1963a8abb46e8f192e8a5a042929a Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 03:07:32 -0400 Subject: [PATCH] feat: add album art resolution with MusicBrainz and fallback chain Made-with: Cursor --- app/build.gradle.kts | 3 + .../cottongin/radio247/audio/IcyMetadata.kt | 3 +- .../xyz/cottongin/radio247/audio/IcyParser.kt | 18 +- .../radio247/metadata/AlbumArtResolver.kt | 110 +++++++++++ .../cottongin/radio247/metadata/ArtCache.kt | 17 ++ .../cottongin/radio247/audio/IcyParserTest.kt | 1 + .../radio247/metadata/AlbumArtResolverTest.kt | 181 ++++++++++++++++++ gradle/libs.versions.toml | 4 + 8 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt create mode 100644 app/src/test/java/xyz/cottongin/radio247/metadata/AlbumArtResolverTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eeb0cd0..21f4a38 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt b/app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt index 8fe324a..8a245a7 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt @@ -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 ) diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt b/app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt index c9e4427..1fd89a8 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt @@ -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? { diff --git a/app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt b/app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt new file mode 100644 index 0000000..96972f5 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt @@ -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") + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt b/app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt new file mode 100644 index 0000000..fd7f09a --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt @@ -0,0 +1,17 @@ +package xyz.cottongin.radio247.metadata + +class ArtCache(private val maxSize: Int = 500) { + private val cache = object : LinkedHashMap(maxSize, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > maxSize + } + } + + @Synchronized + fun get(key: String): String? = cache[key] + + @Synchronized + fun put(key: String, url: String) { + cache[key] = url + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt b/app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt index 905959d..ada0793 100644 --- a/app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt +++ b/app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt @@ -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") diff --git a/app/src/test/java/xyz/cottongin/radio247/metadata/AlbumArtResolverTest.kt b/app/src/test/java/xyz/cottongin/radio247/metadata/AlbumArtResolverTest.kt new file mode 100644 index 0000000..bb34f90 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/metadata/AlbumArtResolverTest.kt @@ -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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08ff1de..f44a54a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }