Files
lockstep-player/app/src/main/java/at/lockstep/player/LockstepViewModel.kt

783 lines
28 KiB
Kotlin

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.MetadataFetchResult
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<String, Mutex>()
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<Boolean> =
prefs.onboardingComplete.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
val spotifyAccessToken: StateFlow<String?> =
prefs.spotifyAccessToken.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null,
)
val annotationMode: StateFlow<Boolean> =
prefs.annotationMode.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
val collectRunData: StateFlow<Boolean> =
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<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 getPlaylistDisplayName(playlistId: String): String =
withContext(Dispatchers.IO) {
app.playlistRepository.getPlaylists()
.find { it.id == playlistId }
?.name
?.trim()
.orEmpty()
.ifBlank { "playlist" }
}
/**
* Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in)
* and canonical beat JSON under Documents/Lockstep/Beats/{playlist_name}/ (local file_metadata only).
* Skips when [beatTimesMs] is empty.
*/
fun persistBeatAnnotation(
playlistId: String,
playlistDisplayName: String,
sessionFolder: String,
event: TrackBoundaryEvent,
beatTimesMs: List<Long>,
) {
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 }
val appContext = getApplication<Application>()
val annotationUri =
BeatAnnotationStorage.writeAnnotationsFile(
context = appContext,
sessionFolder = sessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
contentId = contentId,
title = event.title,
artist = event.artist,
beatTimesMs = beatTimesMs,
) ?: return@launch
recordMetadataEntry(
fileUri = annotationUri.toString(),
trackId = event.trackId,
type = FileMetadataEntity.TYPE_ANNOTATION,
)
val beatsUri =
BeatAnnotationStorage.writeBeatsFile(
context = appContext,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
contentId = contentId,
title = event.title,
artist = event.artist,
beatTimesMs = beatTimesMs,
) ?: return@launch
recordOrUpdateMetadataEntry(
fileUri = beatsUri.toString(),
trackId = event.trackId,
type = FileMetadataEntity.TYPE_BEATS,
)
}
}
private suspend fun recordMetadataEntry(
fileUri: String,
trackId: String,
type: String,
) {
val now = System.currentTimeMillis()
val beats = type == FileMetadataEntity.TYPE_BEATS
val entry =
FileMetadataEntity(
fileUri = fileUri,
trackId = trackId,
type = type,
version = BuildConfig.VERSION_CODE,
synced = beats,
lastSyncedAt = if (beats) now else null,
)
val id = fileMetadataDao.insert(entry)
if (!beats) {
syncMetadataEntry(entry.copy(id = id))
}
}
private suspend fun recordOrUpdateMetadataEntry(
fileUri: String,
trackId: String,
type: String,
) {
val version = BuildConfig.VERSION_CODE
val now = System.currentTimeMillis()
val existing = fileMetadataDao.findByTrackIdAndType(trackId, type)
if (existing != null) {
fileMetadataDao.updateFile(existing.id, fileUri, version, now)
} else {
recordMetadataEntry(fileUri, trackId, type)
}
}
/**
* 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))
}
/**
* Fetches beat metadata from the server for each track in [playlistId] that has no local beats file.
* Records [FileMetadataEntity.lastFetchAttemptAt] when the server returns 404 so polling can retry later.
*/
fun fetchBeatsMetadataForPlaylist(
playlistId: String,
playlistDisplayName: String,
) {
viewModelScope.launch(Dispatchers.IO) {
val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return@launch
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
for (track in tracks) {
val trackId = track.trackId ?: continue
fetchBeatMetadataForTrack(
token = token,
trackId = trackId,
playlistDisplayName = playlistDisplayName,
queueIndex = track.position,
)
}
}
}
private suspend fun fetchBeatMetadataForTrack(
token: String,
trackId: String,
playlistDisplayName: String,
queueIndex: Int,
) {
val existing =
fileMetadataDao.findByTrackIdAndType(trackId, FileMetadataEntity.TYPE_BEATS)
if (existing != null && existing.fileUri.isNotBlank()) {
return
}
if (existing?.lastFetchAttemptAt != null) {
return
}
val attemptAt = System.currentTimeMillis()
val result =
try {
app.metadataSyncClient.fetchMetadata(
accessToken = token,
trackId = trackId,
type = FileMetadataEntity.TYPE_BEATS,
)
} catch (e: IOException) {
Log.w(METADATA_TAG, "beat metadata fetch failed trackId=$trackId", e)
return
}
when (result) {
is MetadataFetchResult.Found -> {
val beatsUri =
BeatAnnotationStorage.writeBeatsFileFromJson(
context = getApplication(),
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex,
json = result.collection,
) ?: return
val now = System.currentTimeMillis()
if (existing != null) {
fileMetadataDao.updateFile(existing.id, beatsUri.toString(), BuildConfig.VERSION_CODE, now)
} else {
fileMetadataDao.insert(
FileMetadataEntity(
fileUri = beatsUri.toString(),
trackId = trackId,
type = FileMetadataEntity.TYPE_BEATS,
version = BuildConfig.VERSION_CODE,
synced = true,
lastSyncedAt = now,
),
)
}
Log.d(METADATA_TAG, "fetched beat metadata from server trackId=$trackId")
}
MetadataFetchResult.NotFound -> {
recordBeatMetadataUnavailable(trackId, attemptAt, existing)
Log.d(METADATA_TAG, "beat metadata not on server trackId=$trackId")
}
}
}
private suspend fun recordBeatMetadataUnavailable(
trackId: String,
attemptAt: Long,
existing: FileMetadataEntity?,
) {
if (existing != null) {
fileMetadataDao.recordFetchAttempt(existing.id, attemptAt)
} else {
fileMetadataDao.insert(
FileMetadataEntity(
fileUri = "",
trackId = trackId,
type = FileMetadataEntity.TYPE_BEATS,
version = BuildConfig.VERSION_CODE,
synced = false,
lastFetchAttemptAt = attemptAt,
),
)
}
}
/** Set before popping Now Playing; [consumeSuppressNextLibraryMetadataSync] skips one Library batch sync. */
@Volatile
private var suppressNextLibraryMetadataSync = false
fun suppressNextLibraryMetadataSync() {
suppressNextLibraryMetadataSync = true
}
fun consumeSuppressNextLibraryMetadataSync(): Boolean {
if (!suppressNextLibraryMetadataSync) {
return false
}
suppressNextLibraryMetadataSync = false
return true
}
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 file(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, System.currentTimeMillis())
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<TrackRow>) {
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<TrackRow>,
pool: MutableList<Mp3FolderCandidate>,
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,
)
}
}