mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
fix: handle artist bio failure and refresh edge cases
Retry a failed download bio fetch instead of blacklisting the artist for the rest of the process, and give a failed or offline refresh its own message so it is not mistaken for a server with no biography. Wait for the stored row before choosing the bio text, so a cached bio no longer flashes the loading state on entry, and bound the page refresh so a stalled response cannot leave the loading state up indefinitely. Render an oversized first token instead of a blank collapsed line, and check whether the whole remainder fits in one measurement.
This commit is contained in:
@@ -432,18 +432,19 @@ class DownloadRepository(
|
||||
artistId: String,
|
||||
) {
|
||||
if (db.libraryDao().artistBioOnce(sourceId, artistId) != null) return
|
||||
if (!attemptedBios.add(sourceId to artistId)) return
|
||||
val biography =
|
||||
try {
|
||||
sourceRepository.artistBiography(sourceId, artistId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
db.libraryDao().upsertArtistBio(
|
||||
ArtistBio(sourceId, artistId, biography?.trim()?.takeIf { it.isNotEmpty() }),
|
||||
)
|
||||
val key = sourceId to artistId
|
||||
if (!attemptedBios.add(key)) return
|
||||
try {
|
||||
val biography = sourceRepository.artistBiography(sourceId, artistId)
|
||||
db.libraryDao().upsertArtistBio(
|
||||
ArtistBio(sourceId, artistId, normalizeBiography(biography)),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
attemptedBios.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistBiosSafely(
|
||||
@@ -451,13 +452,8 @@ class DownloadRepository(
|
||||
artistIds: Set<String>,
|
||||
) {
|
||||
if (artistIds.isEmpty()) return
|
||||
try {
|
||||
withTimeoutOrNull(BIO_TIMEOUT_MS) {
|
||||
artistIds.forEach { fetchArtistBio(sourceId, it) }
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
withTimeoutOrNull(BIO_TIMEOUT_MS) {
|
||||
artistIds.forEach { fetchArtistBio(sourceId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -365,22 +365,20 @@ class LibraryRepository(
|
||||
suspend fun refreshArtistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Result<Unit> {
|
||||
val biography =
|
||||
try {
|
||||
sourceRepository.artistBiography(sourceId, artistId)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w("artist", "failed to refresh bio for $artistId", failure)
|
||||
return Result.failure(failure)
|
||||
): Result<Unit> =
|
||||
try {
|
||||
val biography = normalizeBiography(sourceRepository.artistBiography(sourceId, artistId))
|
||||
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
|
||||
if (current == null || current.biography != biography) {
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
|
||||
}
|
||||
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
|
||||
if (current == null || current.biography != biography) {
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
|
||||
Result.success(Unit)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w("artist", "failed to refresh bio for $artistId", failure)
|
||||
Result.failure(failure)
|
||||
}
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
fun song(
|
||||
sourceId: Long,
|
||||
@@ -555,4 +553,6 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
||||
|
||||
const val SEARCH_RESULT_LIMIT = 50
|
||||
|
||||
internal fun normalizeBiography(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||
|
||||
@@ -116,6 +116,7 @@ import com.subtracks.ui.theme.heroBarColor
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
@@ -155,6 +156,12 @@ private const val SCRIM_FADE_MS = 180
|
||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||
internal const val ARTIST_BIO_MORE_TAG = "artistBioMore"
|
||||
|
||||
private data class BioSnapshot(
|
||||
val known: Boolean,
|
||||
val present: Boolean,
|
||||
val biography: String?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailRoute(
|
||||
artistId: String,
|
||||
@@ -170,8 +177,16 @@ fun ArtistDetailRoute(
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artistBio by viewModel.artistBio.collectAsStateWithLifecycle(initialValue = null)
|
||||
val bioSnapshots =
|
||||
remember(viewModel) {
|
||||
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||
}
|
||||
val bioSnapshot by
|
||||
bioSnapshots.collectAsStateWithLifecycle(
|
||||
initialValue = BioSnapshot(known = false, present = false, biography = null),
|
||||
)
|
||||
val bioLoading by viewModel.bioLoading.collectAsStateWithLifecycle()
|
||||
val bioUnavailable by viewModel.bioUnavailable.collectAsStateWithLifecycle()
|
||||
val bioExpanded by viewModel.bioExpanded.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||
@@ -208,8 +223,10 @@ fun ArtistDetailRoute(
|
||||
artistName = artistName.orEmpty(),
|
||||
albums = albums ?: ArtistAlbums(),
|
||||
albumsLoaded = albums != null,
|
||||
bio = artistBio?.biography,
|
||||
bioLoading = bioLoading && artistBio == null,
|
||||
bio = bioSnapshot.biography,
|
||||
bioLoading = bioLoading && !bioSnapshot.present,
|
||||
bioUnavailable = bioUnavailable && !bioSnapshot.present,
|
||||
bioPending = !bioSnapshot.known,
|
||||
bioExpanded = bioExpanded,
|
||||
art = art,
|
||||
artThumbnail = artThumbnail,
|
||||
@@ -246,6 +263,8 @@ fun ArtistDetailScreen(
|
||||
albumsLoaded: Boolean = true,
|
||||
bio: String? = null,
|
||||
bioLoading: Boolean = false,
|
||||
bioUnavailable: Boolean = false,
|
||||
bioPending: Boolean = false,
|
||||
bioExpanded: Boolean = false,
|
||||
art: CoverArtRef?,
|
||||
artThumbnail: CoverArtRef? = null,
|
||||
@@ -284,8 +303,10 @@ fun ArtistDetailScreen(
|
||||
val bioCollapsed = hasBio && !bioExpanded
|
||||
val bioText =
|
||||
when {
|
||||
bioPending -> ""
|
||||
hasBio -> bio.orEmpty()
|
||||
bioLoading -> stringResource(R.string.artist_bio_loading)
|
||||
bioUnavailable -> stringResource(R.string.artist_bio_unavailable)
|
||||
else -> stringResource(R.string.artist_bio_empty)
|
||||
}
|
||||
var titleHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
@@ -654,6 +675,9 @@ internal fun splitBioCollapsed(
|
||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||
first = candidate
|
||||
index++
|
||||
} else if (first.isEmpty()) {
|
||||
first = words[index]
|
||||
index++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@@ -661,19 +685,9 @@ internal fun splitBioCollapsed(
|
||||
if (index >= words.size) return BioLines(listOf(AnnotatedString(first)), more = false)
|
||||
|
||||
val secondStart = index
|
||||
var second = ""
|
||||
var secondIndex = secondStart
|
||||
while (secondIndex < words.size) {
|
||||
val candidate = if (second.isEmpty()) words[secondIndex] else "$second ${words[secondIndex]}"
|
||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||
second = candidate
|
||||
secondIndex++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (secondIndex >= words.size) {
|
||||
return BioLines(listOf(AnnotatedString(first), AnnotatedString(second)), more = false)
|
||||
val remaining = words.drop(secondStart).joinToString(" ")
|
||||
if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= available) {
|
||||
return BioLines(listOf(AnnotatedString(first), AnnotatedString(remaining)), more = false)
|
||||
}
|
||||
|
||||
val more =
|
||||
|
||||
@@ -29,6 +29,9 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
private const val BIO_REFRESH_TIMEOUT_MS = 15_000L
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ArtistDetailViewModel(
|
||||
@@ -73,6 +76,9 @@ class ArtistDetailViewModel(
|
||||
private val _bioLoading = MutableStateFlow(true)
|
||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||
|
||||
private val _bioUnavailable = MutableStateFlow(false)
|
||||
val bioUnavailable: StateFlow<Boolean> = _bioUnavailable
|
||||
|
||||
private val _bioExpanded = MutableStateFlow(false)
|
||||
val bioExpanded: StateFlow<Boolean> = _bioExpanded
|
||||
|
||||
@@ -82,18 +88,22 @@ class ArtistDetailViewModel(
|
||||
if (bioRefresh?.isActive == true) return
|
||||
bioRefresh =
|
||||
viewModelScope.launch {
|
||||
_bioUnavailable.value = false
|
||||
if (sourceRepository.offline.value) {
|
||||
_bioLoading.value = false
|
||||
_bioUnavailable.value = true
|
||||
return@launch
|
||||
}
|
||||
val id = sourceRepository.activeSourceIdOnce()
|
||||
if (id == null) {
|
||||
_bioLoading.value = false
|
||||
_bioUnavailable.value = true
|
||||
return@launch
|
||||
}
|
||||
_bioLoading.value = true
|
||||
try {
|
||||
libraryRepository.refreshArtistBio(id, artistId)
|
||||
val result = withTimeoutOrNull(BIO_REFRESH_TIMEOUT_MS) { libraryRepository.refreshArtistBio(id, artistId) }
|
||||
_bioUnavailable.value = result == null || result.isFailure
|
||||
} finally {
|
||||
_bioLoading.value = false
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<string name="artist_section_appearances">Appears on</string>
|
||||
<string name="artist_bio_empty">No artist bio available.</string>
|
||||
<string name="artist_bio_loading">Loading artist bio…</string>
|
||||
<string name="artist_bio_unavailable">Artist bio unavailable.</string>
|
||||
<string name="artist_bio_more">More</string>
|
||||
<string name="artist_meta">%1$s · %2$s</string>
|
||||
<string name="resources_filter_genre">Genre</string>
|
||||
|
||||
@@ -44,6 +44,8 @@ class ArtistDetailScreenTest {
|
||||
albumsLoaded: Boolean = true,
|
||||
bio: String? = null,
|
||||
bioLoading: Boolean = false,
|
||||
bioUnavailable: Boolean = false,
|
||||
bioPending: Boolean = false,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -55,6 +57,8 @@ class ArtistDetailScreenTest {
|
||||
albumsLoaded = albumsLoaded,
|
||||
bio = bio,
|
||||
bioLoading = bioLoading,
|
||||
bioUnavailable = bioUnavailable,
|
||||
bioPending = bioPending,
|
||||
bioExpanded = expanded,
|
||||
art = null,
|
||||
coverArt = { _, _ -> null },
|
||||
@@ -151,6 +155,32 @@ class ArtistDetailScreenTest {
|
||||
composeRule.onNodeWithText("No artist bio available.").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBioAreaWaitsForTheStoredValueBeforeChoosingAText() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bioLoading = true,
|
||||
bioPending = true,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Loading artist bio…").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("No artist bio available.").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artist bio unavailable.").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRefreshIsNotMistakenForAnArtistWithoutABio() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bioUnavailable = true,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Artist bio unavailable.").assertExists()
|
||||
composeRule.onNodeWithText("No artist bio available.").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingBioShowsTheEmptyStateAfterTheRefresh() {
|
||||
render(
|
||||
@@ -218,6 +248,33 @@ class ArtistDetailScreenTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOversizedFirstWordIsStillShown() {
|
||||
var result: BioLines? = null
|
||||
composeRule.setContent {
|
||||
val measurer = rememberTextMeasurer()
|
||||
result =
|
||||
splitBioCollapsed(
|
||||
measurer,
|
||||
"Supercalifragilisticexpialidocious and the rest",
|
||||
TextStyle(fontSize = 14.sp),
|
||||
20,
|
||||
0,
|
||||
"More",
|
||||
)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val lines = requireNotNull(result)
|
||||
assertTrue(lines.more)
|
||||
assertTrue(
|
||||
lines.lines
|
||||
.first()
|
||||
.text
|
||||
.isNotBlank(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun album(
|
||||
id: String,
|
||||
name: String,
|
||||
|
||||
Reference in New Issue
Block a user