Playback errors slice + scrubber polish + various polish #74

Merged
bvandeusen merged 13 commits from dev into main 2026-06-02 14:14:11 -04:00
29 changed files with 1408 additions and 82 deletions
+36 -3
View File
@@ -60,9 +60,35 @@ jobs:
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
# Job outputs propagate the computed release version to image-release
# so the bundled sidecar file matches what's baked into the APK —
# otherwise the server would report a different version string than
# the installed client and the update banner could thrash.
outputs:
version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# fetch-depth: 0 retrieves full history; default shallow clone
# would return 1 for `git rev-list --count HEAD`, breaking the
# iteration suffix.
fetch-depth: 0
- name: Compute release version
id: ver
shell: bash
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
- name: Cache Gradle dirs - name: Cache Gradle dirs
uses: actions/cache@v4 uses: actions/cache@v4
@@ -91,7 +117,10 @@ jobs:
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}" echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
- name: Build release APK - name: Build release APK
run: ./gradlew assembleRelease run: |
./gradlew assembleRelease \
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
# @v3 because Gitea Actions emulates GHES and the v2 artifact # @v3 because Gitea Actions emulates GHES and the v2 artifact
@@ -204,14 +233,18 @@ jobs:
- name: Stage bundled APK + version sidecar - name: Stage bundled APK + version sidecar
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
env:
# Pulled from android-release.outputs.version_name so the
# sidecar string the server hands clients matches the
# versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
run: | run: |
set -euxo pipefail set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}"
# The artifact lands as `app-release.apk` (the original Gradle # The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/ # output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version. # and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk mv client/app-release.apk client/minstrel.apk
echo "${TAG}" > client/minstrel.apk.version echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
ls -lh client/ ls -lh client/
- name: Build and push - name: Build and push
+13 -3
View File
@@ -21,8 +21,19 @@ android {
applicationId = "com.fabledsword.minstrel" applicationId = "com.fabledsword.minstrel"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 // versionName / versionCode are released-build values injected by
versionName = "0.1.0-native" // CI from the git tag + commit count. Local / debug builds fall
// back to "dev" so the About card reads honestly. Releases ship
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionCode=<commits>, which is monotonic forever and lets the
// shared isVersionNewer comparator distinguish two same-day
// re-cuts (the iteration suffix differs).
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
(project.findProperty("MINSTREL_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = versionCodeOverride ?: 1
versionName = versionNameOverride ?: "dev"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
@@ -139,7 +150,6 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.ui.text.google.fonts) implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.api.endpoints
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/playback-errors`. Reports a
* client-detected playback failure (zero-duration, decode error, ...)
* so the admin inbox surfaces it.
*
* Failures during dispatch are reported via the MutationQueue path
* for offline replay (see [com.fabledsword.minstrel.cache.mutations.MutationQueue.enqueuePlaybackErrorReport]).
*/
interface PlaybackErrorsApi {
@POST("api/playback-errors")
suspend fun report(@Body body: PlaybackErrorReportRequest)
}
/**
* POST body for `/api/playback-errors`. `kind` is one of
* "zero_duration" / "load_failed" / "stalled"; server validates against
* the CHECK constraint enum. `detail` is optional free-text (Media3
* error message, etc.). `clientId` reuses the existing playback
* client identifier the device already sends with `/api/plays/...`
* so support can correlate reports across surfaces.
*/
@kotlinx.serialization.Serializable
data class PlaybackErrorReportRequest(
@kotlinx.serialization.SerialName("track_id") val trackId: String,
val kind: String,
val detail: String? = null,
@kotlinx.serialization.SerialName("client_id") val clientId: String,
)
@@ -21,6 +21,7 @@ object MutationKind {
const val QUARANTINE_FLAG: String = "quarantine_flag" const val QUARANTINE_FLAG: String = "quarantine_flag"
const val PLAY_OFFLINE: String = "play_offline" const val PLAY_OFFLINE: String = "play_offline"
const val REQUEST_CANCEL: String = "request_cancel" const val REQUEST_CANCEL: String = "request_cancel"
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
} }
/** /**
@@ -144,6 +145,13 @@ class MutationQueue @Inject constructor(
), ),
), ),
) )
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAYBACK_ERROR_REPORT,
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
),
)
} }
/** /**
@@ -203,3 +211,20 @@ data class PlayOfflinePayload(
*/ */
@Serializable @Serializable
data class RequestCancelPayload(val requestId: String) data class RequestCancelPayload(val requestId: String)
/**
* Persisted payload for `MutationKind.PLAYBACK_ERROR_REPORT` — the
* `POST /api/playback-errors` call lost during a connectivity hiccup.
* The replayer re-fires the POST with this body. Server is naturally
* idempotent enough — multiple reports of the same (track, user,
* kind) become multiple rows in the admin inbox, which is acceptable
* (the admin can resolve them all with one action). `clientId` is
* the existing playback client identifier from AuthStore.
*/
@Serializable
data class PlaybackErrorReportPayload(
val trackId: String,
val kind: String,
val detail: String? = null,
val clientId: String,
)
@@ -5,6 +5,8 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi 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.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
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.api.endpoints.RequestsApi import com.fabledsword.minstrel.api.endpoints.RequestsApi
@@ -66,6 +68,7 @@ class MutationReplayer @Inject constructor(
private val playlistsApi: PlaylistsApi = retrofit.create() private val playlistsApi: PlaylistsApi = retrofit.create()
private val eventsApi: EventsApi = retrofit.create() private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create() private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val mutex = Mutex() private val mutex = Mutex()
@@ -116,6 +119,7 @@ class MutationReplayer @Inject constructor(
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload) MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload) MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload) MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(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.
@@ -258,4 +262,24 @@ class MutationReplayer @Inject constructor(
false false
} }
} }
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
return try {
playbackErrorsApi.report(
PlaybackErrorReportRequest(
trackId = decoded.trackId,
kind = decoded.kind,
detail = decoded.detail,
clientId = decoded.clientId,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
} }
@@ -109,8 +109,11 @@ private fun AlbumDetailStateContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel, playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController, navController: NavHostController,
) { ) {
Crossfade(targetState = state, label = "album-detail") { s -> // Crossfade keyed on `state::class` so a Success → fresh Success
when (s) { // refresh (same kind, new detail) doesn't re-fade the whole body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "album-detail") { _ ->
when (val s = state) {
is AlbumDetailUiState.Loading -> is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList() if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> EmptyState( is AlbumDetailUiState.Error -> EmptyState(
@@ -87,8 +87,11 @@ fun ArtistDetailScreen(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
) { ) {
Crossfade(targetState = state, label = "artist-detail") { s -> // Crossfade keyed on `state::class` so a Success → fresh
when (s) { // Success refresh (same kind, new detail) doesn't re-fade
// the body; only Loading ↔ Success ↔ Error animate.
Crossfade(targetState = state::class, label = "artist-detail") { _ ->
when (val s = state) {
is ArtistDetailUiState.Loading -> is ArtistDetailUiState.Loading ->
if (s.seed != null) { if (s.seed != null) {
SeededArtistLoading(s.seed) SeededArtistLoading(s.seed)
@@ -4,7 +4,6 @@ package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -22,11 +23,10 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -69,8 +69,12 @@ fun LibraryScreen(
navController: NavHostController, navController: NavHostController,
viewModel: LibraryViewModel = hiltViewModel(), viewModel: LibraryViewModel = hiltViewModel(),
) { ) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = LIBRARY_TABS val tabs = LIBRARY_TABS
// HorizontalPager owns the active-tab state — the TabRow reads
// pagerState.currentPage and animateScrollToPage drives it on tap,
// so swipe and tap stay in sync.
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -91,13 +95,13 @@ fun LibraryScreen(
}, },
) )
PrimaryScrollableTabRow( PrimaryScrollableTabRow(
selectedTabIndex = selectedTab, selectedTabIndex = pagerState.currentPage,
edgePadding = 16.dp, edgePadding = 16.dp,
) { ) {
tabs.forEachIndexed { index, label -> tabs.forEachIndexed { index, label ->
Tab( Tab(
selected = selectedTab == index, selected = pagerState.currentPage == index,
onClick = { selectedTab = index }, onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(label) }, text = { Text(label) },
) )
} }
@@ -105,8 +109,11 @@ fun LibraryScreen(
} }
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) { HorizontalPager(
when (selectedTab) { state = pagerState,
modifier = Modifier.fillMaxSize().padding(inner),
) { page ->
when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController) TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController) TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_HISTORY -> HistoryTab( TAB_HISTORY -> HistoryTab(
@@ -135,8 +142,13 @@ private fun ArtistsTab(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-artists") { s -> // Crossfade is keyed on `state::class` (not the whole state)
when (s) { // so a refresh that emits a fresh UiState.Success — same kind,
// new data — doesn't re-fade the entire grid. Only Loading ↔
// Success ↔ Error ↔ Empty transitions animate; row-level diff
// is owned by LazyVerticalGrid via its item keys.
Crossfade(targetState = state::class, label = "library-artists") { _ ->
when (val s = state) {
UiState.Loading -> SkeletonArtistsGrid() UiState.Loading -> SkeletonArtistsGrid()
UiState.Empty -> EmptyState( UiState.Empty -> EmptyState(
title = "No artists yet", title = "No artists yet",
@@ -163,8 +175,8 @@ private fun AlbumsTab(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) { PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-albums") { s -> Crossfade(targetState = state::class, label = "library-albums") { _ ->
when (s) { when (val s = state) {
UiState.Loading -> SkeletonAlbumsGrid() UiState.Loading -> SkeletonAlbumsGrid()
UiState.Empty -> EmptyState( UiState.Empty -> EmptyState(
title = "No albums yet", title = "No albums yet",
@@ -13,19 +13,23 @@ import javax.inject.Singleton
private const val DEBOUNCE_MS = 2_000L private const val DEBOUNCE_MS = 2_000L
/** /**
* Surfaces ExoPlayer track-load failures (404 / decoder failure / * Surfaces playback failures (load errors + zero-duration tracks) as
* premature EOS / network drop) as user-visible snackbar messages. * user-visible snackbar messages AND admin-inbox reports.
* *
* Without this the player just silently skips the dead track — the * Without the snackbar the player just silently skips the dead track —
* worst kind of bug, because the user can't tell "this file is * the worst kind of bug, because the user can't tell "this file is
* broken" from "the app is flaky". * broken" from "the app is flaky". Without the admin report the
* operator never finds out the track is bad and the next user hits
* the same wall.
* *
* Pattern mirrors Flutter's `playback_error_reporter.dart`: collect * The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
* per-error events from [PlayerController.playbackErrorEvents], * collect [PlayerController.playbackErrorEvents], debounce in a 2s
* debounce in a 2s window, and emit "Couldn't play 'X' — skipping" * window, emit "Couldn't play 'X' — skipping" for a single error or
* for a single error or "Skipped N unplayable tracks" when a burst * "Skipped N unplayable tracks" when a burst lands inside the window.
* lands inside the window. Stops the user from seeing a stack of N *
* toasts on a network blip that fails N tracks in a row. * The server report fires per event (no debounce) via
* [PlaybackErrorRepository], which handles the offline-first
* MutationQueue fallback per the standing rule for server writes.
* *
* ShellScaffold collects [messages] into its existing snackbar host * ShellScaffold collects [messages] into its existing snackbar host
* so the reporter doesn't need its own UI surface. Constructed at * so the reporter doesn't need its own UI surface. Constructed at
@@ -35,6 +39,7 @@ private const val DEBOUNCE_MS = 2_000L
@Singleton @Singleton
class PlaybackErrorReporter @Inject constructor( class PlaybackErrorReporter @Inject constructor(
private val playerController: PlayerController, private val playerController: PlayerController,
private val repository: PlaybackErrorRepository,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) { ) {
private val outChannel = Channel<String>(Channel.BUFFERED) private val outChannel = Channel<String>(Channel.BUFFERED)
@@ -46,8 +51,11 @@ class PlaybackErrorReporter @Inject constructor(
scope.launch { scope.launch {
val buffer = mutableListOf<String>() val buffer = mutableListOf<String>()
var debounceJob: kotlinx.coroutines.Job? = null var debounceJob: kotlinx.coroutines.Job? = null
playerController.playbackErrorEvents.collect { title -> playerController.playbackErrorEvents.collect { event ->
buffer.add(title) // Fire-and-forget the server report — repository handles
// success/queue branching so callers don't see throws.
scope.launch { repository.report(event) }
buffer.add(event.title)
debounceJob?.cancel() debounceJob?.cancel()
debounceJob = scope.launch { debounceJob = scope.launch {
delay(DEBOUNCE_MS) delay(DEBOUNCE_MS)
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.PlaybackErrorReportPayload
import retrofit2.Retrofit
import retrofit2.create
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Writes a [PlaybackErrorEvent] to the server. Follows the standing
* offline-first rule for server writes: try the POST first, fall back
* to the MutationQueue on transport failure so a tracked report is
* never lost.
*
* Reused client_id comes from [AuthStore.clientId] — the same UUID-
* per-install identifier the play-events reporter already sends, so
* support can correlate playback errors with the surrounding plays.
*/
@Singleton
class PlaybackErrorRepository @Inject constructor(
private val authStore: AuthStore,
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
private val api: PlaybackErrorsApi = retrofit.create()
/**
* Reports a playback failure. Returns [Outcome.ACCEPTED] on a 2xx,
* [Outcome.QUEUED] when the call was buffered to the MutationQueue
* for later replay. Never throws — callers don't need a try block.
*/
suspend fun report(event: PlaybackErrorEvent): Outcome {
val clientId = resolveClientId()
val payload = PlaybackErrorReportPayload(
trackId = event.trackId,
kind = event.kind,
detail = event.detail,
clientId = clientId,
)
return try {
api.report(
PlaybackErrorReportRequest(
trackId = payload.trackId,
kind = payload.kind,
detail = payload.detail,
clientId = payload.clientId,
),
)
Outcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow — see LikesRepository.toggleLike for
// the same offline-first rationale.
mutationQueue.enqueuePlaybackErrorReport(payload)
Outcome.QUEUED
}
}
/**
* Returns the persisted client id, generating + persisting one on
* first read. Mirrors [PlayEventsReporter.resolveClientId] — the
* value is shared across all client-id-carrying surfaces.
*/
private fun resolveClientId(): String {
authStore.clientId.value?.let { return it }
val fresh = UUID.randomUUID().toString()
authStore.setClientId(fresh)
return fresh
}
enum class Outcome { ACCEPTED, QUEUED }
}
@@ -68,13 +68,23 @@ class PlayerController @Inject constructor(
/** /**
* Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter]. * Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter].
* Emits the failing track's title (or `"Track"` when unknown) each * Fires twice: once when Media3's `onPlayerError` surfaces a load
* time Media3's `onPlayerError` fires — not bound to the lifetime of * failure (decoder, transport, EOS), and once when the player
* any UI subscriber so a torn-down screen doesn't drop events. * reaches STATE_READY with a duration of zero / TIME_UNSET — the
* Conflated channel so backpressure can't stall the player loop. * "track loaded but has no audio" case the user sees as the
* player sitting frozen on a track. Conflated channel so back-
* pressure can't stall the player loop.
*/ */
private val playbackErrorEventsChannel = Channel<String>(Channel.CONFLATED) private val playbackErrorEventsChannel = Channel<PlaybackErrorEvent>(Channel.CONFLATED)
val playbackErrorEvents: Flow<String> = playbackErrorEventsChannel.receiveAsFlow() val playbackErrorEvents: Flow<PlaybackErrorEvent> = playbackErrorEventsChannel.receiveAsFlow()
/**
* Tracks which queue index has already been evaluated for the
* zero-duration / load-failed checks so STATE_READY firing
* repeatedly (after every seek, pause/resume) doesn't re-emit
* the same error event. Reset on each onMediaItemTransition.
*/
private var lastEvaluatedItemIndex: Int = -1
/** /**
* Stable queue snapshot kept in sync with the player's MediaItems — * Stable queue snapshot kept in sync with the player's MediaItems —
@@ -166,17 +176,84 @@ class PlayerController @Inject constructor(
} }
/** /**
* Seed a fresh radio queue from [trackId] and start playback. * Seed a fresh radio queue from [trackId]. The `source` tag is
* Replaces the existing queue. The `source` tag is "radio:<id>" * "radio:<id>" so the server-side rotation reporter can
* so the server-side rotation reporter can distinguish radio * distinguish radio plays from album / playlist plays.
* plays from album / playlist plays. No-op on an empty server *
* response; throws on transport / non-2xx for the caller to * Two modes:
* surface in a snackbar. * - Nothing in the queue: start the radio cold from track 0
* (the seed plays first, recommendations follow).
* - Queue already populated: do NOT interrupt — keep the current
* track playing as-is, trim every upcoming track, and append
* the radio results after the current item. If the seed is the
* currently playing track (the common "tap Start Radio on the
* song I'm listening to" case), drop it from the appended list
* so it doesn't immediately repeat. This is an intentional
* divergence from Flutter's `playerActions.startRadio`, which
* always calls `playTracks` and restarts playback from 0.
*
* No-op on an empty server response; throws on transport / non-2xx
* for the caller to surface in a snackbar.
*/ */
suspend fun startRadio(trackId: String) { suspend fun startRadio(trackId: String) {
val controller = mediaController ?: return
val tracks = radio.seed(trackId) val tracks = radio.seed(trackId)
if (tracks.isEmpty()) return if (tracks.isEmpty()) return
setQueue(tracks, initialIndex = 0, source = "radio:$trackId") val source = "radio:$trackId"
if (controller.mediaItemCount == 0) {
setQueue(tracks, initialIndex = 0, source = source)
} else {
appendRadioToQueue(controller, tracks, trackId, source)
}
}
/**
* Mid-queue radio insert. The current track keeps playing; every
* upcoming item is removed; the radio results are appended after.
* Drops the seed from the appended list when it matches the
* currently-playing track so it doesn't immediately repeat.
*/
private fun appendRadioToQueue(
controller: MediaController,
tracks: List<TrackRef>,
seedTrackId: String,
source: String,
) {
val currentIdx = controller.currentMediaItemIndex
val currentTrack = queueRefs.getOrNull(currentIdx)
val toAppend = if (currentTrack?.id == seedTrackId) tracks.drop(1) else tracks
if (toAppend.isEmpty()) return
val nextIdx = currentIdx + 1
if (nextIdx < controller.mediaItemCount) {
controller.removeMediaItems(nextIdx, controller.mediaItemCount)
}
queueRefs = queueRefs.take(nextIdx) + toAppend
controller.addMediaItems(toAppend.map { it.toMediaItem(source) })
}
/**
* When a STATE_READY transition for a freshly-loaded item reports
* a zero / TIME_UNSET duration, fire a `zero_duration` error event
* and advance past the dead track. Otherwise no-op.
*/
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
val current = queueRefs.getOrNull(idx) ?: return
val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = current.id,
kind = "zero_duration",
title = current.title.ifEmpty { "Track" },
detail = "duration=$duration",
),
)
if (controller.hasNextMediaItem()) {
controller.seekToNextMediaItem()
} else {
controller.stop()
}
} }
// ── Internal: async connect + Listener-driven UI state sync ────────── // ── Internal: async connect + Listener-driven UI state sync ──────────
@@ -200,12 +277,34 @@ class PlayerController @Inject constructor(
controller.addListener( controller.addListener(
object : Player.Listener { object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) { override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
val title = queueRefs val current = queueRefs.getOrNull(controller.currentMediaItemIndex)
.getOrNull(controller.currentMediaItemIndex) val title = current?.title?.takeIf { it.isNotEmpty() } ?: "Track"
?.title val trackId = current?.id ?: return
?.takeIf { it.isNotEmpty() } playbackErrorEventsChannel.trySend(
?: "Track" PlaybackErrorEvent(
playbackErrorEventsChannel.trySend(title) trackId = trackId,
kind = "load_failed",
title = title,
detail = error.message,
),
)
}
override fun onMediaItemTransition(
mediaItem: androidx.media3.common.MediaItem?,
reason: Int,
) {
// Reset the per-item evaluation guard so the new
// item's STATE_READY transition gets a fresh check.
lastEvaluatedItemIndex = -1
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState != Player.STATE_READY) return
val idx = controller.currentMediaItemIndex
if (idx == lastEvaluatedItemIndex) return
lastEvaluatedItemIndex = idx
handleZeroDurationIfNeeded(controller, idx)
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
@@ -334,3 +433,19 @@ class PlayerController @Inject constructor(
// surfaces are driven by Media3's MediaSession callbacks separately; // surfaces are driven by Media3's MediaSession callbacks separately;
// they don't need this poll. // they don't need this poll.
private const val POSITION_POLL_INTERVAL_MS = 500L private const val POSITION_POLL_INTERVAL_MS = 500L
/**
* Structured playback failure event for [PlaybackErrorReporter]. Drives
* both the user-facing snackbar ("Couldn't play X — skipping") and the
* /api/playback-errors admin inbox.
*
* `kind` is one of "zero_duration" / "load_failed" / "stalled" matching
* the server's CHECK constraint. `detail` is free-text (the Media3
* error message, the observed duration value, etc.).
*/
data class PlaybackErrorEvent(
val trackId: String,
val kind: String,
val title: String,
val detail: String? = null,
)
@@ -59,9 +59,14 @@ fun rememberDominantColor(coverUrl: String?): Color {
val palette = withContext(Dispatchers.Default) { val palette = withContext(Dispatchers.Default) {
Palette.from(bitmap).generate() Palette.from(bitmap).generate()
} }
val swatch = palette.vibrantSwatch // Always use the dominant (majority-by-pixel-count) swatch.
?: palette.mutedSwatch // The cover art is the main feature of this view; a vibrant
?: palette.dominantSwatch // accent on the cover should pop against the background, not
// be matched by it. Previously we tried vibrant first, which
// picked the highest-saturation swatch even when it covered
// a tiny fraction of the cover — small bright accents made
// the gradient feel disconnected from the actual image.
val swatch = palette.dominantSwatch
if (swatch != null) { if (swatch != null) {
extracted = Color(swatch.rgb) extracted = Color(swatch.rgb)
} }
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -85,6 +86,8 @@ private const val COVER_MAX_WIDTH_DP = 320
private const val TRANSPORT_ICON_DP = 36 private const val TRANSPORT_ICON_DP = 36
private const val PLAY_PAUSE_ICON_DP = 56 private const val PLAY_PAUSE_ICON_DP = 56
private const val POP_GRACE_MS = 500L private const val POP_GRACE_MS = 500L
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
private val SCRUB_TRACK_CORNER_DP = 2.dp
// Vertical drag-down threshold (in pixels) past which the gesture // Vertical drag-down threshold (in pixels) past which the gesture
// pops the player. Matches Flutter's 80px threshold in spirit; // pops the player. Matches Flutter's 80px threshold in spirit;
@@ -488,6 +491,8 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
.background(accent), .background(accent),
) )
}, },
// Custom 4dp rounded track — see ScrubTrack for rationale.
track = { _ -> ScrubTrack(fraction = fraction, accent = accent) },
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -506,6 +511,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
} }
} }
/**
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
* and reads as a heavy pill rather than a measurement line; making
* the thumb visibly taller than the track (14dp thumb on a 4dp bar)
* restores the "handle on a string" cue your eye reads as "tool,
* draggable." Also drops M3's stop indicator dot, which the web
* scrubber doesn't have. Extracted so [ScrubberRow] stays under
* detekt's LongMethod ceiling.
*/
@Composable
private fun ScrubTrack(fraction: Float, accent: Color) {
Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(accent),
)
}
}
@Composable @Composable
private fun TransportRow( private fun TransportRow(
isPlaying: Boolean, isPlaying: Boolean,
@@ -288,7 +288,10 @@ private fun PlaylistDetailContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel, playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController, navController: NavHostController,
) { ) {
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) { // Crossfade keyed on `state::class` so refreshes that emit a
// fresh Success (same kind, new detail) don't re-fade the body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
is PlaylistDetailUiState.Loading -> is PlaylistDetailUiState.Loading ->
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList() s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
@@ -11,12 +11,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LibraryMusic
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.House import com.composables.icons.lucide.House
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.EllipsisVertical import com.composables.icons.lucide.EllipsisVertical
import com.composables.icons.lucide.Search as SearchIcon import com.composables.icons.lucide.Search as SearchIcon
@@ -59,14 +58,7 @@ fun MainAppBarActions(
} }
if (currentRouteName != Library::class.qualifiedName) { if (currentRouteName != Library::class.qualifiedName) {
IconButton(onClick = { navController.navigate(Library) }) { IconButton(onClick = { navController.navigate(Library) }) {
// Intentional cross-family icon — Lucide has no Icon(Lucide.LibraryBig, contentDescription = "Library")
// equivalent "library + music" glyph and the literal
// library icons (Library / LibraryBig / SquareLibrary)
// don't read as "music collection" without a label.
// Material's Outlined LibraryMusic is the canonical
// music-library symbol; the outlined variant matches
// Lucide's stroke style closely enough to blend.
Icon(Icons.Outlined.LibraryMusic, contentDescription = "Library")
} }
} }
if (currentRouteName != SearchRoute::class.qualifiedName) { if (currentRouteName != SearchRoute::class.qualifiedName) {
-5
View File
@@ -51,11 +51,6 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" } compose-material3 = { module = "androidx.compose.material3:material3" }
# material-icons-extended supplies the full Material Icons catalog
# (Outlined / Filled / Rounded / Sharp / TwoTone). We use the Outlined
# variant for LibraryMusic — its stroke style blends with Lucide.
# Version pinned by compose-bom.
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" } compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
+7
View File
@@ -107,6 +107,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Delete("/quarantine/{track_id}", h.handleUnflag) authed.Delete("/quarantine/{track_id}", h.handleUnflag)
authed.Get("/quarantine/mine", h.handleListMyQuarantine) authed.Get("/quarantine/mine", h.handleListMyQuarantine)
// Client-reported playback errors (zero-duration tracks,
// load failures). Admin-only inbox; any user can report.
authed.Post("/playback-errors", h.handleReportPlaybackError)
// Self-hosted in-app update channel (#397). Auth-gated to // Self-hosted in-app update channel (#397). Auth-gated to
// prevent anonymous bandwidth abuse on the APK stream; // prevent anonymous bandwidth abuse on the APK stream;
// /apk additionally per-user rate-limited. // /apk additionally per-user rate-limited.
@@ -127,6 +131,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/quarantine", h.handleListAdminQuarantine) admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine) admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions) admin.Get("/quarantine/actions", h.handleListQuarantineActions)
+242
View File
@@ -0,0 +1,242 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Valid `kind` values. Mirrors the CHECK constraint in migration 0032
// — keep both lists in sync if you add a kind here, otherwise inserts
// pass the handler whitelist and trip the DB constraint instead.
var validPlaybackErrorKinds = map[string]struct{}{
"zero_duration": {},
"load_failed": {},
"stalled": {},
}
// Valid `resolution` values. Hidden / deleted / requested are stamped
// automatically when the admin clicks the matching action from the
// inbox; fixed / ignored are operator-driven (no action taken on the
// track itself). Mirrors the CHECK constraint in migration 0032.
var validPlaybackErrorResolutions = map[string]struct{}{
"hidden": {},
"deleted": {},
"requested": {},
"fixed": {},
"ignored": {},
}
// reportPlaybackErrorRequest is the body of POST /api/playback-errors.
// TrackID is a UUID string; Kind is one of [validPlaybackErrorKinds];
// Detail is optional free-text from the client (e.g. ExoPlayer error
// message); ClientID identifies the device/browser so support can
// correlate reports across surfaces.
type reportPlaybackErrorRequest struct {
TrackID string `json:"track_id"`
Kind string `json:"kind"`
Detail *string `json:"detail,omitempty"`
ClientID string `json:"client_id"`
}
// adminPlaybackErrorView is one row in the admin inbox response. The
// track / album / artist fields are joined so the SPA can render
// without a second round-trip per row.
type adminPlaybackErrorView struct {
ID string `json:"id"`
TrackID string `json:"track_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
ClientID string `json:"client_id"`
Kind string `json:"kind"`
Detail *string `json:"detail,omitempty"`
OccurredAt string `json:"occurred_at"`
ResolvedAt *string `json:"resolved_at,omitempty"`
Resolution *string `json:"resolution,omitempty"`
TrackTitle string `json:"track_title"`
TrackFilePath string `json:"track_file_path"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
}
// resolvePlaybackErrorRequest is the body of POST
// /api/admin/playback-errors/{id}/resolve. The resolution string is
// validated against [validPlaybackErrorResolutions].
type resolvePlaybackErrorRequest struct {
Resolution string `json:"resolution"`
}
// Pagination defaults for the admin list endpoint. 50 fits the
// admin inbox table on a laptop without scrolling; max 200 prevents
// a misbehaving client from asking for the whole table at once.
const (
defaultPlaybackErrorPageSize = 50
maxPlaybackErrorPageSize = 200
)
// handleReportPlaybackError implements POST /api/playback-errors.
// Any signed-in user can report; the handler validates kind + track
// existence and inserts a row for admin review.
func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var req reportPlaybackErrorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
return
}
trackID, ok := parseUUID(req.TrackID)
if !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
return
}
if _, ok := validPlaybackErrorKinds[req.Kind]; !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid kind"))
return
}
if req.ClientID == "" {
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
return
}
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
return
}
h.logger.Error("api: playback_error: lookup track", "err", err)
writeErr(w, apierror.Internal(err))
return
}
row, err := dbq.New(h.pool).InsertPlaybackError(r.Context(), dbq.InsertPlaybackErrorParams{
TrackID: trackID,
UserID: user.ID,
ClientID: req.ClientID,
Kind: req.Kind,
Detail: req.Detail,
})
if err != nil {
h.logger.Error("api: playback_error: insert", "err", err)
writeErr(w, apierror.Internal(err))
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
}
// handleListAdminPlaybackErrors implements GET
// /api/admin/playback-errors?resolved=false&offset=0&limit=50.
func (h *handlers) handleListAdminPlaybackErrors(w http.ResponseWriter, r *http.Request) {
resolved := r.URL.Query().Get("resolved") == "true"
limit := parsePlaybackErrorLimit(r.URL.Query().Get("limit"))
offset := parsePlaybackErrorOffset(r.URL.Query().Get("offset"))
rows, err := dbq.New(h.pool).ListAdminPlaybackErrors(r.Context(), dbq.ListAdminPlaybackErrorsParams{
ResolvedFilter: resolved,
Off: int32(offset),
Lim: int32(limit),
})
if err != nil {
h.logger.Error("admin: list playback_errors", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out := make([]adminPlaybackErrorView, 0, len(rows))
for _, row := range rows {
view := adminPlaybackErrorView{
ID: uuidToString(row.ID),
TrackID: uuidToString(row.TrackID),
UserID: uuidToString(row.UserID),
Username: row.Username,
ClientID: row.ClientID,
Kind: row.Kind,
Detail: row.Detail,
OccurredAt: formatTimestamp(row.OccurredAt),
Resolution: row.Resolution,
TrackTitle: row.TrackTitle,
TrackFilePath: row.TrackFilePath,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
}
if row.ResolvedAt.Valid {
ts := formatTimestamp(row.ResolvedAt)
view.ResolvedAt = &ts
}
out = append(out, view)
}
writeJSON(w, http.StatusOK, out)
}
// handleResolvePlaybackError implements POST
// /api/admin/playback-errors/{id}/resolve.
func (h *handlers) handleResolvePlaybackError(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
var req resolvePlaybackErrorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
return
}
if _, ok := validPlaybackErrorResolutions[req.Resolution]; !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_resolution")
return
}
resolution := req.Resolution
row, err := dbq.New(h.pool).ResolvePlaybackError(r.Context(), dbq.ResolvePlaybackErrorParams{
ID: id,
ResolvedBy: admin.ID,
Resolution: &resolution,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeAdminJSONErr(w, http.StatusNotFound, "not_found")
return
}
h.logger.Error("admin: resolve playback_error", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
writeJSON(w, http.StatusOK, map[string]string{"id": uuidToString(row.ID)})
}
func parsePlaybackErrorLimit(raw string) int {
if raw == "" {
return defaultPlaybackErrorPageSize
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return defaultPlaybackErrorPageSize
}
if n > maxPlaybackErrorPageSize {
return maxPlaybackErrorPageSize
}
return n
}
func parsePlaybackErrorOffset(raw string) int {
if raw == "" {
return 0
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}
+13
View File
@@ -383,6 +383,19 @@ type PlaySession struct {
ClientID *string ClientID *string
} }
type PlaybackError struct {
ID pgtype.UUID
TrackID pgtype.UUID
UserID pgtype.UUID
ClientID string
Kind string
Detail *string
OccurredAt pgtype.Timestamptz
ResolvedAt pgtype.Timestamptz
ResolvedBy pgtype.UUID
Resolution *string
}
type Playlist struct { type Playlist struct {
ID pgtype.UUID ID pgtype.UUID
UserID pgtype.UUID UserID pgtype.UUID
+210
View File
@@ -0,0 +1,210 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playback_errors.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertPlaybackError = `-- name: InsertPlaybackError :one
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution
`
type InsertPlaybackErrorParams struct {
TrackID pgtype.UUID
UserID pgtype.UUID
ClientID string
Kind string
Detail *string
}
// Records a client-reported playback failure. The handler validates
// the kind enum before this runs; the CHECK constraint is a
// belt-and-braces guard.
func (q *Queries) InsertPlaybackError(ctx context.Context, arg InsertPlaybackErrorParams) (PlaybackError, error) {
row := q.db.QueryRow(ctx, insertPlaybackError,
arg.TrackID,
arg.UserID,
arg.ClientID,
arg.Kind,
arg.Detail,
)
var i PlaybackError
err := row.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.ResolvedBy,
&i.Resolution,
)
return i, err
}
const listAdminPlaybackErrors = `-- name: ListAdminPlaybackErrors :many
SELECT
pe.id AS id,
pe.track_id AS track_id,
pe.user_id AS user_id,
u.username AS username,
pe.client_id AS client_id,
pe.kind AS kind,
pe.detail AS detail,
pe.occurred_at AS occurred_at,
pe.resolved_at AS resolved_at,
pe.resolution AS resolution,
t.title AS track_title,
t.file_path AS track_file_path,
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id
FROM playback_errors pe
JOIN users u ON u.id = pe.user_id
JOIN tracks t ON t.id = pe.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE ($1::bool AND pe.resolved_at IS NOT NULL)
OR (NOT $1::bool AND pe.resolved_at IS NULL)
ORDER BY pe.occurred_at DESC
LIMIT $3::int OFFSET $2::int
`
type ListAdminPlaybackErrorsParams struct {
ResolvedFilter bool
Off int32
Lim int32
}
type ListAdminPlaybackErrorsRow struct {
ID pgtype.UUID
TrackID pgtype.UUID
UserID pgtype.UUID
Username string
ClientID string
Kind string
Detail *string
OccurredAt pgtype.Timestamptz
ResolvedAt pgtype.Timestamptz
Resolution *string
TrackTitle string
TrackFilePath string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
}
// Admin inbox query. Returns one row per playback_errors row joined
// with track / album / artist metadata so the SPA can render without
// a second round-trip. Filter by resolved status via the sqlc.arg —
// pass true for the Resolved tab, false for Unresolved (the default).
func (q *Queries) ListAdminPlaybackErrors(ctx context.Context, arg ListAdminPlaybackErrorsParams) ([]ListAdminPlaybackErrorsRow, error) {
rows, err := q.db.Query(ctx, listAdminPlaybackErrors, arg.ResolvedFilter, arg.Off, arg.Lim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAdminPlaybackErrorsRow
for rows.Next() {
var i ListAdminPlaybackErrorsRow
if err := rows.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.Username,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.Resolution,
&i.TrackTitle,
&i.TrackFilePath,
&i.ArtistName,
&i.AlbumTitle,
&i.AlbumID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const resolveAllPlaybackErrorsForTrack = `-- name: ResolveAllPlaybackErrorsForTrack :execrows
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE track_id = $1 AND resolved_at IS NULL
`
type ResolveAllPlaybackErrorsForTrackParams struct {
TrackID pgtype.UUID
ResolvedBy pgtype.UUID
Resolution *string
}
// Bulk-resolve every unresolved error for a track when an admin acts
// on it from a non-inbox surface (existing quarantine flow, etc.).
// Returns the affected row count so the caller can surface "N reports
// auto-resolved" in the response if useful.
func (q *Queries) ResolveAllPlaybackErrorsForTrack(ctx context.Context, arg ResolveAllPlaybackErrorsForTrackParams) (int64, error) {
result, err := q.db.Exec(ctx, resolveAllPlaybackErrorsForTrack, arg.TrackID, arg.ResolvedBy, arg.Resolution)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const resolvePlaybackError = `-- name: ResolvePlaybackError :one
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE id = $1
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution
`
type ResolvePlaybackErrorParams struct {
ID pgtype.UUID
ResolvedBy pgtype.UUID
Resolution *string
}
// Marks a single error resolved. Idempotent over (id, resolution) —
// a second resolve with the same resolution is a no-op rewrite. Returns
// the row so the handler can echo it. The CHECK constraint enforces
// the resolution enum.
func (q *Queries) ResolvePlaybackError(ctx context.Context, arg ResolvePlaybackErrorParams) (PlaybackError, error) {
row := q.db.QueryRow(ctx, resolvePlaybackError, arg.ID, arg.ResolvedBy, arg.Resolution)
var i PlaybackError
err := row.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.ResolvedBy,
&i.Resolution,
)
return i, err
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS playback_errors;
@@ -0,0 +1,41 @@
-- Client-reported playback errors. Populated by clients when a track
-- fails to play (zero duration, decode error, etc.); surfaced in the
-- admin /admin/playback-errors inbox so the operator can hide / delete
-- / re-request the offending track.
--
-- resolved_at + resolved_by + resolution are NULL until an admin acts
-- on the row. Auto-resolved by the client when the admin clicks Hide /
-- Delete / Re-request from the inbox row, with the resolution string
-- recording which path was taken. The partial index keeps the
-- unresolved-queue lookup fast even as resolved history accumulates.
--
-- CHECK constraints on kind/resolution gate the enum values so client
-- typos can't pollute the data (per the standing rule about
-- enum-CHECK whitelists needing migrations).
CREATE TABLE playback_errors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id text NOT NULL,
kind text NOT NULL,
detail text,
occurred_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
resolved_by uuid REFERENCES users(id),
resolution text,
CONSTRAINT playback_errors_kind_check
CHECK (kind IN ('zero_duration', 'load_failed', 'stalled')),
CONSTRAINT playback_errors_resolution_check
CHECK (resolution IS NULL OR resolution IN
('hidden', 'deleted', 'requested', 'fixed', 'ignored')),
CONSTRAINT playback_errors_resolved_consistency
CHECK ((resolved_at IS NULL) = (resolution IS NULL))
);
CREATE INDEX idx_playback_errors_unresolved
ON playback_errors (occurred_at DESC)
WHERE resolved_at IS NULL;
CREATE INDEX idx_playback_errors_track
ON playback_errors (track_id);
+63
View File
@@ -0,0 +1,63 @@
-- name: InsertPlaybackError :one
-- Records a client-reported playback failure. The handler validates
-- the kind enum before this runs; the CHECK constraint is a
-- belt-and-braces guard.
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution;
-- name: ListAdminPlaybackErrors :many
-- Admin inbox query. Returns one row per playback_errors row joined
-- with track / album / artist metadata so the SPA can render without
-- a second round-trip. Filter by resolved status via the sqlc.arg —
-- pass true for the Resolved tab, false for Unresolved (the default).
SELECT
pe.id AS id,
pe.track_id AS track_id,
pe.user_id AS user_id,
u.username AS username,
pe.client_id AS client_id,
pe.kind AS kind,
pe.detail AS detail,
pe.occurred_at AS occurred_at,
pe.resolved_at AS resolved_at,
pe.resolution AS resolution,
t.title AS track_title,
t.file_path AS track_file_path,
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id
FROM playback_errors pe
JOIN users u ON u.id = pe.user_id
JOIN tracks t ON t.id = pe.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE (sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NOT NULL)
OR (NOT sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NULL)
ORDER BY pe.occurred_at DESC
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
-- name: ResolvePlaybackError :one
-- Marks a single error resolved. Idempotent over (id, resolution) —
-- a second resolve with the same resolution is a no-op rewrite. Returns
-- the row so the handler can echo it. The CHECK constraint enforces
-- the resolution enum.
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE id = $1
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution;
-- name: ResolveAllPlaybackErrorsForTrack :execrows
-- Bulk-resolve every unresolved error for a track when an admin acts
-- on it from a non-inbox surface (existing quarantine flow, etc.).
-- Returns the affected row count so the caller can surface "N reports
-- auto-resolved" in the response if useful.
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE track_id = $1 AND resolved_at IS NULL;
+31 -1
View File
@@ -3,6 +3,7 @@ import { api } from './client';
import { qk } from './queries'; import { qk } from './queries';
import type { import type {
ActionResult, ActionResult,
AdminPlaybackError,
AdminQuarantineRow, AdminQuarantineRow,
LidarrConfig, LidarrConfig,
LidarrMetadataProfile, LidarrMetadataProfile,
@@ -11,7 +12,8 @@ import type {
LidarrRequest, LidarrRequest,
LidarrRequestStatus, LidarrRequestStatus,
LidarrRootFolder, LidarrRootFolder,
LidarrTestResult LidarrTestResult,
PlaybackErrorResolution
} from './types'; } from './types';
// Admin Lidarr config ----------------------------------------------------- // Admin Lidarr config -----------------------------------------------------
@@ -174,6 +176,34 @@ export function createQuarantineActionsQuery(limit: number = 50) {
}); });
} }
// Admin playback errors ---------------------------------------------------
export async function listAdminPlaybackErrors(
resolved: boolean = false
): Promise<AdminPlaybackError[]> {
return api.get<AdminPlaybackError[]>(
`/api/admin/playback-errors?resolved=${resolved}`
);
}
export async function resolvePlaybackError(
id: string,
resolution: PlaybackErrorResolution
): Promise<{ id: string }> {
return api.post<{ id: string }>(
`/api/admin/playback-errors/${id}/resolve`,
{ resolution }
);
}
export function createAdminPlaybackErrorsQuery(resolved: boolean = false) {
return createQuery({
queryKey: qk.adminPlaybackErrors(resolved),
queryFn: () => listAdminPlaybackErrors(resolved),
staleTime: 30_000
});
}
// Admin cover art --------------------------------------------------------- // Admin cover art ---------------------------------------------------------
export type RefetchAlbumCoverResponse = { export type RefetchAlbumCoverResponse = {
+2
View File
@@ -43,6 +43,8 @@ export const qk = {
adminQuarantine: () => ['adminQuarantine'] as const, adminQuarantine: () => ['adminQuarantine'] as const,
adminQuarantineActions: (limit?: number) => adminQuarantineActions: (limit?: number) =>
['adminQuarantineActions', { limit: limit ?? 50 }] as const, ['adminQuarantineActions', { limit: limit ?? 50 }] as const,
adminPlaybackErrors: (resolved?: boolean) =>
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
scanStatus: () => ['scanStatus'] as const, scanStatus: () => ['scanStatus'] as const,
scanSchedule: () => ['scanSchedule'] as const, scanSchedule: () => ['scanSchedule'] as const,
coverage: () => ['coverage'] as const, coverage: () => ['coverage'] as const,
+27
View File
@@ -264,6 +264,33 @@ export type AdminQuarantineReport = {
created_at: string; created_at: string;
}; };
// What GET /api/admin/playback-errors returns per row
export type PlaybackErrorKind = 'zero_duration' | 'load_failed' | 'stalled';
export type PlaybackErrorResolution =
| 'hidden'
| 'deleted'
| 'requested'
| 'fixed'
| 'ignored';
export type AdminPlaybackError = {
id: string;
track_id: string;
user_id: string;
username: string;
client_id: string;
kind: PlaybackErrorKind;
detail?: string | null;
occurred_at: string;
resolved_at?: string | null;
resolution?: PlaybackErrorResolution | null;
track_title: string;
track_file_path: string;
artist_name: string;
album_title: string;
album_id: string;
};
export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr';
export type LidarrQuarantineActionRow = { export type LidarrQuarantineActionRow = {
+6 -5
View File
@@ -4,11 +4,12 @@
type Item = { href: string; label: string }; type Item = { href: string; label: string };
const items: Item[] = [ const items: Item[] = [
{ href: '/admin', label: 'Overview' }, { href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' }, { href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' }, { href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' }, { href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/users', label: 'Users' } { href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/users', label: 'Users' }
]; ];
function isActive(href: string): boolean { function isActive(href: string): boolean {
+2 -1
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
); );
}); });
test('renders exactly five tabs', () => { test('renders all six tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin'); state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs); render(AdminTabs);
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');
@@ -61,6 +61,7 @@ describe('AdminTabs', () => {
'Integrations', 'Integrations',
'Requests', 'Requests',
'Quarantine', 'Quarantine',
'Playback errors',
'Users' 'Users'
]); ]);
}); });
@@ -0,0 +1,316 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Copy, Trash2, CheckCircle2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte';
import {
createAdminPlaybackErrorsQuery,
resolvePlaybackError,
deleteQuarantineFile
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/Modal.svelte';
import type { AdminPlaybackError, PlaybackErrorResolution } from '$lib/api/types';
// Client-reported playback errors inbox. Two tabs — Unresolved
// (default) / Resolved. Per-row: copy details to clipboard, delete
// the source file (auto-resolves as 'deleted'), or mark resolved
// manually with a fixed/ignored reason. Other resolutions (hidden,
// requested) get stamped by their respective workflows in follow-up
// slices.
const client = useQueryClient();
// Tab state — observed by the query factory so swapping tabs
// re-fetches the corresponding list.
let resolved = $state(false);
const queryStore = $derived(createAdminPlaybackErrorsQuery(resolved));
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as AdminPlaybackError[]);
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const days = Math.floor(ms / (24 * 3_600_000));
if (days >= 1) return `${days}d ago`;
const hours = Math.floor(ms / 3_600_000);
if (hours >= 1) return `${hours}h ago`;
const minutes = Math.floor(ms / 60_000);
if (minutes >= 1) return `${minutes}m ago`;
return 'just now';
}
// Maps the kind enum to a short readable badge label.
function kindLabel(kind: string): string {
switch (kind) {
case 'zero_duration': return 'Zero duration';
case 'load_failed': return 'Load failed';
case 'stalled': return 'Stalled';
default: return kind;
}
}
// Maps the resolution enum to a short label for the Resolved tab.
function resolutionLabel(r: string | null | undefined): string {
if (!r) return '';
switch (r) {
case 'hidden': return 'Hidden';
case 'deleted': return 'Deleted';
case 'requested': return 'Re-requested';
case 'fixed': return 'Fixed';
case 'ignored': return 'Ignored';
default: return r;
}
}
async function invalidate() {
await client.invalidateQueries({ queryKey: qk.adminPlaybackErrors(resolved) });
}
// Copy a single-row JSON payload to the clipboard. Includes the
// server-side file_path so the operator can grep their library
// mount without round-tripping back through the UI.
async function onCopy(r: AdminPlaybackError) {
const payload = {
id: r.id,
track_id: r.track_id,
track_title: r.track_title,
artist_name: r.artist_name,
album_title: r.album_title,
file_path: r.track_file_path,
kind: r.kind,
detail: r.detail,
reported_by: r.username,
client_id: r.client_id,
occurred_at: r.occurred_at
};
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
pushToast('Copied error details to clipboard');
} catch (e: unknown) {
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
}
}
// Delete the source file via the existing quarantine admin endpoint;
// on success stamp this row as resolved='deleted' so the inbox
// reflects the action immediately.
let deleteFileOpen = $state<AdminPlaybackError | null>(null);
async function confirmDeleteFile() {
const row = deleteFileOpen;
if (!row) return;
deleteFileOpen = null;
try {
await deleteQuarantineFile(row.track_id);
await resolvePlaybackError(row.id, 'deleted');
pushToast(`Deleted "${row.track_title}"`);
await invalidate();
} catch (e: unknown) {
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
}
}
// Manual mark-resolved modal. Resolution dropdown limited to the two
// "no further action taken" cases (fixed / ignored). Delete already
// stamps 'deleted'; hide/request land in follow-up slices.
let resolveOpen = $state<AdminPlaybackError | null>(null);
let resolveChoice = $state<PlaybackErrorResolution>('fixed');
function openResolve(row: AdminPlaybackError) {
resolveOpen = row;
resolveChoice = 'fixed';
}
async function confirmResolve() {
const row = resolveOpen;
if (!row) return;
resolveOpen = null;
try {
await resolvePlaybackError(row.id, resolveChoice);
pushToast(`Marked "${row.track_title}" ${resolutionLabel(resolveChoice).toLowerCase()}`);
await invalidate();
} catch (e: unknown) {
pushToast(`Resolve failed: ${errMessage(e)}`, 'error');
}
}
function actionsFor(r: AdminPlaybackError): { primary: RowAction; secondary: RowAction[] } {
return {
primary: {
icon: CheckCircle2,
label: 'Resolve',
ariaLabel: `Resolve ${r.track_title}`,
onclick: () => openResolve(r)
},
secondary: [
{
icon: Copy,
label: 'Copy',
ariaLabel: `Copy details for ${r.track_title}`,
onclick: () => onCopy(r)
},
{
icon: Trash2,
label: 'Delete file',
ariaLabel: `Delete file for ${r.track_title}`,
danger: true,
onclick: () => { deleteFileOpen = r; }
}
]
};
}
</script>
<svelte:head><title>{pageTitle('Admin · Playback errors')}</title></svelte:head>
<div class="space-y-4">
<header class="flex items-end justify-between gap-3">
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">Playback errors</h1>
<p class="text-sm text-text-secondary">
Tracks that failed to play on a client. Reported automatically by
the Android player when a track loads with zero duration or
decode-fails — the player skips fast and logs here for triage.
</p>
</div>
{#if !query.isPending && !query.isError && !resolved}
<span class="rounded-full bg-accent/15 px-3 py-1 text-sm text-accent">
{rows.length} unresolved
</span>
{/if}
</header>
<!-- Tab toggle. Resolved / unresolved are two separate query keys so
the cache holds both lists without re-fetching when you flip. -->
<div role="tablist" aria-label="Resolution status" class="flex gap-2 border-b border-border">
<button
type="button"
role="tab"
aria-selected={!resolved}
onclick={() => { resolved = false; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {!resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Unresolved</button>
<button
type="button"
role="tab"
aria-selected={resolved}
onclick={() => { resolved = true; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Resolved</button>
</div>
{#if query.isError}
<p class="text-error">Couldn't load: {errMessage(query.error)}</p>
{:else if query.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
<p class="text-text-secondary">
{resolved ? 'No resolved errors yet.' : 'No unresolved errors.'}
</p>
{:else}
<ul class="divide-y divide-border rounded-md border border-border">
{#each rows as r (r.id)}
<li class="flex items-start gap-3 px-3 py-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="truncate text-sm font-medium text-text-primary">
{r.track_title}
</span>
<span class="truncate text-xs text-text-secondary">
{r.artist_name} · {r.album_title}
</span>
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-secondary">
{kindLabel(r.kind)}
</span>
{#if resolved && r.resolution}
<span class="rounded bg-accent/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-accent">
{resolutionLabel(r.resolution)}
</span>
{/if}
</div>
<div class="mt-0.5 text-xs text-text-muted">
by {r.username} · {relativeTime(r.occurred_at)}
{#if r.detail}<span class="text-text-secondary"> · {r.detail}</span>{/if}
</div>
<div class="mt-1 truncate text-[11px] font-mono text-text-muted" title={r.track_file_path}>
{r.track_file_path}
</div>
</div>
{#if !resolved}
{@const a = actionsFor(r)}
<RowActionsMenu primary={a.primary} secondary={a.secondary} />
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<Modal
title="Delete file?"
open={deleteFileOpen !== null}
onClose={() => { deleteFileOpen = null; }}
>
{#if deleteFileOpen}
<p class="text-sm text-text-secondary">
This removes <span class="font-medium text-text-primary">{deleteFileOpen.track_title}</span>
from the library and deletes the underlying file. The error row
will be marked resolved.
</p>
<p class="mt-2 text-xs text-text-muted font-mono">{deleteFileOpen.track_file_path}</p>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => { deleteFileOpen = null; }}
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
>Cancel</button>
<button
type="button"
onclick={confirmDeleteFile}
class="rounded bg-action-danger px-3 py-2 text-sm text-action-fg hover:opacity-90"
>Delete file</button>
</div>
{/if}
</Modal>
<Modal
title="Mark resolved"
open={resolveOpen !== null}
onClose={() => { resolveOpen = null; }}
>
{#if resolveOpen}
<p class="text-sm text-text-secondary">
Marking <span class="font-medium text-text-primary">{resolveOpen.track_title}</span> resolved.
Pick how you handled it:
</p>
<label class="mt-3 block text-xs text-text-secondary">
Resolution
<select
bind:value={resolveChoice}
class="mt-1 block w-full rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="fixed">Fixed — track now plays</option>
<option value="ignored">Ignored — no action needed</option>
</select>
</label>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => { resolveOpen = null; }}
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
>Cancel</button>
<button
type="button"
onclick={confirmResolve}
class="rounded bg-action-primary px-3 py-2 text-sm text-action-fg hover:opacity-90"
>Mark resolved</button>
</div>
{/if}
</Modal>