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.playback.TrackBoundaryEvent import at.lockstep.player.util.BeatAnnotationStorage 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() 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 = prefs.onboardingComplete.stateIn( viewModelScope, SharingStarted.Eagerly, false, ) val spotifyAccessToken: StateFlow = prefs.spotifyAccessToken.stateIn( viewModelScope, SharingStarted.Eagerly, null, ) val annotationMode: StateFlow = prefs.annotationMode.stateIn( viewModelScope, SharingStarted.Eagerly, false, ) fun setAnnotationMode(enabled: Boolean) { viewModelScope.launch { prefs.setAnnotationMode(enabled) } } private val context get() = getApplication() /** * 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 { 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 getPlaylistDisplayName(playlistId: String): String = withContext(Dispatchers.IO) { app.playlistRepository.getPlaylists() .find { it.id == playlistId } ?.name ?.trim() .orEmpty() .ifBlank { "playlist" } } /** * Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described * by [event]. Skips when [beatTimesMs] is empty. */ fun persistBeatAnnotation( playlistId: String, playlistDisplayName: String, event: TrackBoundaryEvent, beatTimesMs: List, ) { if (beatTimesMs.isEmpty()) { return } viewModelScope.launch(Dispatchers.IO) { val pairing = pairingDao.findForTrack(playlistId, event.trackId) val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri) val contentId = docId.ifBlank { event.trackId } BeatAnnotationStorage.writeAnnotationsFile( context = getApplication(), playlistDisplayName = playlistDisplayName, trackQueueIndex0Based = event.queueIndex, contentId = contentId, title = event.title, artist = event.artist, beatTimesMs = beatTimesMs, ) } } 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.syncDelta(false) null } catch (e: LockstepApiException) { e.message ?: "Sync failed" } catch (e: IOException) { e.message ?: "Sync failed" } } } suspend fun syncPlaylistDetailForLibraryOpen(playlistId: String): String? { val token = spotifyAccessToken.value if (token.isNullOrBlank()) { return "Not signed in" } app.setSpotifyAccessTokenForApi(token) return withContext(Dispatchers.IO) { if (app.playlistRepository.getTracks(playlistId).isNotEmpty()) { return@withContext null } try { app.playlistRepository.syncPlaylistDetail(playlistId) null } catch (e: LockstepApiException) { e.message ?: "Load failed" } catch (e: IOException) { e.message ?: "Load failed" } } } fun completeOnboarding() { viewModelScope.launch { prefs.setOnboardingComplete(true) } } fun saveSpotifyAccessToken(token: String?) { viewModelScope.launch { prefs.setSpotifyAccessToken(token) } } /** Clears the Spotify token and shows the first-run flow again so you can sign in with a fresh token. */ fun logoutSpotifyAndRestartOnboarding() { viewModelScope.launch { prefs.setSpotifyAccessToken(null) prefs.setOnboardingComplete(false) } } 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, ), ) } } }