From 5b524f2153e5825a8b88835e6fbbe9186ff69ef4 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 09:18:25 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20dedupe=20library=20=E2=80=94=20refuse=20?= =?UTF-8?q?duplicate=20saves,=20auto-remove=20existing=20dupes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: import deduped by artist+title, but save-from-air did not, so saving the same broadcast again wrote 'Song (1).mp3' + a new row. - SaveSongUseCase now refuses a save when artist+title already exists (case-insensitive), reporting 'Already in your library' - DedupeLibrary collapses existing duplicates: keeps the largest file (ties → oldest), re-points playlist memberships to the keeper, then deletes duplicate rows + files - Runs automatically when the Library opens; a snackbar reports the removed count - On-device: user library 89 → 54 songs, 0 remaining dup groups, playlist links intact (Zaza All 32, Apollo 19), zero orphans, all kept files present on disk (pre-run backup taken) --- .../com/rada/data/local/dao/SavedSongDao.kt | 3 + .../com/rada/data/repository/DedupeLibrary.kt | 68 +++++++++++++++++++ .../com/rada/recording/SaveSongUseCase.kt | 15 ++++ .../com/rada/ui/library/LibraryViewModel.kt | 11 +++ 4 files changed, 97 insertions(+) create mode 100644 app/src/main/java/com/rada/data/repository/DedupeLibrary.kt diff --git a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt index 6a76890..a0f6761 100644 --- a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt +++ b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt @@ -14,6 +14,9 @@ interface SavedSongDao { @Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC") fun getAllByDate(): Flow> + @Query("SELECT * FROM saved_songs ORDER BY id ASC") + suspend fun getAllOnce(): List + @Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC") fun getAllByArtist(): Flow> diff --git a/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt b/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt new file mode 100644 index 0000000..2171301 --- /dev/null +++ b/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt @@ -0,0 +1,68 @@ +package com.rada.data.repository + +import android.util.Log +import com.rada.data.local.dao.PlaylistDao +import com.rada.data.local.dao.SavedSongDao +import com.rada.data.local.entity.SavedSongEntity +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Collapses duplicate library songs (same artist + title, case-insensitive). + * Duplicates arise mainly from save-from-air: saving the same broadcast twice + * wrote a second file ("Song (1).mp3") and a second row. + * + * Keep rule: the largest file (the most complete recording; equal sizes tie + * to the oldest entry). Dropped songs' playlist links are re-pointed to the + * kept song before their rows and files are deleted. + */ +@Singleton +class DedupeLibrary @Inject constructor( + private val savedSongDao: SavedSongDao, + private val playlistDao: PlaylistDao, +) { + data class Result(val removed: Int) + + suspend fun run(): Result { + val all = savedSongDao.getAllOnce() + val groups = all.groupBy { + "${it.artist.trim().lowercase()}${it.title.trim().lowercase()}" + } + var removed = 0 + for ((_, group) in groups) { + if (group.size < 2) continue + // Largest file wins; ties → oldest (smallest id). + val keeper = group.maxWithOrNull( + compareBy { it.fileSize }.thenBy { -it.id } + ) ?: continue + for (dup in group.filter { it.id != keeper.id }) { + // Preserve group membership: re-point links, skip if the + // keeper is already in that playlist. + for (playlistId in playlistDao.getPlaylistIdsForSong(dup.id)) { + if (!playlistDao.isSongInPlaylist(playlistId, keeper.id)) { + playlistDao.addSongToPlaylist( + com.rada.data.local.entity.PlaylistSongEntity( + playlistId = playlistId, + songId = keeper.id, + ), + ) + } + } + savedSongDao.deleteById(dup.id) + removed++ + try { + if (dup.filePath != keeper.filePath) File(dup.filePath).delete() + } catch (_: Exception) { + } + } + Log.d(TAG, "Deduped ${group.size - 1} copy(ies) of " + + "${keeper.artist} — ${keeper.title}") + } + return Result(removed) + } + + companion object { + private const val TAG = "DedupeLibrary" + } +} diff --git a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt index 9e76f5f..f017327 100644 --- a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt +++ b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt @@ -47,6 +47,21 @@ class SaveSongUseCase( ) } + // Refuse duplicates before touching storage: the same artist+title is + // already in the library (case-insensitive, trimmed). + val dupArtist = boundaries.artist.trim() + val dupTitle = boundaries.title.trim() + if (dupArtist.isNotEmpty() && dupTitle.isNotEmpty()) { + val existing = libraryRepository.getByArtistTitle(dupArtist, dupTitle) + if (existing != null) { + return SaveResult( + success = false, + songId = existing.id, + error = "Already in your library: $dupTitle", + ) + } + } + // Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes) val minBytes = 160_000L if (boundaries.durationBytes < minBytes) { diff --git a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt index efbe24a..26683a8 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rada.data.local.entity.PlaylistEntity import com.rada.data.local.entity.SavedSongEntity +import com.rada.data.repository.DedupeLibrary import com.rada.data.repository.LibraryRepository import com.rada.data.repository.PlaylistRepository import com.rada.data.repository.SortOrder @@ -29,6 +30,7 @@ class LibraryViewModel @Inject constructor( private val playlistRepository: PlaylistRepository, private val playbackManager: PlaybackManager, private val songImporter: SongImporter, + private val dedupeLibrary: DedupeLibrary, ) : ViewModel() { private val _uiState = MutableStateFlow(LibraryUiState()) @@ -121,6 +123,15 @@ class LibraryViewModel @Inject constructor( if (id != null) _uiState.update { it.copy(currentPlayingId = id) } } } + // Collapse duplicate saves (same artist+title) once per app session. + viewModelScope.launch { + val result = runCatching { dedupeLibrary.run() }.getOrNull() + if (result != null && result.removed > 0) { + _importMessage.value = + "Removed ${result.removed} duplicate song" + + (if (result.removed == 1) "" else "s") + } + } } private fun sortSongs(