feat: fetch beat metadata from api
This commit is contained in:
@@ -7,6 +7,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import at.lockstep.jukebox.api.LockstepApiException
|
import at.lockstep.jukebox.api.LockstepApiException
|
||||||
import at.lockstep.jukebox.db.TrackRow
|
import at.lockstep.jukebox.db.TrackRow
|
||||||
|
import at.lockstep.player.data.MetadataFetchResult
|
||||||
import at.lockstep.player.data.UserPreferencesRepository
|
import at.lockstep.player.data.UserPreferencesRepository
|
||||||
import at.lockstep.player.data.db.FileMetadataEntity
|
import at.lockstep.player.data.db.FileMetadataEntity
|
||||||
import at.lockstep.player.data.db.TrackPairingEntity
|
import at.lockstep.player.data.db.TrackPairingEntity
|
||||||
@@ -349,6 +350,109 @@ class LockstepViewModel(
|
|||||||
syncMetadataEntry(entry.copy(id = id))
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun syncPendingMetadata(): String? {
|
suspend fun syncPendingMetadata(): String? {
|
||||||
val token = spotifyAccessToken.value
|
val token = spotifyAccessToken.value
|
||||||
if (token.isNullOrBlank()) {
|
if (token.isNullOrBlank()) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package at.lockstep.player.data
|
package at.lockstep.player.data
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -7,10 +8,56 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
|
sealed class MetadataFetchResult {
|
||||||
|
data class Found(val collection: JSONObject) : MetadataFetchResult()
|
||||||
|
|
||||||
|
data object NotFound : MetadataFetchResult()
|
||||||
|
}
|
||||||
|
|
||||||
class MetadataSyncClient(
|
class MetadataSyncClient(
|
||||||
private val httpClient: OkHttpClient,
|
private val httpClient: OkHttpClient,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
) {
|
) {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun fetchMetadata(
|
||||||
|
accessToken: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
): MetadataFetchResult {
|
||||||
|
val url =
|
||||||
|
baseUrl.toHttpUrl()
|
||||||
|
.newBuilder()
|
||||||
|
.addPathSegments("metadata")
|
||||||
|
.addQueryParameter("trackId", trackId)
|
||||||
|
.addQueryParameter("type", type)
|
||||||
|
.build()
|
||||||
|
val request =
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "Bearer $accessToken")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 404) {
|
||||||
|
return MetadataFetchResult.NotFound
|
||||||
|
}
|
||||||
|
val bodyText = response.body?.string().orEmpty()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val message =
|
||||||
|
runCatching { JSONObject(bodyText).optString("error") }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: response.message
|
||||||
|
throw IOException(message)
|
||||||
|
}
|
||||||
|
val collection =
|
||||||
|
runCatching { JSONObject(bodyText).getJSONObject("collection") }
|
||||||
|
.getOrNull()
|
||||||
|
?: throw IOException("Missing collection in response")
|
||||||
|
return MetadataFetchResult.Found(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun uploadCollection(
|
fun uploadCollection(
|
||||||
accessToken: String,
|
accessToken: String,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
|
|||||||
TrackPairingEntity::class,
|
TrackPairingEntity::class,
|
||||||
FileMetadataEntity::class,
|
FileMetadataEntity::class,
|
||||||
],
|
],
|
||||||
version = 5,
|
version = 6,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase :
|
abstract class AppDatabase :
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ interface FileMetadataDao {
|
|||||||
@Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1")
|
@Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1")
|
||||||
suspend fun findByTrackIdAndType(trackId: String, type: String): FileMetadataEntity?
|
suspend fun findByTrackIdAndType(trackId: String, type: String): FileMetadataEntity?
|
||||||
|
|
||||||
|
@Query("UPDATE file_metadata SET lastFetchAttemptAt = :attemptAt WHERE id = :id")
|
||||||
|
suspend fun recordFetchAttempt(id: Long, attemptAt: Long)
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt WHERE id = :id",
|
"UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt, lastFetchAttemptAt = NULL WHERE id = :id",
|
||||||
)
|
)
|
||||||
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
|
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ data class FileMetadataEntity(
|
|||||||
val synced: Boolean = false,
|
val synced: Boolean = false,
|
||||||
/** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */
|
/** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */
|
||||||
val lastSyncedAt: Long? = null,
|
val lastSyncedAt: Long? = null,
|
||||||
|
/** Epoch millis of the last server fetch attempt when beat metadata was unavailable (404). */
|
||||||
|
val lastFetchAttemptAt: Long? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val TYPE_COLLECTION = "collection"
|
const val TYPE_COLLECTION = "collection"
|
||||||
|
|||||||
@@ -213,6 +213,10 @@ fun NowPlayingRoute(
|
|||||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(playlistId, playlistDisplayName) {
|
||||||
|
viewModel.fetchBeatsMetadataForPlaylist(playlistId, playlistDisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(playback) {
|
LaunchedEffect(playback) {
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
service.uiState.collect { p ->
|
service.uiState.collect { p ->
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ object BeatAnnotationStorage {
|
|||||||
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes server-fetched beat JSON under Documents/Lockstep/Beats/{playlist_name}/. */
|
||||||
|
fun writeBeatsFileFromJson(
|
||||||
|
context: Context,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
json: JSONObject,
|
||||||
|
): Uri? {
|
||||||
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
|
val fileName = "$suffix.json"
|
||||||
|
val jsonString = json.toString(2)
|
||||||
|
val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName)
|
||||||
|
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
|
||||||
|
}
|
||||||
|
|
||||||
fun buildAnnotationJson(
|
fun buildAnnotationJson(
|
||||||
contentId: String,
|
contentId: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user