feat: upload collected run data to api
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user