diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index 45cd80f..c692475 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -7,6 +7,7 @@ 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 @@ -349,6 +350,109 @@ class LockstepViewModel( 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? { val token = spotifyAccessToken.value if (token.isNullOrBlank()) { diff --git a/app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt b/app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt index 4a5550b..e6c828d 100644 --- a/app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt +++ b/app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt @@ -1,5 +1,6 @@ package at.lockstep.player.data +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -7,10 +8,56 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.io.IOException +sealed class MetadataFetchResult { + data class Found(val collection: JSONObject) : MetadataFetchResult() + + data object NotFound : MetadataFetchResult() +} + class MetadataSyncClient( private val httpClient: OkHttpClient, 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) fun uploadCollection( accessToken: String, diff --git a/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt b/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt index 369afbf..184129d 100644 --- a/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt +++ b/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt @@ -10,7 +10,7 @@ import androidx.room.RoomDatabase TrackPairingEntity::class, FileMetadataEntity::class, ], - version = 5, + version = 6, exportSchema = false, ) abstract class AppDatabase : diff --git a/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt b/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt index 724cb1a..7824c04 100644 --- a/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt +++ b/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt @@ -18,8 +18,11 @@ interface FileMetadataDao { @Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1") 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( - "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) } diff --git a/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt b/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt index 34c2b41..214cb49 100644 --- a/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt +++ b/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt @@ -18,6 +18,8 @@ data class FileMetadataEntity( val synced: Boolean = false, /** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */ val lastSyncedAt: Long? = null, + /** Epoch millis of the last server fetch attempt when beat metadata was unavailable (404). */ + val lastFetchAttemptAt: Long? = null, ) { companion object { const val TYPE_COLLECTION = "collection" diff --git a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt index 18612a4..9d6e324 100644 --- a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt @@ -213,6 +213,10 @@ fun NowPlayingRoute( playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) } + LaunchedEffect(playlistId, playlistDisplayName) { + viewModel.fetchBeatsMetadataForPlaylist(playlistId, playlistDisplayName) + } + LaunchedEffect(playback) { val service = playback ?: return@LaunchedEffect service.uiState.collect { p -> diff --git a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt index d0f3231..96282bc 100644 --- a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt +++ b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt @@ -48,6 +48,20 @@ object BeatAnnotationStorage { 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( contentId: String, title: String,