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