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:
parent
ec55ba84ae
commit
5b524f2153
4 changed files with 97 additions and 0 deletions
|
|
@ -14,6 +14,9 @@ interface SavedSongDao {
|
|||
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
|
||||
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")
|
||||
fun getAllByArtist(): Flow<List<SavedSongEntity>>
|
||||
|
||||
|
|
|
|||
68
app/src/main/java/com/rada/data/repository/DedupeLibrary.kt
Normal file
68
app/src/main/java/com/rada/data/repository/DedupeLibrary.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue