2 Commits

Author SHA1 Message Date
f7c5b932e9 feat: fetch beat metadata from api 2026-05-31 11:41:06 +02:00
633afd115d ui: re-brand to Pasada 2026-05-31 10:22:58 +02:00
8 changed files with 183 additions and 9 deletions

View File

@@ -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()) {

View File

@@ -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,

View File

@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
TrackPairingEntity::class,
FileMetadataEntity::class,
],
version = 5,
version = 6,
exportSchema = false,
)
abstract class AppDatabase :

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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 ->

View File

@@ -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,

View File

@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Lockstep</string>
<string name="app_name">Pasada</string>
<string name="cd_previous_track">Previous track</string>
<string name="cd_play_pause">Play or pause</string>
<string name="cd_next_track">Next track</string>
<string name="notification_channel_playback_name">Playback</string>
<string name="notification_channel_playback_description">Now playing controls while Lockstep runs</string>
<string name="notification_channel_playback_description">Now playing controls while Pasada runs</string>
<string name="notification_prev">Previous</string>
<string name="notification_next">Next</string>
<string name="notification_play_pause">Play or pause</string>
<string name="notification_loading_playlist">Loading playlist…</string>
<string name="onboarding_title">Welcome to Lockstep</string>
<string name="onboarding_notifications_body">Lockstep shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
<string name="onboarding_title">Welcome to Pasada</string>
<string name="onboarding_notifications_body">Pasada shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
<string name="onboarding_notifications_cta">Continue and ask for notification permission</string>
<string name="onboarding_spotify_body">Sign in with Spotify via the Lockstep web login. When your browser returns to this app, your access token is stored locally.</string>
<string name="onboarding_spotify_body">Sign in with Spotify via the Pasada web login. When your browser returns to this app, your access token is stored locally.</string>
<string name="onboarding_spotify_open_browser">Open Spotify login</string>
<string name="onboarding_spotify_connected">Account linked — you can continue.</string>
<string name="onboarding_continue_signed_in">Continue</string>
@@ -31,7 +31,7 @@
<string name="settings_annotation_mode">Annotation mode</string>
<string name="settings_annotation_mode_help">When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing.</string>
<string name="settings_collect_run_data">Collect run data</string>
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Lockstep/ under a timestamped run folder.</string>
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Pasada/ under a timestamped run folder.</string>
<string name="annotation_title">Beat annotation</string>
<string name="annotation_subtitle">Tap on each beat; times use the same clock as playback (ExoPlayer position).</string>
<string name="annotation_tap_area_label">Tap here on the beat</string>
@@ -47,7 +47,7 @@
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
<string name="pairing_jukebox_empty">No tracks loaded for this playlist yet. Open Playlists and wait for sync, then try again.</string>
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See LockstepPairing logs.</string>
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See PasadaPairing logs.</string>
<string name="pairing_folder_ok">Paired %1$d track(s).</string>
<string name="pairing_folder_mixed_result">Paired %1$d track(s); %2$d still unmatched or unreadable.</string>
<string name="pairing_unknown_track">(removed or unknown track)</string>