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")
|
@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>>
|
||||||
|
|
||||||
|
|
|
||||||
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)
|
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue