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.coroutines.android)
|
||||||
implementation(libs.media.session)
|
implementation(libs.media.session)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
@@ -70,4 +72,5 @@ dependencies {
|
|||||||
testImplementation(libs.turbine)
|
testImplementation(libs.turbine)
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
testImplementation(libs.room.testing)
|
testImplementation(libs.room.testing)
|
||||||
|
testImplementation(libs.json)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ package xyz.cottongin.radio247.audio
|
|||||||
data class IcyMetadata(
|
data class IcyMetadata(
|
||||||
val raw: String,
|
val raw: String,
|
||||||
val title: 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 {
|
private fun parseMetaString(raw: String): IcyMetadata {
|
||||||
val streamTitle = extractStreamTitle(raw)
|
val streamTitle = extractStreamTitle(raw)
|
||||||
|
val streamUrl = extractStreamUrl(raw)
|
||||||
if (streamTitle != null) {
|
if (streamTitle != null) {
|
||||||
val separatorIndex = streamTitle.indexOf(" - ")
|
val separatorIndex = streamTitle.indexOf(" - ")
|
||||||
if (separatorIndex >= 0) {
|
if (separatorIndex >= 0) {
|
||||||
return IcyMetadata(
|
return IcyMetadata(
|
||||||
raw = raw,
|
raw = raw,
|
||||||
artist = streamTitle.substring(0, separatorIndex).trim(),
|
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? {
|
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(1, metadataEvents.size)
|
||||||
assertEquals("Song", metadataEvents[0].title)
|
assertEquals("Song", metadataEvents[0].title)
|
||||||
|
assertEquals("http://art.jpg", metadataEvents[0].streamUrl)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
metadataEvents[0].raw.contains("StreamTitle") &&
|
metadataEvents[0].raw.contains("StreamTitle") &&
|
||||||
metadataEvents[0].raw.contains("StreamUrl")
|
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"
|
junit = "4.13.2"
|
||||||
mockk = "1.13.16"
|
mockk = "1.13.16"
|
||||||
turbine = "1.2.0"
|
turbine = "1.2.0"
|
||||||
|
coil = "3.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||||
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user