feat(android): MutationQueue PLAY_OFFLINE kind
Extends the offline mutation queue with the play_offline kind so the upcoming PlayEventsReporter can durably capture plays that complete without a successful live play_started, or whose live ended/skipped close failed. Replay re-fires POST /api/events with type=play_offline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+24
@@ -19,6 +19,7 @@ object MutationKind {
|
|||||||
const val QUARANTINE_UNFLAG: String = "quarantine_unflag"
|
const val QUARANTINE_UNFLAG: String = "quarantine_unflag"
|
||||||
const val PLAYLIST_APPEND: String = "playlist_append"
|
const val PLAYLIST_APPEND: String = "playlist_append"
|
||||||
const val QUARANTINE_FLAG: String = "quarantine_flag"
|
const val QUARANTINE_FLAG: String = "quarantine_flag"
|
||||||
|
const val PLAY_OFFLINE: String = "play_offline"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +126,13 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun enqueuePlayOffline(payload: PlayOfflinePayload): Long = dao.insert(
|
||||||
|
CachedMutationEntity(
|
||||||
|
kind = MutationKind.PLAY_OFFLINE,
|
||||||
|
payload = json.encodeToString(PlayOfflinePayload.serializer(), payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,3 +167,19 @@ data class QuarantineFlagPayload(
|
|||||||
val reason: String,
|
val reason: String,
|
||||||
val notes: String,
|
val notes: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.PLAY_OFFLINE`. The replayer
|
||||||
|
* re-fires `POST /api/events` with type=play_offline; the server
|
||||||
|
* records start+end from `atIso` (the original play-start time)
|
||||||
|
* + `durationPlayedMs`, applies the canonical skip rule, and
|
||||||
|
* advances #415 rotation when source is a system playlist.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayOfflinePayload(
|
||||||
|
val trackId: String,
|
||||||
|
val clientId: String,
|
||||||
|
val atIso: String,
|
||||||
|
val durationPlayedMs: Long,
|
||||||
|
val source: String? = null,
|
||||||
|
)
|
||||||
|
|||||||
+25
@@ -2,10 +2,12 @@ package com.fabledsword.minstrel.cache.mutations
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||||
|
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
@@ -61,6 +63,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val discoverApi: DiscoverApi = retrofit.create()
|
private val discoverApi: DiscoverApi = retrofit.create()
|
||||||
private val quarantineApi: QuarantineApi = retrofit.create()
|
private val quarantineApi: QuarantineApi = retrofit.create()
|
||||||
private val playlistsApi: PlaylistsApi = retrofit.create()
|
private val playlistsApi: PlaylistsApi = retrofit.create()
|
||||||
|
private val eventsApi: EventsApi = retrofit.create()
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -109,6 +112,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||||
|
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||||
else -> {
|
else -> {
|
||||||
// Unknown kind — drop the row by claiming success so a
|
// Unknown kind — drop the row by claiming success so a
|
||||||
// stale schema entry can't wedge the queue forever.
|
// stale schema entry can't wedge the queue forever.
|
||||||
@@ -217,4 +221,25 @@ class MutationReplayer @Inject constructor(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
||||||
|
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||||
|
return try {
|
||||||
|
eventsApi.playOffline(
|
||||||
|
PlayOfflineRequest(
|
||||||
|
trackId = decoded.trackId,
|
||||||
|
clientId = decoded.clientId,
|
||||||
|
at = decoded.atIso,
|
||||||
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
|
source = decoded.source,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: row stays queued; next drain pass retries.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user