From 58369b0be956950f3cc30ead096d123499e7172c Mon Sep 17 00:00:00 2001 From: Duhan <136324426+lostf1sh@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:17:43 +0300 Subject: [PATCH] Recover missing album tracks during incremental scans --- .../data/worker/SyncWorkerTest.kt | 87 ++++++++++++++++++- .../pixelplayeross/data/worker/SyncWorker.kt | 11 ++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt index 593b2995..de21984c 100644 --- a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt @@ -15,6 +15,7 @@ import androidx.work.ListenableWorker import androidx.work.WorkerFactory import androidx.work.WorkerParameters import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf import com.google.common.truth.Truth.assertThat import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase @@ -50,7 +51,10 @@ class SyncWorkerTest { private lateinit var mockContentResolver: android.content.ContentResolver - class TestSyncWorkerFactory(private val dao: MusicDao) : WorkerFactory() { + class TestSyncWorkerFactory( + private val dao: MusicDao, + private val preferences: UserPreferencesRepository = createTestPreferencesRepository() + ) : WorkerFactory() { override fun createWorker( appContext: Context, workerClassName: String, @@ -61,7 +65,7 @@ class SyncWorkerTest { appContext = appContext, workerParams = workerParameters, musicDao = dao, - userPreferencesRepository = createTestPreferencesRepository(), + userPreferencesRepository = preferences, lyricsRepository = mockk(relaxed = true), cloudSyncCoordinator = mockk(relaxed = true) ) @@ -188,6 +192,85 @@ class SyncWorkerTest { assertThat(musicDao.getArtistCount().first()).isEqualTo(0) } + @Test + fun incrementalSync_recoversAlbumTracksIndexedWithOldTimestamps() = runBlocking { + val preferences = createTestPreferencesRepository() + var lastSyncTimestamp = 0L + coEvery { preferences.getLastSyncTimestamp() } answers { lastSyncTimestamp } + coEvery { preferences.setLastSyncTimestamp(any()) } answers { + lastSyncTimestamp = firstArg() + } + + var visibleTrackCount = 3 + val songSelections = mutableListOf() + every { mockContentResolver.query(any(), any(), any(), any(), any()) } answers { + val projection = secondArg>() + val cursor = MatrixCursor(projection) + if (firstArg() == MediaStore.Audio.Media.EXTERNAL_CONTENT_URI) { + val selection = thirdArg() + val selectionArgs = arg>(3) + val usesTimestamp = selection.contains("${MediaStore.Audio.Media.DATE_MODIFIED} > ?") + if (MediaStore.Audio.Media.TITLE in projection) { + songSelections += selection + } + // The scanner exposes the remaining tracks after a previous sync, with + // timestamps older than its watermark (for example, after restoring files). + if (!usesTimestamp || 100L > selectionArgs.last().toLong()) { + for (track in 1..visibleTrackCount) { + val values = mapOf( + MediaStore.Audio.Media._ID to track.toLong(), + MediaStore.Audio.Media.TITLE to "Track $track", + MediaStore.Audio.Media.ARTIST to "milet", + MediaStore.Audio.Media.ARTIST_ID to 1L, + MediaStore.Audio.Media.ALBUM to "Made of Glass", + MediaStore.Audio.Media.ALBUM_ID to 201L, + MediaStore.Audio.Media.ALBUM_ARTIST to "milet", + MediaStore.Audio.Media.DURATION to 180_000L, + MediaStore.Audio.Media.DATA to "/storage/emulated/0/Music/milet - Made of Glass/$track.flac", + MediaStore.Audio.Media.MIME_TYPE to "audio/flac", + MediaStore.Audio.Media.TRACK to track, + MediaStore.Audio.Media.YEAR to 2026, + MediaStore.Audio.Media.DATE_ADDED to 100L, + MediaStore.Audio.Media.DATE_MODIFIED to 100L + ) + cursor.addRow(projection.map { values[it] }) + } + } + } + cursor + } + + val testContext = object : ContextWrapper(context) { + override fun getContentResolver() = mockContentResolver + } + suspend fun sync(): ListenableWorker.Result = + TestListenableWorkerBuilder(testContext) + .setWorkerFactory(TestSyncWorkerFactory(musicDao, preferences)) + .setInputData(workDataOf(SyncWorker.INPUT_RUN_MAINTENANCE to false)) + .build() + .doWork() + + assertSuccessfulSongCount(sync(), expectedCount = 3) + assertThat(lastSyncTimestamp).isGreaterThan(100_000L) + val editedSong = musicDao.getSongByIdOnce(1L)!!.copy( + title = "My title", + titleUserEdited = true + ) + musicDao.updateSongs(listOf(editedSong)) + + visibleTrackCount = 16 + assertSuccessfulSongCount(sync(), expectedCount = 16) + assertThat(musicDao.getSongsByAlbumId(201L).first().map { it.id }) + .containsExactlyElementsIn((1L..16L).toList()) + assertThat(musicDao.getAlbumById(201L).first()?.songCount).isEqualTo(16) + assertThat(musicDao.getSongByIdOnce(1L)?.title).isEqualTo("My title") + assertThat(songSelections.last()).doesNotContain("${MediaStore.Audio.Media.DATE_MODIFIED} > ?") + + // Once the IDs match, ordinary incremental scans keep their timestamp filter. + assertSuccessfulSongCount(sync(), expectedCount = 16) + assertThat(songSelections.last()).contains("${MediaStore.Audio.Media.DATE_MODIFIED} > ?") + } + private fun assertSuccessfulSongCount( result: ListenableWorker.Result, expectedCount: Int, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt index c05b5b98..2c37dc22 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt @@ -194,9 +194,11 @@ constructor( "(current=$directoryRulesVersion, applied=$lastAppliedDirectoryRulesVersion)" ) + var hasUnindexedSongs = false if (syncMode != SyncMode.REBUILD) { val localSongIds = musicDao.getAllMediaStoreSongIds().toHashSet() val mediaStoreIds = fetchMediaStoreIds(directoryResolver) + hasUnindexedSongs = mediaStoreIds.any { it !in localSongIds } val deletedIds = localSongIds - mediaStoreIds @@ -226,12 +228,19 @@ constructor( isFreshInstall = isFreshInstall ) - val fetchTimestamp = if (!syncPlan.forceProcessAll) { + // Newly visible files can retain timestamps older than the last sync. + // Reconcile missing IDs without forcing unchanged songs through metadata + // processing, so a partial album import can recover on the next scan. + val fetchTimestamp = if (!syncPlan.forceProcessAll && !hasUnindexedSongs) { incrementalFetchTimestampSeconds(lastSyncTimestamp) } else { 0L } + if (hasUnindexedSongs && !syncPlan.forceProcessAll) { + Timber.tag(TAG).i("Recovering unindexed MediaStore songs without a timestamp cutoff.") + } + Timber.tag(TAG) .i("Fetching music from MediaStore (plan=${syncPlan.localScanMode}, since=$fetchTimestamp seconds)...")