Skip to content

Commit 4891e65

Browse files
authored
Merge pull request #1619 from theovilardo/fix/prefetch-artist-and-album-tap
Fix/prefetch artist and album tap
2 parents 296f80a + 952ca82 commit 4891e65

7 files changed

Lines changed: 246 additions & 15 deletions

File tree

app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.theveloper.pixelplay.data.network.deezer.DeezerApiService
1111
import com.theveloper.pixelplay.utils.NetworkRetryUtils
1212
import com.theveloper.pixelplay.utils.isRetryableNetworkError
1313
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.CancellationException
1415
import kotlinx.coroutines.async
1516
import kotlinx.coroutines.awaitAll
1617
import kotlinx.coroutines.sync.Mutex
@@ -138,6 +139,8 @@ class ArtistImageRepository @Inject constructor(
138139
} else {
139140
Timber.tag(TAG).d("Skipping prefetch for $artistName") //check
140141
}
142+
} catch (e: CancellationException) {
143+
throw e
141144
} catch (e: Exception) {
142145
Timber.tag(TAG).w("Failed to prefetch image for $artistName: ${e.message}")
143146
}
@@ -194,6 +197,8 @@ class ArtistImageRepository @Inject constructor(
194197
null
195198
}
196199
}
200+
} catch (e: CancellationException) {
201+
throw e
197202
} catch (e: Exception) {
198203
Timber.tag(TAG).e("Error fetching artist image for $artistName: ${e.message}")
199204
// Consider transient errors? For now treating as failed to avoid spam.

app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class MusicRepositoryImpl @Inject constructor(
109109
// Tracks the active prefetch job so a new flow emission cancels the previous one.
110110
@Volatile private var prefetchJob: Job? = null
111111
@Volatile private var currentSongArtistPrefetchJob: Job? = null
112+
@Volatile private var currentSongArtistPrefetchSongId: Long? = null
112113
@Volatile private var telegramDownloadSyncObserverStarted = false
113114
private val telegramCacheManager: com.theveloper.pixelplay.data.telegram.TelegramCacheManager
114115
get() = telegramCacheManagerProvider.get()
@@ -310,7 +311,16 @@ class MusicRepositoryImpl @Inject constructor(
310311
.onEach { artists ->
311312
val missingImages = artists.missingImageCandidates()
312313
if (missingImages.isNotEmpty()) {
313-
currentSongArtistPrefetchJob?.cancel()
314+
val isNewSong = currentSongArtistPrefetchSongId != songId
315+
if (isNewSong) {
316+
currentSongArtistPrefetchJob?.cancel()
317+
currentSongArtistPrefetchSongId = songId
318+
} else if (currentSongArtistPrefetchJob?.isActive == true) {
319+
// Room re-emits as artist rows are updated; keep the current song batch
320+
// alive so one successful image write does not cancel the remaining fetches.
321+
return@onEach
322+
}
323+
314324
currentSongArtistPrefetchJob = repositoryScope.launch {
315325
artistImageRepository.prefetchArtistImages(missingImages)
316326
}

app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ fun AlbumCarouselSection(
151151
.fillMaxSize()
152152
.aspectRatio(1f)
153153
.clickable(
154-
enabled = isFocusedItem && song.albumId > 0L,
154+
enabled = isFocusedItem && song.albumId != -1L,
155155
interactionSource = remember { MutableInteractionSource() },
156156
indication = null,
157157
onClick = { onAlbumClick(song) }

app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2044,7 +2044,7 @@ class PlayerViewModel @Inject constructor(
20442044
}
20452045

20462046
fun triggerAlbumNavigationFromPlayer(albumId: Long) {
2047-
if (albumId <= 0) {
2047+
if (albumId == -1L) {
20482048
Log.d("AlbumDebug", "triggerAlbumNavigationFromPlayer ignored invalid albumId=$albumId")
20492049
return
20502050
}

app/src/test/java/com/theveloper/pixelplay/data/repository/ArtistImageRepositoryTest.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
package com.theveloper.pixelplay.data.repository
22

3+
import com.theveloper.pixelplay.data.database.MusicDao
4+
import com.theveloper.pixelplay.data.network.deezer.DeezerApiService
5+
import com.theveloper.pixelplay.data.network.deezer.DeezerArtist
6+
import com.theveloper.pixelplay.data.network.deezer.DeezerSearchResponse
7+
import io.mockk.coEvery
8+
import io.mockk.coJustRun
9+
import io.mockk.coVerify
10+
import io.mockk.mockk
11+
import java.util.concurrent.atomic.AtomicInteger
12+
import kotlinx.coroutines.CompletableDeferred
13+
import kotlinx.coroutines.awaitCancellation
14+
import kotlinx.coroutines.launch
15+
import kotlinx.coroutines.test.runTest
316
import org.junit.jupiter.api.Assertions.assertEquals
417
import org.junit.jupiter.api.Assertions.assertTrue
518
import org.junit.jupiter.api.Test
@@ -18,4 +31,51 @@ class ArtistImageRepositoryTest {
1831
assertTrue(sampleSize >= 4)
1932
assertEquals(8, sampleSize)
2033
}
34+
35+
@Test
36+
fun `cancelled prefetch does not mark artist as failed for the session`() = runTest {
37+
val deezerApiService = mockk<DeezerApiService>()
38+
val musicDao = mockk<MusicDao>()
39+
val repository = ArtistImageRepository(deezerApiService, musicDao)
40+
val firstAttemptStarted = CompletableDeferred<Unit>()
41+
val searchAttempts = AtomicInteger(0)
42+
val rawUrl = "https://cdn-images.dzcdn.net/images/artist/250x250-000000-80-0-0.jpg"
43+
val upgradedUrl = "https://cdn-images.dzcdn.net/images/artist/1000x1000-000000-80-0-0.jpg"
44+
45+
coEvery { musicDao.getArtistIdByNormalizedName("Artist Name") } returns 42L
46+
coEvery { musicDao.getArtistImageUrl(42L) } returns null
47+
coEvery { musicDao.getArtistImageUrlByNormalizedName("Artist Name") } returns null
48+
coJustRun { musicDao.updateArtistImageUrl(42L, any()) }
49+
coEvery { deezerApiService.searchArtist("Artist Name", 1) } coAnswers {
50+
when (searchAttempts.incrementAndGet()) {
51+
1 -> {
52+
firstAttemptStarted.complete(Unit)
53+
awaitCancellation()
54+
}
55+
56+
else -> DeezerSearchResponse(
57+
data = listOf(
58+
DeezerArtist(
59+
id = 7L,
60+
name = "Artist Name",
61+
pictureBig = rawUrl
62+
)
63+
)
64+
)
65+
}
66+
}
67+
68+
val prefetchJob = launch {
69+
repository.prefetchArtistImages(listOf(42L to "Artist Name"))
70+
}
71+
firstAttemptStarted.await()
72+
prefetchJob.cancel()
73+
prefetchJob.join()
74+
75+
val imageUrl = repository.getArtistImageUrl("Artist Name", 42L)
76+
77+
assertEquals(upgradedUrl, imageUrl)
78+
assertEquals(2, searchAttempts.get())
79+
coVerify(exactly = 1) { musicDao.updateArtistImageUrl(42L, upgradedUrl) }
80+
}
2181
}

app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt

Lines changed: 147 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import com.google.common.util.concurrent.ListenableFuture
77
import com.theveloper.pixelplay.data.model.SearchFilterType
88
import com.theveloper.pixelplay.data.model.SearchHistoryItem
99
import com.theveloper.pixelplay.data.model.SearchResultItem
10+
import com.theveloper.pixelplay.data.model.Album
11+
import com.theveloper.pixelplay.data.model.Artist
1012
import com.theveloper.pixelplay.data.model.Song
1113
import com.theveloper.pixelplay.data.model.SortOption
1214
import com.theveloper.pixelplay.data.model.StorageFilter
@@ -389,22 +391,43 @@ class PlayerViewModelTest {
389391
assertEquals(Player.REPEAT_MODE_OFF, controllerRepeatMode)
390392
}
391393

394+
@Test
395+
fun `album navigation from player accepts synthetic negative album ids`() = runTest {
396+
playerViewModel.albumNavigationRequests.test {
397+
playerViewModel.triggerAlbumNavigationFromPlayer(-42L)
398+
advanceUntilIdle()
399+
400+
assertEquals(-42L, awaitItem())
401+
cancelAndConsumeRemainingEvents()
402+
}
403+
}
404+
405+
@Test
406+
fun `album navigation from player still ignores sentinel album id`() = runTest {
407+
playerViewModel.albumNavigationRequests.test {
408+
playerViewModel.triggerAlbumNavigationFromPlayer(-1L)
409+
advanceUntilIdle()
410+
411+
expectNoEvents()
412+
}
413+
}
414+
392415
@Nested
393416
@DisplayName("Shuffle Functionality")
394417
inner class ShuffleFunctionalityTests {
395418

396419
private val song1 = Song(id = "1", title = "Song 1", artist = "Artist A", genre = "Rock", albumArtUriString = "cover1.png", artistId = 1L, albumId = 1L, contentUriString = "content://dummy/1", duration = 180000L, bitrate = null, sampleRate = null, album = "Album", path = "path", mimeType = "audio/mpeg")
397420
private val song2 = Song(id = "2", title = "Song 2", artist = "Artist B", genre = "Pop", albumArtUriString = "cover2.png", artistId = 2L, albumId = 2L, contentUriString = "content://dummy/2", duration = 200000L, bitrate = null, sampleRate = null, album = "Album", path = "path", mimeType = "audio/mpeg")
398421
private val song3 = Song(id = "3", title = "Song 3", artist = "Artist C", genre = "Jazz", albumArtUriString = "cover3.png", artistId = 3L, albumId = 3L, contentUriString = "content://dummy/3", duration = 210000L, bitrate = null, sampleRate = null, album = "Album", path = "path", mimeType = "audio/mpeg")
399-
400-
@Test
401-
fun `shuffleAllSongs calls prepareShuffledQueue with random songs`() = runTest {
402-
// Arrange
403-
val randomSongs = listOf(song2, song3, song1)
404-
coEvery { mockMusicRepository.getRandomSongs(500) } returns randomSongs
405-
406-
// Mock queue preparation to return a valid shuffled queue and start song
407-
coEvery { mockQueueStateHolder.prepareShuffledQueueSuspending(randomSongs, any()) } returns Pair(randomSongs, song2)
422+
423+
private fun stubShuffledPlayback(
424+
songs: List<Song>,
425+
queueName: String,
426+
startSong: Song = songs.first()
427+
) {
428+
coEvery {
429+
mockQueueStateHolder.prepareShuffledQueueSuspending(songs, queueName, true)
430+
} returns Pair(songs, startSong)
408431

409432
mockkObject(MediaItemBuilder)
410433
every { MediaItemBuilder.build(any()) } returns MediaItem.Builder()
@@ -414,14 +437,126 @@ class PlayerViewModelTest {
414437
val mockedPlaybackUri = mockk<android.net.Uri>(relaxed = true)
415438
every { mockedPlaybackUri.scheme } returns "file"
416439
every { MediaItemBuilder.playbackUri(any()) } returns mockedPlaybackUri
440+
}
441+
442+
@Test
443+
fun `shuffleAllSongs calls prepareShuffledQueue with random songs at index zero`() = runTest {
444+
val randomSongs = listOf(song2, song3, song1)
445+
coEvery { mockMusicRepository.getRandomSongs(500) } returns randomSongs
446+
stubShuffledPlayback(randomSongs, "All Songs (Shuffled)", startSong = song2)
417447

418-
// Act
419448
playerViewModel.shuffleAllSongs()
420449
advanceUntilIdle()
421450

422-
// Assert
423451
coVerify { mockMusicRepository.getRandomSongs(500) }
424-
coVerify { mockQueueStateHolder.prepareShuffledQueueSuspending(randomSongs, "All Songs (Shuffled)") }
452+
coVerify {
453+
mockQueueStateHolder.prepareShuffledQueueSuspending(
454+
randomSongs,
455+
"All Songs (Shuffled)",
456+
true
457+
)
458+
}
459+
}
460+
461+
@Test
462+
fun `playRandomSong calls prepareShuffledQueue with startAtZero`() = runTest {
463+
val randomSongs = listOf(song3, song1, song2)
464+
coEvery { mockMusicRepository.getRandomSongs(500) } returns randomSongs
465+
stubShuffledPlayback(randomSongs, "All Songs (Shuffled)", startSong = song3)
466+
467+
playerViewModel.playRandomSong()
468+
advanceUntilIdle()
469+
470+
coVerify {
471+
mockQueueStateHolder.prepareShuffledQueueSuspending(
472+
randomSongs,
473+
"All Songs (Shuffled)",
474+
true
475+
)
476+
}
477+
}
478+
479+
@Test
480+
fun `shuffleFavoriteSongs calls prepareShuffledQueue with startAtZero`() = runTest {
481+
val favoriteSongs = listOf(song1, song3)
482+
coEvery { mockMusicRepository.getFavoriteSongsOnce(StorageFilter.ALL) } returns favoriteSongs
483+
stubShuffledPlayback(favoriteSongs, "Liked Songs (Shuffled)", startSong = song1)
484+
485+
playerViewModel.shuffleFavoriteSongs()
486+
advanceUntilIdle()
487+
488+
coVerify { mockMusicRepository.getFavoriteSongsOnce(StorageFilter.ALL) }
489+
coVerify {
490+
mockQueueStateHolder.prepareShuffledQueueSuspending(
491+
favoriteSongs,
492+
"Liked Songs (Shuffled)",
493+
true
494+
)
495+
}
496+
}
497+
498+
@Test
499+
fun `shuffleRandomAlbum calls prepareShuffledQueue with startAtZero`() = runTest {
500+
val album = Album(
501+
id = 77L,
502+
title = "Album Roulette",
503+
artist = "Artist A",
504+
year = 2024,
505+
dateAdded = 0L,
506+
albumArtUriString = null,
507+
songCount = 3
508+
)
509+
every { mockLibraryStateHolder.albums } returns MutableStateFlow(persistentListOf(album))
510+
every { mockMusicRepository.getSongsForAlbum(album.id) } returns flowOf(listOf(song1, song2, song3))
511+
stubShuffledPlayback(listOf(song1, song2, song3), album.title, startSong = song2)
512+
513+
playerViewModel.shuffleRandomAlbum()
514+
advanceUntilIdle()
515+
516+
coVerify {
517+
mockQueueStateHolder.prepareShuffledQueueSuspending(
518+
listOf(song1, song2, song3),
519+
"Album Roulette",
520+
true
521+
)
522+
}
523+
}
524+
525+
@Test
526+
fun `shuffleRandomArtist calls prepareShuffledQueue with startAtZero`() = runTest {
527+
val artist = Artist(id = 88L, name = "Artist Roulette", songCount = 3)
528+
every { mockLibraryStateHolder.artists } returns MutableStateFlow(persistentListOf(artist))
529+
every { mockMusicRepository.getSongsForArtist(artist.id) } returns flowOf(listOf(song3, song2, song1))
530+
stubShuffledPlayback(listOf(song3, song2, song1), artist.name, startSong = song3)
531+
532+
playerViewModel.shuffleRandomArtist()
533+
advanceUntilIdle()
534+
535+
coVerify {
536+
mockQueueStateHolder.prepareShuffledQueueSuspending(
537+
listOf(song3, song2, song1),
538+
"Artist Roulette",
539+
true
540+
)
541+
}
542+
}
543+
544+
@Test
545+
fun `triggerShuffleAllFromTile uses startAtZero when library is already loaded`() = runTest {
546+
val songs = listOf(song1, song2, song3)
547+
_allSongsFlow.value = songs.toImmutableList()
548+
stubShuffledPlayback(songs, "All Songs (Shuffled)", startSong = song1)
549+
550+
playerViewModel.triggerShuffleAllFromTile()
551+
advanceUntilIdle()
552+
553+
coVerify {
554+
mockQueueStateHolder.prepareShuffledQueueSuspending(
555+
songs,
556+
"All Songs (Shuffled)",
557+
true
558+
)
559+
}
425560
}
426561
}
427562

app/src/test/java/com/theveloper/pixelplay/utils/QueueUtilsTest.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,27 @@ class QueueUtilsTest {
7474
)
7575
}
7676

77+
@Test
78+
fun buildAnchoredShuffleQueueSuspending_startAtZero_placesAnchorFirst() = runBlocking {
79+
val songs = buildSongs(32)
80+
val anchorIndex = 11
81+
82+
val shuffled = QueueUtils.buildAnchoredShuffleQueueSuspending(
83+
currentQueue = songs,
84+
anchorIndex = anchorIndex,
85+
startAtZero = true,
86+
random = Random(99)
87+
)
88+
89+
assertEquals("Anchor song must become the first item", songs[anchorIndex].id, shuffled.first().id)
90+
assertEquals("Queue size must stay the same", songs.size, shuffled.size)
91+
assertEquals(
92+
"Shuffled queue must contain the same songs",
93+
songs.map { it.id }.toSet(),
94+
shuffled.map { it.id }.toSet()
95+
)
96+
}
97+
7798
private fun buildSongs(count: Int): List<Song> = List(count) { index ->
7899
Song(
79100
id = "song-$index",

0 commit comments

Comments
 (0)