metadata: harden the screenshot generator and changelog test

Only cache a cover after a successful response and drop the file if decoding
fails, so a transient error cannot poison the cache permanently. Fail with a
clear message instead of a bare NoSuchElementException when a demo album has
no songs or the artist list is empty, and normalize line endings before the
changelog length check.
This commit is contained in:
austinried
2026-10-04 00:30:08 +00:00
parent e34363c1ac
commit cdba272f98
2 changed files with 26 additions and 14 deletions
@@ -18,7 +18,7 @@ class ChangelogTest {
.filter { it.isFile && it.extension == "txt" } .filter { it.isFile && it.extension == "txt" }
assertTrue("no changelog files found under metadata/", files.isNotEmpty()) assertTrue("no changelog files found under metadata/", files.isNotEmpty())
files.forEach { file -> files.forEach { file ->
val text = file.readText() val text = file.readText().replace("\r\n", "\n")
val length = text.codePointCount(0, text.length) val length = text.codePointCount(0, text.length)
assertTrue( assertTrue(
"metadata changelogs must be at most $LIMIT characters; ${file.name} is $length", "metadata changelogs must be at most $LIMIT characters; ${file.name} is $length",
@@ -77,7 +77,8 @@ import java.io.File
/** /**
* Records the store screenshots from a real sync of the public Navidrome demo library. It is not run * Records the store screenshots from a real sync of the public Navidrome demo library. It is not run
* by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`. * by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`.
* The synced database and the downloaded cover art live only in memory and are never written down. * The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
* so reruns do not re-fetch it.
*/ */
@OptIn(DelicateCoilApi::class) @OptIn(DelicateCoilApi::class)
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -378,17 +379,20 @@ class DemoScreenshotTest {
val allArtists = dao.artistIds(1).mapNotNull { dao.artist(1, it).first() } val allArtists = dao.artistIds(1).mapNotNull { dao.artist(1, it).first() }
val playlists = dao.playlistIds(1).mapNotNull { dao.playlist(1, it).first() } val playlists = dao.playlistIds(1).mapNotNull { dao.playlist(1, it).first() }
suspend fun songsOf(album: Album) =
dao.songsByAlbum(1, album.id).first().ifEmpty { error("demo album '${album.name}' has no songs") }
val nowPlayingAlbum = allAlbums.firstOrNull { it.name == "Chillhop Essentials - Winter 2016" } ?: allAlbums.first() val nowPlayingAlbum = allAlbums.firstOrNull { it.name == "Chillhop Essentials - Winter 2016" } ?: allAlbums.first()
val nowPlayingSongs = dao.songsByAlbum(1, nowPlayingAlbum.id).first() val nowPlayingSongs = songsOf(nowPlayingAlbum)
val nowPlayingSong = nowPlayingSongs.getOrNull(1) ?: nowPlayingSongs.first() val nowPlayingSong = nowPlayingSongs.getOrNull(1) ?: nowPlayingSongs.first()
val album = allAlbums.firstOrNull { it.name == "My latin way" } ?: allAlbums.first() val album = allAlbums.firstOrNull { it.name == "My latin way" } ?: allAlbums.first()
val albumSongs = dao.songsByAlbum(1, album.id).first() val albumSongs = songsOf(album)
val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last() val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last()
val homePlayingSong = dao.songsByAlbum(1, homePlayingAlbum.id).first().first() val homePlayingSong = songsOf(homePlayingAlbum).first()
val artist = val artist =
allArtists.firstOrNull { it.name == "Ugress" } allArtists.firstOrNull { it.name == "Ugress" }
?: allArtists.maxByOrNull { it.albumCount } ?: allArtists.maxByOrNull { it.albumCount }
?: allArtists.first() ?: error("demo library has no artists")
val artistAlbums = dao.albumsForArtist(1, artist.id).first() val artistAlbums = dao.albumsForArtist(1, artist.id).first()
val coverArtIds = val coverArtIds =
@@ -492,26 +496,34 @@ class DemoScreenshotTest {
id: String, id: String,
thumbnail: Boolean, thumbnail: Boolean,
coverDir: File, coverDir: File,
): Bitmap? = ): Bitmap? {
try { val url = source.coverArtUri(id, thumbnail) ?: return null
val url = source.coverArtUri(id, thumbnail) ?: return null val file = File(coverDir, "${url.hashCode().toUInt().toString(16)}.img")
val file = File(coverDir, "${url.hashCode().toUInt().toString(16)}.img") return try {
if (!file.exists()) { if (!file.exists()) {
http.newCall(Request.Builder().url(url).build()).execute().use { response -> http.newCall(Request.Builder().url(url).build()).execute().use { response ->
check(response.isSuccessful) { "cover art HTTP ${response.code}" }
response.body.byteStream().use { input -> response.body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) } file.outputStream().use { output -> input.copyTo(output) }
} }
} }
} }
val bytes = file.readBytes() val bytes = file.readBytes()
if (thumbnail) { val bitmap =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size) if (thumbnail) {
} else { BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
decodeDownsampled(bytes, MAX_ART_DIM) } else {
decodeDownsampled(bytes, MAX_ART_DIM)
}
bitmap ?: run {
file.delete()
null
} }
} catch (_: Exception) { } catch (_: Exception) {
file.delete()
null null
} }
}
private fun decodeDownsampled( private fun decodeDownsampled(
bytes: ByteArray, bytes: ByteArray,