Merge pull request 'Image rendering + player resilience (#968, #980)' (#103) from dev into main
test-go / test (push) Successful in 46s
test-web / test (push) Successful in 55s
android / Build + lint + test (push) Successful in 4m11s
test-go / integration (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 3m53s
release / Build + push container image (push) Successful in 1m41s
test-go / test (push) Successful in 46s
test-web / test (push) Successful in 55s
android / Build + lint + test (push) Successful in 4m11s
test-go / integration (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 3m53s
release / Build + push container image (push) Successful in 1m41s
This commit was merged in pull request #103.
This commit is contained in:
@@ -95,6 +95,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||||
|
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -167,6 +169,15 @@ class HomeViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
||||||
|
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
||||||
|
// and You-might-like rows reflect the new snapshot without a manual
|
||||||
|
// reload. Mirrors the web SSE consumer.
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventsStream.events
|
||||||
|
.filter { it.kind == "playlist.system_rebuilt" }
|
||||||
|
.collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val radio: RadioController,
|
private val radio: RadioController,
|
||||||
|
private val playlists: PlaylistsRepository,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var lastEvaluatedItemIndex: Int = -1
|
private var lastEvaluatedItemIndex: Int = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
||||||
|
* the last successful play. Capped so a still-broken refresh can't loop;
|
||||||
|
* reset when any track loads with real audio.
|
||||||
|
*/
|
||||||
|
private var selfHealAttempts = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||||
@@ -351,7 +361,12 @@ class PlayerController @Inject constructor(
|
|||||||
if (current == null || upnpEngaged) return
|
if (current == null || upnpEngaged) return
|
||||||
val duration = controller.duration
|
val duration = controller.duration
|
||||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||||
if (!isZeroDuration) return
|
if (!isZeroDuration) {
|
||||||
|
// A track loaded with real audio — clear the self-heal budget so
|
||||||
|
// a later stale queue can recover again.
|
||||||
|
selfHealAttempts = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
playbackErrorEventsChannel.trySend(
|
playbackErrorEventsChannel.trySend(
|
||||||
PlaybackErrorEvent(
|
PlaybackErrorEvent(
|
||||||
trackId = current.id,
|
trackId = current.id,
|
||||||
@@ -367,6 +382,58 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #968: the queue is fully unplayable (every track failed to load). If it
|
||||||
|
* came from a refreshable source — a system playlist (bare-variant source)
|
||||||
|
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
|
||||||
|
* open across the daily rebuild); re-pull it and resume instead of stopping.
|
||||||
|
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
|
||||||
|
* sources have nothing to refresh — stop.
|
||||||
|
*/
|
||||||
|
private fun selfHealOrStop(controller: MediaController) {
|
||||||
|
val source = controller.currentMediaItem
|
||||||
|
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
|
||||||
|
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
|
||||||
|
controller.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
source.startsWith("radio:") -> {
|
||||||
|
selfHealAttempts++
|
||||||
|
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
|
||||||
|
}
|
||||||
|
!source.contains(':') -> { // bare variant = refreshable system playlist
|
||||||
|
selfHealAttempts++
|
||||||
|
scope.launch { selfHealSystem(source) }
|
||||||
|
}
|
||||||
|
else -> controller.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selfHealSystem(variant: String) {
|
||||||
|
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
if (refs.isEmpty()) {
|
||||||
|
stopOnControllerThread()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQueue(refs, initialIndex = 0, source = variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selfHealRadio(seedTrackId: String) {
|
||||||
|
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
|
||||||
|
if (refs.isEmpty()) {
|
||||||
|
stopOnControllerThread()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopOnControllerThread() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
runOnControllerThread(controller) { controller.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||||
|
|
||||||
private suspend fun connectAndObserve() {
|
private suspend fun connectAndObserve() {
|
||||||
@@ -417,6 +484,17 @@ class PlayerController @Inject constructor(
|
|||||||
detail = error.message,
|
detail = error.message,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// A failed load leaves the player IDLE; advance past the bad
|
||||||
|
// track and re-prepare so one unplayable item doesn't strand
|
||||||
|
// playback (mirrors the zero_duration skip). At the end of a
|
||||||
|
// fully-unplayable queue, try to self-heal a stale refreshable
|
||||||
|
// source before stopping.
|
||||||
|
if (controller.hasNextMediaItem()) {
|
||||||
|
controller.seekToNextMediaItem()
|
||||||
|
controller.prepare()
|
||||||
|
} else {
|
||||||
|
selfHealOrStop(controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(
|
override fun onMediaItemTransition(
|
||||||
@@ -763,6 +841,10 @@ class PlayerController @Inject constructor(
|
|||||||
// 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
|
||||||
|
|
||||||
|
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
||||||
|
// the next successful play) so a still-broken refresh can't loop.
|
||||||
|
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||||
|
|||||||
+68
@@ -28,6 +28,10 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -139,6 +143,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
|
||||||
|
* its list is now stale and the uuid has rotated. Drives a "this mix was
|
||||||
|
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
|
||||||
|
* re-resolves the variant via [reloadRebuilt].
|
||||||
|
*/
|
||||||
|
private val staleInternal = MutableStateFlow(false)
|
||||||
|
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
|
||||||
|
|
||||||
val likedTrackIds: StateFlow<Set<String>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTrackIds()
|
likes.observeLikedTrackIds()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -163,6 +176,12 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
* ignored — the playlists-list screen handles those.
|
* ignored — the playlists-list screen handles those.
|
||||||
*/
|
*/
|
||||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
private fun handlePlaylistEvent(event: LiveEvent) {
|
||||||
|
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
|
||||||
|
// user's system mixes at once) — handle it before the per-id filter.
|
||||||
|
if (event.kind == "playlist.system_rebuilt") {
|
||||||
|
markStaleIfSystem()
|
||||||
|
return
|
||||||
|
}
|
||||||
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||||
if (eventPlaylistId != playlistId) return
|
if (eventPlaylistId != playlistId) return
|
||||||
when (event.kind) {
|
when (event.kind) {
|
||||||
@@ -171,6 +190,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun markStaleIfSystem() {
|
||||||
|
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||||
|
?.detail?.playlist ?: return
|
||||||
|
if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
|
||||||
|
staleInternal.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleLikeTrack(trackId: String) {
|
fun toggleLikeTrack(trackId: String) {
|
||||||
val desired = trackId !in likedTrackIds.value
|
val desired = trackId !in likedTrackIds.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -220,6 +247,30 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #980: user tapped Refresh on the stale-view snackbar. The rebuild
|
||||||
|
* already ran server-side (uuid rotated) — re-resolve the variant to the
|
||||||
|
* fresh playlist id and hand it to [regenerated] for navigate-replace.
|
||||||
|
* Unlike [regenerate] this does NOT trigger another rebuild.
|
||||||
|
*/
|
||||||
|
fun reloadRebuilt() {
|
||||||
|
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||||
|
?.detail?.playlist ?: return
|
||||||
|
val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
|
||||||
|
staleInternal.value = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.systemShuffle(variant).playlist.id }
|
||||||
|
.onSuccess { newId ->
|
||||||
|
if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #980: user dismissed the stale-view snackbar without refreshing. */
|
||||||
|
fun dismissStale() {
|
||||||
|
staleInternal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
/** Play the available tracks starting at [startTrackId] (or first available). */
|
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||||
val refs = tracks.toPlayableTrackRefs()
|
val refs = tracks.toPlayableTrackRefs()
|
||||||
@@ -261,8 +312,25 @@ fun PlaylistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #980: a rebuild landed while this system-playlist screen is open. Offer a
|
||||||
|
// non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
|
||||||
|
// rotated uuid (via the regenerated navigate-replace flow).
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val stale by viewModel.stale.collectAsState()
|
||||||
|
LaunchedEffect(stale) {
|
||||||
|
if (stale) {
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = "This mix was refreshed",
|
||||||
|
actionLabel = "Refresh",
|
||||||
|
duration = SnackbarDuration.Indefinite,
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
|
||||||
|
else viewModel.dismissStale()
|
||||||
|
}
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(currentTitle(state)) },
|
title = { Text(currentTitle(state)) },
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||||
* is blank or unresolvable.
|
* is blank/unresolvable, while the image is still loading, and when the
|
||||||
|
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||||
|
* which the "You might like" row hits often).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -22,12 +32,26 @@ fun ServerImage(
|
|||||||
val resolved = resolveServerUrl(url)
|
val resolved = resolveServerUrl(url)
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
fallback()
|
fallback()
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
// Track Coil's load state so the fallback doubles as a placeholder
|
||||||
|
// (loading) and an error state (404 / unreachable) — not just a
|
||||||
|
// null-URL guard, which left present-but-failing URLs blank.
|
||||||
|
var state by remember(resolved) {
|
||||||
|
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||||
|
}
|
||||||
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
|
onState = { state = it },
|
||||||
)
|
)
|
||||||
|
if (state is AsyncImagePainter.State.Loading ||
|
||||||
|
state is AsyncImagePainter.State.Error
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,7 +242,12 @@ func run() error {
|
|||||||
// active user's daily build at 03:00 in their stored timezone.
|
// active user's daily build at 03:00 in their stored timezone.
|
||||||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||||
// of this arc).
|
// of this arc).
|
||||||
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
|
playlistScheduler, err := playlists.NewScheduler(
|
||||||
|
pool,
|
||||||
|
logger.With("component", "playlist_scheduler"),
|
||||||
|
cfg.Storage.DataDir,
|
||||||
|
bus,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init playlist scheduler: %w", err)
|
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishSystemRebuilt notifies the owner's clients that their system
|
||||||
|
// playlists (and You-might-like rows) were regenerated, so they invalidate
|
||||||
|
// the home / system-playlist providers and a stale active queue can re-pull.
|
||||||
|
// Mirrors the daily scheduler's event; fired here from the manual refresh.
|
||||||
|
func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
|
||||||
|
if h.eventbus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.eventbus.Publish(eventbus.Event{
|
||||||
|
Kind: "playlist.system_rebuilt",
|
||||||
|
UserID: uuidToString(userID),
|
||||||
|
Data: map[string]any{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
||||||
// the request's original requester so their /requests page reflects the
|
// the request's original requester so their /requests page reflects the
|
||||||
// new state without manual refresh. Admin actors (approve / reject) still
|
// new state without manual refresh. Admin actors (approve / reject) still
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
|
|||||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// #968: announce the rebuild so the user's other clients refresh.
|
||||||
|
h.publishSystemRebuilt(user.ID)
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
v := kind
|
v := kind
|
||||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,14 +48,21 @@ type Scheduler struct {
|
|||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
dataDir string
|
dataDir string
|
||||||
|
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
|
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScheduler builds an idle scheduler. Caller must invoke Start
|
// NewScheduler builds an idle scheduler. Caller must invoke Start
|
||||||
// before any builds fire.
|
// before any builds fire. bus may be nil (tests); rebuild events are
|
||||||
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) {
|
// then skipped.
|
||||||
|
func NewScheduler(
|
||||||
|
pool *pgxpool.Pool,
|
||||||
|
logger *slog.Logger,
|
||||||
|
dataDir string,
|
||||||
|
bus *eventbus.Bus,
|
||||||
|
) (*Scheduler, error) {
|
||||||
g, err := gocron.NewScheduler()
|
g, err := gocron.NewScheduler()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init gocron: %w", err)
|
return nil, fmt.Errorf("init gocron: %w", err)
|
||||||
@@ -64,6 +72,7 @@ func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Sch
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
|
bus: bus,
|
||||||
jobs: map[pgtype.UUID]uuid.UUID{},
|
jobs: map[pgtype.UUID]uuid.UUID{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -234,7 +243,26 @@ func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, no
|
|||||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
|
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
|
||||||
s.logger.Warn("scheduler: build failed",
|
s.logger.Warn("scheduler: build failed",
|
||||||
"user_id", uuidStringPL(userID), "err", err)
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
// #968: tell the user's connected clients their home content was
|
||||||
|
// regenerated, so a tab/app left open across the rebuild refreshes its
|
||||||
|
// system-playlist + You-might-like views (and a stale active queue can
|
||||||
|
// re-pull) instead of serving yesterday's snapshot until a manual reload.
|
||||||
|
s.publishRebuilt(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishRebuilt broadcasts a user-scoped "system playlists rebuilt" event.
|
||||||
|
// No-op when the bus is nil (test construction).
|
||||||
|
func (s *Scheduler) publishRebuilt(userID pgtype.UUID) {
|
||||||
|
if s.bus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.bus.Publish(eventbus.Event{
|
||||||
|
Kind: "playlist.system_rebuilt",
|
||||||
|
UserID: uuidStringPL(userID),
|
||||||
|
Data: map[string]any{},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop drains gocron and stops the scheduler loop.
|
// Stop drains gocron and stops the scheduler loop.
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import AlbumMenu from './AlbumMenu.svelte';
|
import AlbumMenu from './AlbumMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
let { album }: { album: AlbumRef } = $props();
|
let { album }: { album: AlbumRef } = $props();
|
||||||
|
|
||||||
function onImgError(e: Event) {
|
|
||||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onAddClick(e: MouseEvent) {
|
async function onAddClick(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -38,12 +34,9 @@
|
|||||||
shadow-sm transition-all duration-150
|
shadow-sm transition-all duration-150
|
||||||
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||||
>
|
>
|
||||||
<img
|
<Cover
|
||||||
src={album.cover_url}
|
src={album.cover_url}
|
||||||
alt=""
|
class="transition-transform group-hover:scale-[1.03]"
|
||||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
|
||||||
loading="lazy"
|
|
||||||
onerror={onImgError}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
||||||
import { Disc3, Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import ArtistMenu from './ArtistMenu.svelte';
|
import ArtistMenu from './ArtistMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '$lib/api/artists';
|
||||||
|
|
||||||
let { artist }: { artist: ArtistRef } = $props();
|
let { artist }: { artist: ArtistRef } = $props();
|
||||||
@@ -48,18 +49,12 @@
|
|||||||
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
||||||
{#if artist.cover_url}
|
<Cover
|
||||||
<img
|
src={artist.cover_url}
|
||||||
src={artist.cover_url}
|
shape="round"
|
||||||
alt=""
|
fallback="artist"
|
||||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
class="transition-transform group-hover:scale-[1.03]"
|
||||||
loading="lazy"
|
/>
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-full w-full items-center justify-center">
|
|
||||||
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${artist.name}`}
|
aria-label={`Play ${artist.name}`}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import { Plus } from 'lucide-svelte';
|
import { Plus } from 'lucide-svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { coverUrl } from '$lib/media/covers';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
// Horizontal compact track row — cover thumb on the left, title +
|
// Horizontal compact track row — cover thumb on the left, title +
|
||||||
// artist on the right. Mirrors Android's CompactTrackTile so the
|
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||||
@@ -23,10 +24,6 @@
|
|||||||
|
|
||||||
const cover = $derived(coverUrl(track.album_id));
|
const cover = $derived(coverUrl(track.album_id));
|
||||||
|
|
||||||
function onImgError(e: Event) {
|
|
||||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick() {
|
function onClick() {
|
||||||
playQueue(sectionTracks, index);
|
playQueue(sectionTracks, index);
|
||||||
}
|
}
|
||||||
@@ -47,13 +44,7 @@
|
|||||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||||
<img
|
<Cover src={cover} />
|
||||||
src={cover}
|
|
||||||
alt=""
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
onerror={onImgError}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
|
import { Disc3 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
// Shared cover-artwork renderer. The web counterpart of Android's
|
||||||
|
// CoverTile/ServerImage: one place that owns the loading placeholder and
|
||||||
|
// the error fallback so no card sits blank while art loads or 404s (the
|
||||||
|
// "You might like" row surfaces unplayed items whose art is often not yet
|
||||||
|
// backfilled). Replaces the three hand-rolled <img> tags that previously
|
||||||
|
// disagreed on fallback handling.
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
shape = 'square',
|
||||||
|
fallback = 'image',
|
||||||
|
class: klass = ''
|
||||||
|
}: {
|
||||||
|
src: string | undefined;
|
||||||
|
alt?: string;
|
||||||
|
// 'round' clips to a circle (artist avatars); 'square' leaves clipping
|
||||||
|
// to the parent so existing rounded-md / rounded corners still apply.
|
||||||
|
shape?: 'square' | 'round';
|
||||||
|
// 'artist' degrades to a Disc3 icon; 'image' to the static placeholder
|
||||||
|
// cover. A null/empty src is treated the same as a failed load.
|
||||||
|
fallback?: 'image' | 'artist';
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Per-image lifecycle. Reset when src changes so a reused card (virtualized
|
||||||
|
// list, client-side nav) never keeps a stale 'loaded'/'failed'.
|
||||||
|
let loaded = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
void src;
|
||||||
|
loaded = false;
|
||||||
|
failed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const useIcon = $derived(fallback === 'artist' && (failed || !src));
|
||||||
|
// On failure for the image fallback, swap to the static placeholder asset.
|
||||||
|
// src doesn't change on the second error, so there's no reload loop.
|
||||||
|
const imgSrc = $derived(failed ? FALLBACK_COVER : src);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative h-full w-full overflow-hidden bg-surface-hover {shape === 'round'
|
||||||
|
? 'rounded-full'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{#if useIcon}
|
||||||
|
<div class="flex h-full w-full items-center justify-center">
|
||||||
|
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
{:else if imgSrc}
|
||||||
|
<img
|
||||||
|
src={imgSrc}
|
||||||
|
{alt}
|
||||||
|
class="h-full w-full object-cover transition-opacity duration-200 {loaded
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0'} {klass}"
|
||||||
|
loading="lazy"
|
||||||
|
onload={() => (loaded = true)}
|
||||||
|
onerror={() => (failed = true)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
||||||
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
||||||
|
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
@@ -80,7 +81,14 @@
|
|||||||
const detail = await systemShuffle(variant);
|
const detail = await systemShuffle(variant);
|
||||||
const refs = toTrackRefs(detail.tracks);
|
const refs = toTrackRefs(detail.tracks);
|
||||||
if (refs.length > 0) {
|
if (refs.length > 0) {
|
||||||
playQueue(refs, 0, { source: variant });
|
playQueue(refs, 0, {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: playlist.id,
|
||||||
|
perArtist: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const detail = await getPlaylist(playlist.id);
|
const detail = await getPlaylist(playlist.id);
|
||||||
@@ -93,7 +101,14 @@
|
|||||||
// so source attribution stays absent — the PlaylistCard
|
// so source attribution stays absent — the PlaylistCard
|
||||||
// test pins this contract.
|
// test pins this contract.
|
||||||
if (variant != null) {
|
if (variant != null) {
|
||||||
playQueue(refs, 0, { source: variant });
|
playQueue(refs, 0, {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: playlist.id,
|
||||||
|
perArtist: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
playQueue(refs, 0);
|
playQueue(refs, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,23 @@ let _radioRefreshInFlight = false;
|
|||||||
// queue clears it).
|
// queue clears it).
|
||||||
let _queueSource = $state<string | null>(null);
|
let _queueSource = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Track B (#968): consecutive load failures since the last successful
|
||||||
|
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
|
||||||
|
// (capped against queue length) stops a fully-unplayable queue from
|
||||||
|
// looping forever. Reset on a successful play and on a fresh playQueue.
|
||||||
|
let _failureStreak = 0;
|
||||||
|
|
||||||
|
// Track B (#968): when a queue is seeded from a refreshable source (a system
|
||||||
|
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
|
||||||
|
// snapshot. On total failure (whole queue unplayable — likely a stale tab
|
||||||
|
// left open across the daily rebuild) we call it once to self-heal before
|
||||||
|
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
|
||||||
|
// playlist API and the per-artist (songs_like_artist) identity problem.
|
||||||
|
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
|
||||||
|
let _selfHealInFlight = false;
|
||||||
|
let _selfHealAttempts = 0;
|
||||||
|
const MAX_SELF_HEAL_ATTEMPTS = 1;
|
||||||
|
|
||||||
let _audioEl: HTMLAudioElement | null = null;
|
let _audioEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export const player = {
|
export const player = {
|
||||||
@@ -109,13 +126,20 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
|||||||
export function playQueue(
|
export function playQueue(
|
||||||
tracks: TrackRef[],
|
tracks: TrackRef[],
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
opts: { shuffle?: boolean; source?: string | null } = {},
|
opts: {
|
||||||
|
shuffle?: boolean;
|
||||||
|
source?: string | null;
|
||||||
|
refetch?: () => Promise<TrackRef[]>;
|
||||||
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||||
// #415: a fresh queue resets the system-playlist source. Set only
|
// #415: a fresh queue resets the system-playlist source. Set only
|
||||||
// when seeded from a system playlist (server already returned the
|
// when seeded from a system playlist (server already returned the
|
||||||
// rotation-aware order, so no client shuffle in that path).
|
// rotation-aware order, so no client shuffle in that path).
|
||||||
_queueSource = opts.source ?? null;
|
_queueSource = opts.source ?? null;
|
||||||
|
// #968: a fresh play replaces the self-heal closure + resets its budget.
|
||||||
|
_queueRefetch = opts.refetch ?? null;
|
||||||
|
_selfHealAttempts = 0;
|
||||||
if (opts.shuffle && tracks.length > 1) {
|
if (opts.shuffle && tracks.length > 1) {
|
||||||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||||||
// caller is asking for "random play from this pool," so the first
|
// caller is asking for "random play from this pool," so the first
|
||||||
@@ -142,6 +166,7 @@ export function playQueue(
|
|||||||
_position = 0;
|
_position = 0;
|
||||||
_duration = 0;
|
_duration = 0;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_failureStreak = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function togglePlay(): void {
|
export function togglePlay(): void {
|
||||||
@@ -263,6 +288,8 @@ export function reportStateFromAudio(
|
|||||||
case 'playing':
|
case 'playing':
|
||||||
_state = 'playing';
|
_state = 'playing';
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_failureStreak = 0;
|
||||||
|
_selfHealAttempts = 0;
|
||||||
return;
|
return;
|
||||||
case 'paused':
|
case 'paused':
|
||||||
_state = 'paused';
|
_state = 'paused';
|
||||||
@@ -271,8 +298,7 @@ export function reportStateFromAudio(
|
|||||||
_state = 'loading';
|
_state = 'loading';
|
||||||
return;
|
return;
|
||||||
case 'error':
|
case 'error':
|
||||||
_state = 'error';
|
handleLoadFailure(detail);
|
||||||
_error = detail ?? 'Playback failed.';
|
|
||||||
return;
|
return;
|
||||||
case 'ended':
|
case 'ended':
|
||||||
if (_repeat === 'one') {
|
if (_repeat === 'one') {
|
||||||
@@ -301,6 +327,68 @@ export function reportStateFromAudio(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track B (#968): a single failed track must not strand the player on the
|
||||||
|
// "Try again" dead-end. Advance past it; only surface the error once the
|
||||||
|
// whole queue has proven unplayable — every track failed, or we reached the
|
||||||
|
// end with nothing playable. The streak cap (vs queue length) stops a
|
||||||
|
// fully-broken queue from cycling forever instead of settling.
|
||||||
|
function handleLoadFailure(detail?: string): void {
|
||||||
|
_failureStreak++;
|
||||||
|
if (_failureStreak < _queue.length && _index + 1 < _queue.length) {
|
||||||
|
_index++;
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Whole queue is unplayable. If it came from a refreshable source, the
|
||||||
|
// snapshot is probably stale — re-pull it and resume before giving up.
|
||||||
|
if (trySelfHeal()) return;
|
||||||
|
_state = 'error';
|
||||||
|
_error = detail ?? 'Playback failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
|
||||||
|
// started (caller must not dead-end). Bounded to one attempt per exhaustion
|
||||||
|
// (reset on the next successful play) so a still-broken refresh can't loop.
|
||||||
|
function trySelfHeal(): boolean {
|
||||||
|
if (_selfHealInFlight) return true;
|
||||||
|
if (_queueRefetch === null) return false;
|
||||||
|
if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
|
||||||
|
_selfHealAttempts++;
|
||||||
|
_selfHealInFlight = true;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
const refetch = _queueRefetch;
|
||||||
|
void refetch()
|
||||||
|
.then((refs) => {
|
||||||
|
if (refs.length > 0) {
|
||||||
|
// Re-seed in place — keep _queueSource / _queueRefetch so the new
|
||||||
|
// snapshot can itself self-heal, and don't reset _selfHealAttempts
|
||||||
|
// (only a successful 'playing' clears the budget).
|
||||||
|
_queue = refs;
|
||||||
|
_index = 0;
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_failureStreak = 0;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
} else {
|
||||||
|
_state = 'error';
|
||||||
|
_error = 'Nothing playable in this mix right now.';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
_state = 'error';
|
||||||
|
_error = 'Couldn’t refresh this mix. Try again.';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_selfHealInFlight = false;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function enqueueTrack(t: TrackRef): void {
|
export function enqueueTrack(t: TrackRef): void {
|
||||||
_radioSeedId = null; // M4c
|
_radioSeedId = null; // M4c
|
||||||
_queue = [..._queue, t];
|
_queue = [..._queue, t];
|
||||||
@@ -345,12 +433,19 @@ export function playNextMany(ts: TrackRef[]): void {
|
|||||||
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playRadio(seedTrackId: string): Promise<void> {
|
async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
|
||||||
const resp = await api.get<RadioResponse>(
|
const resp = await api.get<RadioResponse>(
|
||||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||||
);
|
);
|
||||||
if (resp.tracks.length === 0) return;
|
return resp.tracks;
|
||||||
playQueue(resp.tracks, 0);
|
}
|
||||||
|
|
||||||
|
export async function playRadio(seedTrackId: string): Promise<void> {
|
||||||
|
const tracks = await fetchRadioTracks(seedTrackId);
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
// #968: hand the player a self-heal closure so a fully-stale radio queue
|
||||||
|
// re-seeds from the same track instead of dead-ending.
|
||||||
|
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
|
||||||
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,50 @@ describe('player store — shuffle + repeat + audio reports', () => {
|
|||||||
expect(player.state).toBe('error');
|
expect(player.state).toBe('error');
|
||||||
expect(player.error).toBe('network lost');
|
expect(player.error).toBe('network lost');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reportStateFromAudio("error") auto-advances past a bad track', () => {
|
||||||
|
playQueue([track('1'), track('2'), track('3')]);
|
||||||
|
reportStateFromAudio('error', 'boom');
|
||||||
|
expect(player.index).toBe(1);
|
||||||
|
expect(player.state).toBe('loading');
|
||||||
|
expect(player.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => {
|
||||||
|
playQueue([track('1'), track('2')]);
|
||||||
|
reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2
|
||||||
|
expect(player.index).toBe(1);
|
||||||
|
expect(player.state).toBe('loading');
|
||||||
|
reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted
|
||||||
|
expect(player.state).toBe('error');
|
||||||
|
expect(player.error).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => {
|
||||||
|
const fresh = [track('a'), track('b')];
|
||||||
|
const refetch = vi.fn().mockResolvedValue(fresh);
|
||||||
|
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||||
|
reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal
|
||||||
|
expect(refetch).toHaveBeenCalledOnce();
|
||||||
|
expect(player.state).toBe('loading');
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']);
|
||||||
|
expect(player.index).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self-heal fires at most once per exhaustion, then dead-ends', async () => {
|
||||||
|
const refetch = vi.fn().mockResolvedValue([track('x')]);
|
||||||
|
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||||
|
reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error
|
||||||
|
expect(player.state).toBe('error');
|
||||||
|
expect(refetch).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { TrackRef, PlaylistDetail } from '$lib/api/types';
|
||||||
|
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
|
||||||
|
import { playlistTrackToRef } from './playlistTrackToRef';
|
||||||
|
|
||||||
|
// #968: builds the self-heal closure for a system-playlist queue. On total
|
||||||
|
// playback failure (every queued track unplayable — typically a stale tab
|
||||||
|
// left open across the daily rebuild) the player calls this to re-pull the
|
||||||
|
// current snapshot and resume.
|
||||||
|
//
|
||||||
|
// Single-instance variants (for_you, discover, deep_cuts, …) re-pull by
|
||||||
|
// variant via the rotation-aware shuffle endpoint. Per-artist mixes
|
||||||
|
// (songs_like_artist — one playlist per seed artist) can't be addressed by
|
||||||
|
// variant alone, so they re-pull by playlist id. Mirrors the play routing
|
||||||
|
// in PlaylistCard.
|
||||||
|
|
||||||
|
function toRefs(detail: PlaylistDetail): TrackRef[] {
|
||||||
|
return detail.tracks
|
||||||
|
.map((r) => playlistTrackToRef(r))
|
||||||
|
.filter((t): t is TrackRef => t !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function systemPlaylistRefetch(opts: {
|
||||||
|
variant: string;
|
||||||
|
playlistId: string;
|
||||||
|
perArtist: boolean;
|
||||||
|
}): () => Promise<TrackRef[]> {
|
||||||
|
return async () => {
|
||||||
|
const detail = opts.perArtist
|
||||||
|
? await getPlaylist(opts.playlistId)
|
||||||
|
: await systemShuffle(opts.variant);
|
||||||
|
return toRefs(detail);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { queryClient } from '$lib/query/client';
|
||||||
|
import { qk } from '$lib/api/queries';
|
||||||
|
import { user } from '$lib/auth/store.svelte';
|
||||||
|
|
||||||
|
// Inbound server events over SSE (#968). The web client's OUTBOUND half
|
||||||
|
// (play events) lives in player/events.svelte.ts; this is the inbound
|
||||||
|
// listener it never had. Today it reacts to `playlist.system_rebuilt` — the
|
||||||
|
// daily 03:00 rebuild or a manual refresh — by invalidating the home +
|
||||||
|
// system-playlist query caches so a tab left open across the rebuild stops
|
||||||
|
// serving yesterday's snapshot (the stale-browse-view bug). Active playback
|
||||||
|
// self-heals separately on the failure path (store.handleLoadFailure); we
|
||||||
|
// deliberately do NOT yank a playing queue here — that would interrupt a
|
||||||
|
// mid-song listen to restart the new mix at track 0.
|
||||||
|
|
||||||
|
// Monotonic rebuild counter (#980). Bumped on every playlist.system_rebuilt.
|
||||||
|
// An open system-playlist DETAIL page can't be invalidated in place (its id
|
||||||
|
// rotates on rebuild — a refetch would 404), so instead it watches this
|
||||||
|
// counter and offers a "this mix was refreshed → Refresh" affordance that
|
||||||
|
// re-resolves the variant to the new playlist.
|
||||||
|
let _systemRebuildCount = $state(0);
|
||||||
|
export const systemRebuilt = {
|
||||||
|
get count(): number {
|
||||||
|
return _systemRebuildCount;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function onSystemRebuilt(): void {
|
||||||
|
_systemRebuildCount++;
|
||||||
|
queryClient.invalidateQueries({ queryKey: qk.home() });
|
||||||
|
// Prefix match invalidates every kind ('user' | 'system' | 'all').
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['playlists'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: qk.systemPlaylistsStatus() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire once from +layout.svelte's mount. Opens the stream only while
|
||||||
|
// authenticated and closes it on logout; EventSource auto-reconnects on
|
||||||
|
// transient network drops.
|
||||||
|
export function useServerEvents(): void {
|
||||||
|
$effect(() => {
|
||||||
|
if (!user.value) return;
|
||||||
|
// Absent under SSR / jsdom — the listener simply no-ops there.
|
||||||
|
if (typeof EventSource === 'undefined') return;
|
||||||
|
const es = new EventSource('/api/events/stream');
|
||||||
|
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||||
|
return () => {
|
||||||
|
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
} from '$lib/player/store.svelte';
|
} from '$lib/player/store.svelte';
|
||||||
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
||||||
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
||||||
|
import { useServerEvents } from '$lib/serverEvents.svelte';
|
||||||
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
|
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
|
||||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||||
import { audioLoader } from '$lib/player/audioLoader';
|
import { audioLoader } from '$lib/player/audioLoader';
|
||||||
@@ -132,6 +133,7 @@
|
|||||||
|
|
||||||
useMediaSession();
|
useMediaSession();
|
||||||
useEventsDispatcher();
|
useEventsDispatcher();
|
||||||
|
useServerEvents();
|
||||||
useGlobalShortcuts();
|
useGlobalShortcuts();
|
||||||
applyMetaThemeColor();
|
applyMetaThemeColor();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -12,12 +13,15 @@
|
|||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
removePlaylistTrack,
|
removePlaylistTrack,
|
||||||
reorderPlaylist,
|
reorderPlaylist,
|
||||||
refreshSystem
|
refreshSystem,
|
||||||
|
systemShuffle
|
||||||
} from '$lib/api/playlists';
|
} from '$lib/api/playlists';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { errCode, errMessage } from '$lib/api/errors';
|
import { errCode, errMessage } from '$lib/api/errors';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
|
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||||
|
import { systemRebuilt } from '$lib/serverEvents.svelte';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -61,16 +65,31 @@
|
|||||||
|
|
||||||
function onPlay(position: number) {
|
function onPlay(position: number) {
|
||||||
if (!playlistQuery?.data) return;
|
if (!playlistQuery?.data) return;
|
||||||
|
const data = playlistQuery.data;
|
||||||
// Skip rows where track_id is null (track was removed from library).
|
// Skip rows where track_id is null (track was removed from library).
|
||||||
const live = playlistQuery.data.tracks
|
const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef);
|
||||||
.filter((t) => t.track_id !== null)
|
|
||||||
.map(toTrackRef);
|
|
||||||
// Find which `live` index corresponds to the clicked position. Some
|
// Find which `live` index corresponds to the clicked position. Some
|
||||||
// positions in the original list may be unavailable (filtered out),
|
// positions in the original list may be unavailable (filtered out),
|
||||||
// so the index in the playable list != the playlist position.
|
// so the index in the playable list != the playlist position.
|
||||||
const clickedTrackID = playlistQuery.data.tracks.find((t) => t.position === position)?.track_id;
|
const clickedTrackID = data.tracks.find((t) => t.position === position)?.track_id;
|
||||||
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
|
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
|
||||||
playQueue(live, Math.max(0, startIdx));
|
// #968: a system playlist played from its detail page is tagged with its
|
||||||
|
// source (so play_started advances the rotation, matching the home tile and
|
||||||
|
// the Android detail screen) and gets the self-heal closure so a stale
|
||||||
|
// snapshot re-pulls on total failure.
|
||||||
|
const variant = data.system_variant;
|
||||||
|
if (variant != null) {
|
||||||
|
playQueue(live, Math.max(0, startIdx), {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: id,
|
||||||
|
perArtist: data.seed_artist_id != null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
playQueue(live, Math.max(0, startIdx));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTrackRef(t: {
|
function toTrackRef(t: {
|
||||||
@@ -158,6 +177,40 @@
|
|||||||
refreshingSystem = false;
|
refreshingSystem = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- #980: stale-view banner for an open system-playlist detail page ---
|
||||||
|
// The daily rebuild rotates the playlist id, so this page's cached data goes
|
||||||
|
// stale and can't be refetched in place (the old id 404s). Watch the global
|
||||||
|
// rebuild counter; when it advances while we're on a system playlist, offer a
|
||||||
|
// Refresh that re-resolves the variant to the fresh playlist.
|
||||||
|
const isSystemPlaylist = $derived(playlistQuery?.data?.system_variant != null);
|
||||||
|
// Baseline starts at the current count so a rebuild earlier this session
|
||||||
|
// doesn't flash the banner on open; re-synced on navigation below.
|
||||||
|
let acknowledgedRebuild = $state(systemRebuilt.count);
|
||||||
|
$effect(() => {
|
||||||
|
id; // re-sync the baseline whenever we land on a different playlist
|
||||||
|
acknowledgedRebuild = untrack(() => systemRebuilt.count);
|
||||||
|
});
|
||||||
|
const staleSystemView = $derived(
|
||||||
|
isSystemPlaylist && systemRebuilt.count > acknowledgedRebuild
|
||||||
|
);
|
||||||
|
|
||||||
|
let reloadingStale = $state(false);
|
||||||
|
async function onReloadStale() {
|
||||||
|
const variant = playlistQuery?.data?.system_variant;
|
||||||
|
if (!variant) return;
|
||||||
|
reloadingStale = true;
|
||||||
|
try {
|
||||||
|
// The rebuild minted a new playlist id for this variant; resolve it and
|
||||||
|
// navigate there so the page loads the fresh mix.
|
||||||
|
const fresh = await systemShuffle(variant);
|
||||||
|
await goto(`/playlists/${fresh.id}`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Couldn’t load the refreshed mix: ${errCode(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
reloadingStale = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -171,6 +224,24 @@
|
|||||||
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
||||||
{:else if playlistQuery?.data}
|
{:else if playlistQuery?.data}
|
||||||
{@const pl = playlistQuery.data}
|
{@const pl = playlistQuery.data}
|
||||||
|
{#if staleSystemView}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
class="mb-4 flex items-center justify-between gap-3 rounded-md border border-accent/40
|
||||||
|
bg-accent/10 px-4 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-text-primary">This mix was refreshed since you opened it.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onReloadStale}
|
||||||
|
disabled={reloadingStale}
|
||||||
|
class="flex-shrink-0 rounded-md bg-action-secondary px-3 py-1 text-xs font-medium
|
||||||
|
text-action-fg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reloadingStale ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if !editing}
|
{#if !editing}
|
||||||
<header class="mb-6 flex items-start gap-4">
|
<header class="mb-6 flex items-start gap-4">
|
||||||
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
||||||
|
|||||||
Reference in New Issue
Block a user