feat: sync Playlists, wip: pair songs
This commit is contained in:
319
app/src/main/java/at/lockstep/player/LockstepViewModel.kt
Normal file
319
app/src/main/java/at/lockstep/player/LockstepViewModel.kt
Normal file
@@ -0,0 +1,319 @@
|
||||
package at.lockstep.player
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.lockstep.jukebox.api.LockstepApiException
|
||||
import at.lockstep.jukebox.db.TrackRow
|
||||
import at.lockstep.player.data.UserPreferencesRepository
|
||||
import at.lockstep.player.data.db.TrackPairingEntity
|
||||
import at.lockstep.player.util.AudioUriValidator
|
||||
import at.lockstep.player.util.FolderMp3Scanner
|
||||
import at.lockstep.player.util.Mp3EmbeddedMetadata
|
||||
import at.lockstep.player.util.Mp3FolderCandidate
|
||||
import at.lockstep.player.util.TrackFileMatching
|
||||
import at.lockstep.player.util.mp3DisplayNameFromUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class LockstepViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application) {
|
||||
private val app = application as LockstepApplication
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LockstepPairing"
|
||||
|
||||
/** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */
|
||||
private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>()
|
||||
private fun detailMutexFor(playlistId: String): Mutex =
|
||||
playlistDetailMutexes.getOrPut(playlistId) { Mutex() }
|
||||
}
|
||||
private val prefs = UserPreferencesRepository(application)
|
||||
private val pairingDao get() = app.database.pairingDao()
|
||||
|
||||
val onboardingComplete: StateFlow<Boolean> =
|
||||
prefs.onboardingComplete.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false,
|
||||
)
|
||||
|
||||
val spotifyAccessToken: StateFlow<String?> =
|
||||
prefs.spotifyAccessToken.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
null,
|
||||
)
|
||||
|
||||
private val context get() = getApplication<Application>()
|
||||
|
||||
/**
|
||||
* When local [playlistId] has no track rows (common after sync list-only or unchanged snapshot),
|
||||
* fetches [PlaylistRepository.syncPlaylistDetail] once so pairing UI and folder pairing see tracks.
|
||||
*/
|
||||
fun ensurePlaylistTracksLoaded(playlistId: String) {
|
||||
viewModelScope.launch {
|
||||
if (spotifyAccessToken.value.isNullOrBlank()) {
|
||||
Log.d(TAG, "ensurePlaylistTracksLoaded: no token, skip fetch for $playlistId")
|
||||
return@launch
|
||||
}
|
||||
loadJukeboxTracksEnsuringDetail(playlistId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadJukeboxTracksEnsuringDetail(playlistId: String): List<TrackRow> {
|
||||
var rows = withContext(Dispatchers.IO) { app.playlistRepository.getTracks(playlistId) }
|
||||
if (rows.isNotEmpty()) {
|
||||
return rows
|
||||
}
|
||||
return detailMutexFor(playlistId).withLock {
|
||||
rows = withContext(Dispatchers.IO) { app.playlistRepository.getTracks(playlistId) }
|
||||
if (rows.isNotEmpty()) {
|
||||
Log.d(TAG, "jukebox cache filled while waiting for detail mutex playlistId=$playlistId")
|
||||
return@withLock rows
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"No tracks in jukebox cache for playlistId=$playlistId - calling syncPlaylistDetail (GET /playlists/{id})",
|
||||
)
|
||||
app.setSpotifyAccessTokenForApi(spotifyAccessToken.value)
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
app.playlistRepository.syncPlaylistDetail(playlistId)
|
||||
} catch (e: LockstepApiException) {
|
||||
Log.e(TAG, "syncPlaylistDetail API error for $playlistId", e)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "syncPlaylistDetail IO error for $playlistId", e)
|
||||
}
|
||||
}
|
||||
rows = withContext(Dispatchers.IO) { app.playlistRepository.getTracks(playlistId) }
|
||||
Log.d(TAG, "After syncPlaylistDetail: track row count=${rows.size} for $playlistId")
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
spotifyAccessToken.collect { token ->
|
||||
app.setSpotifyAccessTokenForApi(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun observePlaylists() = app.playlistRepository.observePlaylists()
|
||||
|
||||
fun observeJukeboxTracks(playlistId: String) =
|
||||
app.playlistRepository.observeTracks(playlistId)
|
||||
|
||||
fun observePairings(playlistId: String) = pairingDao.observeForPlaylist(playlistId)
|
||||
|
||||
suspend fun hasPairedTracks(playlistId: String): Boolean = pairingDao.countPaired(playlistId) > 0
|
||||
|
||||
suspend fun syncJukeboxIfToken(): String? {
|
||||
val token = spotifyAccessToken.value
|
||||
if (token.isNullOrBlank()) {
|
||||
return "Not signed in"
|
||||
}
|
||||
app.setSpotifyAccessTokenForApi(token)
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
app.playlistRepository.syncInitial()
|
||||
null
|
||||
} catch (e: LockstepApiException) {
|
||||
e.message ?: "Sync failed"
|
||||
} catch (e: IOException) {
|
||||
e.message ?: "Sync failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSpotifyAccessToken(token: String?) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSpotifyAccessToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upsertPairing(
|
||||
playlistId: String,
|
||||
trackId: String,
|
||||
localUri: String?,
|
||||
pairingError: String?,
|
||||
) {
|
||||
pairingDao.upsert(
|
||||
TrackPairingEntity(
|
||||
playlistId = playlistId,
|
||||
trackId = trackId,
|
||||
localUri = localUri,
|
||||
pairingError = pairingError,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun pairTrackFromUri(
|
||||
playlistId: String,
|
||||
track: TrackRow,
|
||||
uri: Uri,
|
||||
onResult: (paired: Boolean, message: String?) -> Unit,
|
||||
) {
|
||||
val trackId = track.trackId
|
||||
if (trackId.isNullOrBlank()) {
|
||||
onResult(false, "No Spotify track id for this row")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val err = withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, uri) }
|
||||
if (err != null) {
|
||||
upsertPairing(playlistId, trackId, null, err)
|
||||
onResult(false, err)
|
||||
} else {
|
||||
upsertPairing(playlistId, trackId, uri.toString(), null)
|
||||
onResult(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads playlist tracks from the jukebox DB on a background thread (not from Compose state) so folder
|
||||
* pairing is never run against a stale or empty list.
|
||||
*/
|
||||
fun pairPlaylistFromFolder(
|
||||
playlistId: String,
|
||||
treeUri: Uri,
|
||||
onFinished: (FolderPairingResult) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
Log.d(TAG, "pairPlaylistFromFolder playlistId=$playlistId treeUri=$treeUri (will load tracks from jukebox DB)")
|
||||
val persistFlags =
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(treeUri, persistFlags)
|
||||
Log.d(TAG, "takePersistableUriPermission ok")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "takePersistableUriPermission failed — listing may still work from picker grant", e)
|
||||
}
|
||||
|
||||
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
||||
val withId = tracks.count { !it.trackId.isNullOrBlank() }
|
||||
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
|
||||
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
|
||||
Log.d(
|
||||
TAG,
|
||||
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
|
||||
"withTitle=$withTitle withArtist=$withArtist",
|
||||
)
|
||||
tracks.take(12).forEach { row ->
|
||||
Log.d(
|
||||
TAG,
|
||||
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
|
||||
"artist='${row.artistName}' durationMs=${row.durationMs}",
|
||||
)
|
||||
}
|
||||
if (tracks.size > 12) {
|
||||
Log.d(TAG, " … ${tracks.size - 12} more rows omitted from log")
|
||||
}
|
||||
|
||||
val (mp3Total, pool) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
|
||||
Log.d(TAG, "pairPlaylistFromFolder scan returned mp3Count=${mp3s.size}")
|
||||
val p =
|
||||
mp3s.map { uri ->
|
||||
val (id3Title, id3Artist) = Mp3EmbeddedMetadata.readTitleAndArtist(context, uri)
|
||||
Mp3FolderCandidate(
|
||||
uri = uri,
|
||||
fileBaseName = mp3DisplayNameFromUri(uri).substringBeforeLast('.'),
|
||||
id3Title = id3Title,
|
||||
id3Artist = id3Artist,
|
||||
)
|
||||
}.toMutableList()
|
||||
Pair(mp3s.size, p)
|
||||
}
|
||||
|
||||
if (pool.isEmpty()) {
|
||||
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
|
||||
onFinished(
|
||||
FolderPairingResult(
|
||||
paired = 0,
|
||||
failed = 0,
|
||||
jukeboxRowCount = tracks.size,
|
||||
mp3Count = mp3Total,
|
||||
skippedNoSpotifyTrackId = 0,
|
||||
),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
|
||||
"files have title or artist from tags",
|
||||
)
|
||||
|
||||
var paired = 0
|
||||
var failed = 0
|
||||
var skippedNoSpotifyTrackId = 0
|
||||
for (track in tracks.sortedBy { it.position }) {
|
||||
val tid = track.trackId
|
||||
if (tid.isNullOrBlank()) {
|
||||
skippedNoSpotifyTrackId++
|
||||
failed++
|
||||
Log.d(
|
||||
TAG,
|
||||
"skip row pos=${track.position}: no Spotify track id " +
|
||||
"(title='${track.trackName}' artist='${track.artistName}')",
|
||||
)
|
||||
continue
|
||||
}
|
||||
val title = track.trackName.orEmpty()
|
||||
val artist = track.artistName.orEmpty()
|
||||
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
|
||||
if (pick == null) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
val err =
|
||||
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
|
||||
if (err != null) {
|
||||
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
|
||||
upsertPairing(playlistId, tid, null, err)
|
||||
failed++
|
||||
} else {
|
||||
upsertPairing(playlistId, tid, pick.toString(), null)
|
||||
paired++
|
||||
pool.removeAll { it.uri == pick }
|
||||
}
|
||||
}
|
||||
Log.d(
|
||||
TAG,
|
||||
"pairPlaylistFromFolder done paired=$paired failed=$failed " +
|
||||
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
|
||||
)
|
||||
onFinished(
|
||||
FolderPairingResult(
|
||||
paired = paired,
|
||||
failed = failed,
|
||||
jukeboxRowCount = tracks.size,
|
||||
mp3Count = mp3Total,
|
||||
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user