From e8f9351a83f8ca058390d175f1fa0b5bf44b755c Mon Sep 17 00:00:00 2001 From: 0CrazyLove Date: Thu, 10 Sep 2026 18:59:22 -0500 Subject: [PATCH 1/2] fix(sync): drop YouTube-deleted songs instead of re-appending them --- app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt b/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt index 485beb988c..5291d4e0eb 100644 --- a/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt +++ b/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt @@ -1468,6 +1468,7 @@ class SyncUtils @Inject constructor( } val preservedSongs = localSongIndexesAbsentFromRemote(localIds, remoteIds) .map(localSongs::get) + .filter { it.setVideoId == null } database.withTransaction { database.clearPlaylist(playlistId) From 1ca83916ce1de343767ffd0e0aff014f3227da58 Mon Sep 17 00:00:00 2001 From: 0CrazyLove Date: Thu, 10 Sep 2026 19:53:18 -0500 Subject: [PATCH 2/2] fix(sync): only skip empty playlist sync on unconfirmed fetches --- .../com/metrolist/music/utils/SyncUtils.kt | 13 +++++- .../metrolist/music/utils/PlaylistSyncTest.kt | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt b/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt index 5291d4e0eb..01e4d91c6d 100644 --- a/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt +++ b/app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt @@ -13,6 +13,7 @@ import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem +import com.metrolist.innertube.pages.PlaylistPage import com.metrolist.innertube.utils.completed import com.metrolist.innertube.utils.parseCookieString import com.metrolist.lastfm.LastFM @@ -97,6 +98,14 @@ internal fun localSongIndexesAbsentFromRemote( } } +internal fun isGenuineEmptyPlaylist(page: PlaylistPage): Boolean { + if (page.songs.isNotEmpty()) return false + val advertised = page.playlist.songCountText?.let { + Regex("""\d+""").find(it)?.value?.toIntOrNull() + } ?: return false + return advertised == 0 +} + @Singleton class SyncUtils @Inject constructor( @ApplicationContext private val context: Context, @@ -1437,8 +1446,8 @@ class SyncUtils @Inject constructor( val songs = page.songs.map(SongItem::toMediaMetadata) Timber.d("syncPlaylist: Fetched ${songs.size} songs from remote") - if (songs.isEmpty()) { - Timber.w("syncPlaylist: Remote playlist is empty, skipping sync") + if (songs.isEmpty() && !isGenuineEmptyPlaylist(page)) { + Timber.w("syncPlaylist: Empty fetch but remote advertises songs, skipping sync") return@onSuccess } diff --git a/app/src/test/kotlin/com/metrolist/music/utils/PlaylistSyncTest.kt b/app/src/test/kotlin/com/metrolist/music/utils/PlaylistSyncTest.kt index 81a8bb3400..7b467f5539 100644 --- a/app/src/test/kotlin/com/metrolist/music/utils/PlaylistSyncTest.kt +++ b/app/src/test/kotlin/com/metrolist/music/utils/PlaylistSyncTest.kt @@ -1,5 +1,8 @@ package com.metrolist.music.utils +import com.metrolist.innertube.models.PlaylistItem +import com.metrolist.innertube.models.SongItem +import com.metrolist.innertube.pages.PlaylistPage import org.junit.Assert.assertEquals import org.junit.Test @@ -33,4 +36,46 @@ class PlaylistSyncTest { localSongIndexesAbsentFromRemote(listOf("a", "b"), listOf("b", "a")), ) } + + private fun emptyPage(songCountText: String?) = + PlaylistPage( + playlist = + PlaylistItem( + id = "playlist", + title = "Playlist", + author = null, + songCountText = songCountText, + thumbnail = null, + playEndpoint = null, + shuffleEndpoint = null, + radioEndpoint = null, + ), + songs = emptyList(), + songsContinuation = null, + continuation = null, + ) + + @Test + fun `empty fetch with advertised songs is not genuine`() { + assertEquals(false, isGenuineEmptyPlaylist(emptyPage("37 songs"))) + } + + @Test + fun `empty fetch with zero advertised songs is genuine`() { + assertEquals(true, isGenuineEmptyPlaylist(emptyPage("0 songs"))) + } + + @Test + fun `empty fetch without advertised count is not genuine`() { + assertEquals(false, isGenuineEmptyPlaylist(emptyPage(null))) + } + + @Test + fun `non-empty fetch is never genuine empty`() { + val page = + emptyPage("37 songs").copy( + songs = listOf(SongItem(id = "a", title = "A", artists = emptyList(), thumbnail = "")), + ) + assertEquals(false, isGenuineEmptyPlaylist(page)) + } }