2026-05-14 02:43:49 +02:00
|
|
|
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
|
2026-05-31 11:41:06 +02:00
|
|
|
import at.lockstep.player.data.MetadataFetchResult
|
2026-05-14 02:43:49 +02:00
|
|
|
import at.lockstep.player.data.UserPreferencesRepository
|
2026-05-30 19:47:58 +02:00
|
|
|
import at.lockstep.player.data.db.FileMetadataEntity
|
2026-05-14 02:43:49 +02:00
|
|
|
import at.lockstep.player.data.db.TrackPairingEntity
|
|
|
|
|
import at.lockstep.player.util.AudioUriValidator
|
2026-05-15 09:03:20 +02:00
|
|
|
import at.lockstep.player.playback.TrackBoundaryEvent
|
|
|
|
|
import at.lockstep.player.util.BeatAnnotationStorage
|
2026-05-14 02:43:49 +02:00
|
|
|
import at.lockstep.player.util.FolderMp3Scanner
|
2026-05-24 06:47:10 +02:00
|
|
|
import at.lockstep.player.util.MediaStoreMp3Scanner
|
2026-05-14 02:43:49 +02:00
|
|
|
import at.lockstep.player.util.Mp3EmbeddedMetadata
|
|
|
|
|
import at.lockstep.player.util.Mp3FolderCandidate
|
2026-05-24 07:17:51 +02:00
|
|
|
import at.lockstep.player.util.RunDataStorage
|
|
|
|
|
import at.lockstep.player.util.RunTrackDataSnapshot
|
2026-05-14 02:43:49 +02:00
|
|
|
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
|
2026-05-30 19:47:58 +02:00
|
|
|
import org.json.JSONObject
|
|
|
|
|
import java.io.File
|
2026-05-14 02:43:49 +02:00
|
|
|
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"
|
2026-05-30 19:47:58 +02:00
|
|
|
private const val METADATA_TAG = "LockstepMetadata"
|
2026-05-14 02:43:49 +02:00
|
|
|
|
|
|
|
|
/** 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()
|
2026-05-30 19:47:58 +02:00
|
|
|
private val fileMetadataDao get() = app.database.fileMetadataDao()
|
2026-05-14 02:43:49 +02:00
|
|
|
|
|
|
|
|
val onboardingComplete: StateFlow<Boolean> =
|
|
|
|
|
prefs.onboardingComplete.stateIn(
|
|
|
|
|
viewModelScope,
|
|
|
|
|
SharingStarted.Eagerly,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
val spotifyAccessToken: StateFlow<String?> =
|
|
|
|
|
prefs.spotifyAccessToken.stateIn(
|
|
|
|
|
viewModelScope,
|
|
|
|
|
SharingStarted.Eagerly,
|
|
|
|
|
null,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-15 09:03:20 +02:00
|
|
|
val annotationMode: StateFlow<Boolean> =
|
|
|
|
|
prefs.annotationMode.stateIn(
|
|
|
|
|
viewModelScope,
|
|
|
|
|
SharingStarted.Eagerly,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-24 07:17:51 +02:00
|
|
|
val collectRunData: StateFlow<Boolean> =
|
|
|
|
|
prefs.collectRunData.stateIn(
|
|
|
|
|
viewModelScope,
|
|
|
|
|
SharingStarted.Eagerly,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-15 09:03:20 +02:00
|
|
|
fun setAnnotationMode(enabled: Boolean) {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setAnnotationMode(enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 07:17:51 +02:00
|
|
|
fun setCollectRunData(enabled: Boolean) {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setCollectRunData(enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
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
|
|
|
|
|
|
2026-05-15 09:03:20 +02:00
|
|
|
suspend fun getPlaylistDisplayName(playlistId: String): String =
|
|
|
|
|
withContext(Dispatchers.IO) {
|
|
|
|
|
app.playlistRepository.getPlaylists()
|
|
|
|
|
.find { it.id == playlistId }
|
|
|
|
|
?.name
|
|
|
|
|
?.trim()
|
|
|
|
|
.orEmpty()
|
|
|
|
|
.ifBlank { "playlist" }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-31 10:21:31 +02:00
|
|
|
* 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.
|
2026-05-15 09:03:20 +02:00
|
|
|
*/
|
|
|
|
|
fun persistBeatAnnotation(
|
|
|
|
|
playlistId: String,
|
|
|
|
|
playlistDisplayName: String,
|
2026-05-30 19:55:42 +02:00
|
|
|
sessionFolder: String,
|
2026-05-15 09:03:20 +02:00
|
|
|
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 }
|
2026-05-31 10:21:31 +02:00
|
|
|
val appContext = getApplication<Application>()
|
|
|
|
|
val annotationUri =
|
2026-05-30 19:55:42 +02:00
|
|
|
BeatAnnotationStorage.writeAnnotationsFile(
|
2026-05-31 10:21:31 +02:00
|
|
|
context = appContext,
|
2026-05-30 19:55:42 +02:00
|
|
|
sessionFolder = sessionFolder,
|
|
|
|
|
playlistDisplayName = playlistDisplayName,
|
2026-05-31 12:10:02 +02:00
|
|
|
trackQueueIndex0Based = event.playlistPosition,
|
2026-05-30 19:55:42 +02:00
|
|
|
contentId = contentId,
|
|
|
|
|
title = event.title,
|
|
|
|
|
artist = event.artist,
|
|
|
|
|
beatTimesMs = beatTimesMs,
|
|
|
|
|
) ?: return@launch
|
2026-05-31 10:21:31 +02:00
|
|
|
recordMetadataEntry(
|
|
|
|
|
fileUri = annotationUri.toString(),
|
|
|
|
|
trackId = event.trackId,
|
|
|
|
|
type = FileMetadataEntity.TYPE_ANNOTATION,
|
|
|
|
|
)
|
|
|
|
|
val beatsUri =
|
|
|
|
|
BeatAnnotationStorage.writeBeatsFile(
|
|
|
|
|
context = appContext,
|
|
|
|
|
playlistDisplayName = playlistDisplayName,
|
2026-05-31 12:10:02 +02:00
|
|
|
trackQueueIndex0Based = event.playlistPosition,
|
2026-05-31 10:21:31 +02:00
|
|
|
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) {
|
2026-05-30 19:55:42 +02:00
|
|
|
syncMetadataEntry(entry.copy(id = id))
|
2026-05-15 09:03:20 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 10:21:31 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 07:17:51 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-05-30 19:47:58 +02:00
|
|
|
writeRunDataAndRecordMetadata(
|
2026-05-24 07:17:51 +02:00
|
|
|
runSessionFolder = runSessionFolder,
|
|
|
|
|
playlistDisplayName = playlistDisplayName,
|
2026-05-31 12:10:02 +02:00
|
|
|
trackQueueIndex0Based = event.playlistPosition,
|
2026-05-30 19:47:58 +02:00
|
|
|
trackId = event.trackId,
|
2026-05-24 07:17:51 +02:00
|
|
|
metaContentUri = meta,
|
2026-05-24 07:24:16 +02:00
|
|
|
title = event.title,
|
|
|
|
|
artist = event.artist,
|
2026-05-24 07:17:51 +02:00
|
|
|
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,
|
2026-05-24 07:24:16 +02:00
|
|
|
title: String,
|
|
|
|
|
artist: String,
|
2026-05-31 12:10:02 +02:00
|
|
|
playlistPosition: Int,
|
2026-05-24 07:17:51 +02:00
|
|
|
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
|
2026-05-30 19:47:58 +02:00
|
|
|
writeRunDataAndRecordMetadata(
|
2026-05-24 07:17:51 +02:00
|
|
|
runSessionFolder = runSessionFolder,
|
|
|
|
|
playlistDisplayName = playlistDisplayName,
|
2026-05-31 12:10:02 +02:00
|
|
|
trackQueueIndex0Based = playlistPosition,
|
2026-05-30 19:47:58 +02:00
|
|
|
trackId = trackId,
|
2026-05-24 07:17:51 +02:00
|
|
|
metaContentUri = meta,
|
2026-05-24 07:24:16 +02:00
|
|
|
title = title,
|
|
|
|
|
artist = artist,
|
2026-05-24 07:17:51 +02:00
|
|
|
snapshot = snapshot,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:47:58 +02:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 11:41:06 +02:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 11:53:20 +02:00
|
|
|
/** 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:47:58 +02:00
|
|
|
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) {
|
2026-05-30 19:55:42 +02:00
|
|
|
"Failed to sync $failures file(s)"
|
2026-05-30 19:47:58 +02:00
|
|
|
} 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,
|
|
|
|
|
)
|
2026-05-31 10:21:31 +02:00
|
|
|
fileMetadataDao.markSynced(entry.id, System.currentTimeMillis())
|
2026-05-30 19:47:58 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
suspend fun syncJukeboxIfToken(): String? {
|
|
|
|
|
val token = spotifyAccessToken.value
|
|
|
|
|
if (token.isNullOrBlank()) {
|
|
|
|
|
return "Not signed in"
|
|
|
|
|
}
|
|
|
|
|
app.setSpotifyAccessTokenForApi(token)
|
|
|
|
|
return withContext(Dispatchers.IO) {
|
|
|
|
|
try {
|
2026-05-14 03:03:17 +02:00
|
|
|
app.playlistRepository.syncDelta(false)
|
2026-05-14 02:43:49 +02:00
|
|
|
null
|
|
|
|
|
} catch (e: LockstepApiException) {
|
|
|
|
|
e.message ?: "Sync failed"
|
|
|
|
|
} catch (e: IOException) {
|
|
|
|
|
e.message ?: "Sync failed"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 03:03:17 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
fun completeOnboarding() {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setOnboardingComplete(true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun saveSpotifyAccessToken(token: String?) {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
prefs.setSpotifyAccessToken(token)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 03:03:17 +02:00
|
|
|
/** 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:43:49 +02:00
|
|
|
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)
|
2026-05-24 06:47:10 +02:00
|
|
|
logJukeboxTracks(tracks)
|
2026-05-14 02:43:49 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-05-24 06:47:10 +02:00
|
|
|
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 02:43:49 +02:00
|
|
|
|
2026-05-24 06:47:10 +02:00
|
|
|
/** 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))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 02:43:49 +02:00
|
|
|
|
2026-05-24 06:47:10 +02:00
|
|
|
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 ->
|
2026-05-14 02:43:49 +02:00
|
|
|
Log.d(
|
|
|
|
|
TAG,
|
2026-05-24 06:47:10 +02:00
|
|
|
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
|
|
|
|
|
"artist='${row.artistName}' durationMs=${row.durationMs}",
|
2026-05-14 02:43:49 +02:00
|
|
|
)
|
2026-05-24 06:47:10 +02:00
|
|
|
}
|
|
|
|
|
if (tracks.size > 12) {
|
|
|
|
|
Log.d(TAG, " … ${tracks.size - 12} more rows omitted from log")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 02:43:49 +02:00
|
|
|
|
2026-05-24 06:47:10 +02:00
|
|
|
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,
|
2026-05-14 02:43:49 +02:00
|
|
|
)
|
|
|
|
|
}
|
2026-05-24 06:47:10 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-05-14 02:43:49 +02:00
|
|
|
}
|
|
|
|
|
}
|