feat: upload collected run data to api

This commit is contained in:
2026-05-30 19:47:58 +02:00
parent 7d9654aa7e
commit 45c4980c05
8 changed files with 220 additions and 9 deletions

View File

@@ -6,8 +6,10 @@ import android.app.NotificationManager
import android.os.Build
import at.lockstep.jukebox.Jukebox
import at.lockstep.jukebox.PlaylistRepository
import at.lockstep.player.data.MetadataSyncClient
import at.lockstep.player.data.db.AppDatabase
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicReference
class LockstepApplication : Application() {
@@ -15,6 +17,13 @@ class LockstepApplication : Application() {
val database: AppDatabase by lazy { AppDatabase.getInstance(this) }
val metadataSyncClient: MetadataSyncClient by lazy {
MetadataSyncClient(
OkHttpClient(),
BuildConfig.LOCKSTEP_API_BASE_URL.trimEnd('/'),
)
}
val playlistRepository: PlaylistRepository by lazy {
Jukebox.playlistRepository(
this,

View File

@@ -8,6 +8,7 @@ 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
@@ -28,6 +29,8 @@ 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
@@ -38,6 +41,7 @@ class LockstepViewModel(
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>()
@@ -46,6 +50,7 @@ class LockstepViewModel(
}
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(
@@ -207,11 +212,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile(
context = getApplication(),
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
trackId = event.trackId,
metaContentUri = meta,
title = event.title,
artist = event.artist,
@@ -237,11 +242,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile(
context = getApplication(),
writeRunDataAndRecordMetadata(
runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex,
trackId = trackId,
metaContentUri = meta,
title = title,
artist = artist,
@@ -250,6 +255,99 @@ class LockstepViewModel(
}
}
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()) {

View File

@@ -0,0 +1,53 @@
package at.lockstep.player.data
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
class MetadataSyncClient(
private val httpClient: OkHttpClient,
private val baseUrl: String,
) {
@Throws(IOException::class)
fun uploadCollection(
accessToken: String,
trackId: String,
type: String,
version: Int,
collection: JSONObject,
) {
val payload =
JSONObject()
.apply {
put("trackId", trackId)
put("type", type)
put("version", version)
put("collection", collection)
}.toString()
val request =
Request.Builder()
.url("$baseUrl/metadata")
.addHeader("Authorization", "Bearer $accessToken")
.post(payload.toRequestBody(JSON_MEDIA))
.build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return
}
val bodyText = response.body?.string().orEmpty()
val message =
runCatching { JSONObject(bodyText).optString("error") }
.getOrNull()
?.takeIf { it.isNotBlank() }
?: response.message
throw IOException(message)
}
}
companion object {
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
}
}

View File

@@ -6,14 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [TrackPairingEntity::class],
version = 2,
entities = [
TrackPairingEntity::class,
FileMetadataEntity::class,
],
version = 4,
exportSchema = false,
)
abstract class AppDatabase :
RoomDatabase() {
abstract fun pairingDao(): PairingDao
abstract fun fileMetadataDao(): FileMetadataDao
companion object {
private const val DB_NAME = "lockstep.db"

View File

@@ -0,0 +1,17 @@
package at.lockstep.player.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface FileMetadataDao {
@Insert
suspend fun insert(row: FileMetadataEntity): Long
@Query("SELECT * FROM file_metadata WHERE synced = 0 ORDER BY id ASC")
suspend fun listUnsynced(): List<FileMetadataEntity>
@Query("UPDATE file_metadata SET synced = 1 WHERE id = :id")
suspend fun markSynced(id: Long)
}

View File

@@ -0,0 +1,23 @@
package at.lockstep.player.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "file_metadata")
data class FileMetadataEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/** Content or file URI of the persisted JSON. */
val fileUri: String,
/** Spotify track id for the collected run. */
val trackId: String,
/** e.g. [TYPE_COLLECTION] for run-data JSON files. */
val type: String,
/** [BuildConfig.VERSION_CODE] at write time. */
val version: Int,
val synced: Boolean = false,
) {
companion object {
const val TYPE_COLLECTION = "collection"
}
}

View File

@@ -49,9 +49,13 @@ fun LibraryScreen(
LaunchedEffect(token) {
if (token.isNullOrBlank()) return@LaunchedEffect
val err = viewModel.syncJukeboxIfToken()
if (err != null) {
Toast.makeText(context, err, Toast.LENGTH_LONG).show()
val errors =
listOfNotNull(
viewModel.syncJukeboxIfToken(),
viewModel.syncPendingMetadata(),
)
if (errors.isNotEmpty()) {
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
}
}