fix: dedupe library — refuse duplicate saves, auto-remove existing dupes

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)
This commit is contained in:
avi 2026-09-08 09:18:25 -05:00
commit 5b524f2153
4 changed files with 97 additions and 0 deletions

View file

@ -14,6 +14,9 @@ interface SavedSongDao {
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC") @Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
fun getAllByDate(): Flow<List<SavedSongEntity>> fun getAllByDate(): Flow<List<SavedSongEntity>>
@Query("SELECT * FROM saved_songs ORDER BY id ASC")
suspend fun getAllOnce(): List<SavedSongEntity>
@Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC") @Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC")
fun getAllByArtist(): Flow<List<SavedSongEntity>> fun getAllByArtist(): Flow<List<SavedSongEntity>>

View file

@ -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<SavedSongEntity> { 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"
}
}

View file

@ -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) // Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes)
val minBytes = 160_000L val minBytes = 160_000L
if (boundaries.durationBytes < minBytes) { if (boundaries.durationBytes < minBytes) {

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rada.data.local.entity.PlaylistEntity import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.SavedSongEntity import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.DedupeLibrary
import com.rada.data.repository.LibraryRepository import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.SortOrder import com.rada.data.repository.SortOrder
@ -29,6 +30,7 @@ class LibraryViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository, private val playlistRepository: PlaylistRepository,
private val playbackManager: PlaybackManager, private val playbackManager: PlaybackManager,
private val songImporter: SongImporter, private val songImporter: SongImporter,
private val dedupeLibrary: DedupeLibrary,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(LibraryUiState()) private val _uiState = MutableStateFlow(LibraryUiState())
@ -121,6 +123,15 @@ class LibraryViewModel @Inject constructor(
if (id != null) _uiState.update { it.copy(currentPlayingId = id) } 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( private fun sortSongs(