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.FileMetadataEntity 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.MediaStoreMp3Scanner import at.lockstep.player.util.Mp3EmbeddedMetadata import at.lockstep.player.util.Mp3FolderCandidate import at.lockstep.player.util.RunDataStorage import at.lockstep.player.util.RunTrackDataSnapshot 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 org.json.JSONObject import java.io.File 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" private const val METADATA_TAG = "LockstepMetadata" /** 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() private val fileMetadataDao get() = app.database.fileMetadataDao() 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, ) val collectRunData: StateFlow = prefs.collectRunData.stateIn( viewModelScope, SharingStarted.Eagerly, false, ) fun setAnnotationMode(enabled: Boolean) { viewModelScope.launch { prefs.setAnnotationMode(enabled) } } fun setCollectRunData(enabled: Boolean) { viewModelScope.launch { prefs.setCollectRunData(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, ) } } /** * Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped. * Skips when [samples] is empty or the track has no paired local URI. */ fun persistRunData( playlistId: String, playlistDisplayName: String, runSessionFolder: String, event: TrackBoundaryEvent, snapshot: RunTrackDataSnapshot, ) { if (snapshot.isEmpty()) { return } viewModelScope.launch(Dispatchers.IO) { val pairing = pairingDao.findForTrack(playlistId, event.trackId) val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch writeRunDataAndRecordMetadata( runSessionFolder = runSessionFolder, playlistDisplayName = playlistDisplayName, trackQueueIndex0Based = event.queueIndex, trackId = event.trackId, metaContentUri = meta, title = event.title, artist = event.artist, snapshot = snapshot, ) } } /** Flush in-progress run data when leaving Now Playing before a track boundary fires. */ fun persistRunDataForCurrentTrack( playlistId: String, playlistDisplayName: String, runSessionFolder: String, trackId: String, title: String, artist: String, queueIndex: Int, snapshot: RunTrackDataSnapshot, ) { if (snapshot.isEmpty()) { return } viewModelScope.launch(Dispatchers.IO) { val pairing = pairingDao.findForTrack(playlistId, trackId) val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch writeRunDataAndRecordMetadata( runSessionFolder = runSessionFolder, playlistDisplayName = playlistDisplayName, trackQueueIndex0Based = queueIndex, trackId = trackId, metaContentUri = meta, title = title, artist = artist, snapshot = snapshot, ) } } private suspend fun writeRunDataAndRecordMetadata( runSessionFolder: String, playlistDisplayName: String, trackQueueIndex0Based: Int, trackId: String, metaContentUri: String, title: String, artist: String, snapshot: RunTrackDataSnapshot, ) { val uri = RunDataStorage.writeRunDataFile( context = getApplication(), runSessionFolder = runSessionFolder, playlistDisplayName = playlistDisplayName, trackQueueIndex0Based = trackQueueIndex0Based, metaContentUri = metaContentUri, title = title, artist = artist, snapshot = snapshot, ) ?: return val entry = FileMetadataEntity( fileUri = uri.toString(), trackId = trackId, type = FileMetadataEntity.TYPE_COLLECTION, version = BuildConfig.VERSION_CODE, ) val id = fileMetadataDao.insert(entry) syncMetadataEntry(entry.copy(id = id)) } suspend fun syncPendingMetadata(): String? { val token = spotifyAccessToken.value if (token.isNullOrBlank()) { return null } return withContext(Dispatchers.IO) { val pending = fileMetadataDao.listUnsynced() if (pending.isEmpty()) { return@withContext null } val failures = pending.count { entry -> !syncMetadataEntry(entry) } if (failures > 0) { "Failed to sync $failures collection(s)" } else { null } } } private suspend fun syncMetadataEntry(entry: FileMetadataEntity): Boolean { val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return false val collection = withContext(Dispatchers.IO) { readCollectionJson(entry.fileUri) } ?: return false return withContext(Dispatchers.IO) { try { app.metadataSyncClient.uploadCollection( accessToken = token, trackId = entry.trackId, type = entry.type, version = entry.version, collection = collection, ) fileMetadataDao.markSynced(entry.id) true } catch (e: IOException) { Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e) false } } } private fun readCollectionJson(fileUri: String): JSONObject? { val uri = Uri.parse(fileUri) try { context.contentResolver.openInputStream(uri)?.use { stream -> return JSONObject(stream.bufferedReader().readText()) } } catch (e: Exception) { Log.w(METADATA_TAG, "readCollectionJson contentResolver failed uri=$fileUri", e) } return try { val path = uri.path ?: return null JSONObject(File(path).readText()) } catch (e: Exception) { Log.w(METADATA_TAG, "readCollectionJson file path failed uri=$fileUri", e) null } } 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) logJukeboxTracks(tracks) 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) } onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total)) } } /** Pair against all MP3s indexed on the device (Music, Downloads, etc.) — no folder picker needed. */ fun pairPlaylistFromDeviceAudio( playlistId: String, onFinished: (FolderPairingResult) -> Unit, ) { viewModelScope.launch { Log.d(TAG, "pairPlaylistFromDeviceAudio playlistId=$playlistId") val tracks = loadJukeboxTracksEnsuringDetail(playlistId) logJukeboxTracks(tracks) val (mp3Total, pool) = withContext(Dispatchers.IO) { val candidates = MediaStoreMp3Scanner.listMp3Candidates(context) Pair(candidates.size, candidates.toMutableList()) } onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total)) } } private fun logJukeboxTracks(tracks: List) { 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") } } private suspend fun matchPlaylistTracks( playlistId: String, tracks: List, pool: MutableList, mp3Total: Int, ): FolderPairingResult { if (pool.isEmpty()) { Log.w(TAG, "No MP3 candidates after scan — cannot pair") return FolderPairingResult( paired = 0, failed = 0, jukeboxRowCount = tracks.size, mp3Count = mp3Total, skippedNoSpotifyTrackId = 0, ) } 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, "playlist pairing done paired=$paired failed=$failed " + "skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total", ) return FolderPairingResult( paired = paired, failed = failed, jukeboxRowCount = tracks.size, mp3Count = mp3Total, skippedNoSpotifyTrackId = skippedNoSpotifyTrackId, ) } }