Compare commits

...

16 Commits

Author SHA1 Message Date
bvandeusen 0de2437689 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
2026-06-20 20:57:39 -04:00
bvandeusen f4f4df7708 feat(android/playlists): stale-view snackbar + Refresh on open system playlist after rebuild
android / Build + lint + test (push) Successful in 3m38s
#980, parity with web e932ab43. When playlist.system_rebuilt arrives (SSE)
while a system-playlist detail screen is open, the ViewModel marks it stale and
the screen shows an indefinite "This mix was refreshed · Refresh" snackbar.
Refresh re-resolves the rotated variant via PlaylistsRepository.systemShuffle
and reuses the existing regenerated navigate-replace flow to land on the fresh
playlist id — without triggering another server rebuild (unlike the manual
regenerate button). Dismiss clears the flag. Functional behaviors were already
correct; this closes the cosmetic stale-list gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:52:13 -04:00
bvandeusen e932ab438c feat(web/playlists): stale-view banner + Refresh on an open system-playlist after rebuild
test-web / test (push) Successful in 39s
#980. When the daily rebuild fires while a system-playlist detail page is
open, its cached data goes stale and can't be refetched in place — the
playlist id rotated, so the old id 404s. serverEvents now exposes a monotonic
rebuild counter; the detail page shows a "this mix was refreshed" banner with
a Refresh that re-resolves the variant (systemShuffle) to the new playlist id
and navigates there. No forced redirect, no auto-reload — the user refreshes
on their terms. Functional behaviors were already correct (tapping a song
plays it; tiles load the current mix); this closes the cosmetic list-staleness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:48:52 -04:00
bvandeusen d05264ff80 fix(web/playlists): attribute source when playing a system playlist from its detail page
test-web / test (push) Successful in 38s
Playing a system playlist from /playlists/<id> previously sent no source, so
it never advanced that playlist's rotation — inconsistent with the home tile
(and the Android detail screen, which already tags the variant). Pass
source: variant alongside the existing self-heal closure so a play is
attributed regardless of the surface it started from. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:35:45 -04:00
bvandeusen a23e2e36ca feat(android/home): refresh Home on playlist.system_rebuilt
android / Build + lint + test (push) Successful in 3m46s
Parity with the web SSE consumer (5a80a1e4). HomeViewModel now subscribes to
EventsStream and re-pulls Home (refreshIndex + system-playlist status) when
the server emits playlist.system_rebuilt — the daily 03:00 rebuild or a
manual refresh — so the system-playlist tiles and You-might-like rows reflect
the new snapshot without a manual reload. Browse-only: the active playback
queue is left to self-heal on the failure path. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:46:35 -04:00
bvandeusen 5a80a1e460 feat(web): subscribe to SSE and refresh home/playlists on playlist.system_rebuilt
test-web / test (push) Successful in 39s
The web client only ever SENT events; it had no inbound SSE listener, so a
tab left open across the daily system-playlist rebuild kept showing
yesterday's home + playlist snapshots until a manual reload (the stale-
browse-view bug behind #968). Add useServerEvents(): opens /api/events/stream
while authenticated and, on playlist.system_rebuilt, invalidates the home,
playlists, and system-playlist-status query caches. Deliberately does not
disturb the active playback queue — that self-heals on the failure path.
Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:44:05 -04:00
bvandeusen 16f76ea707 feat(server): emit playlist.system_rebuilt on daily + manual system-playlist rebuild
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s
The daily 03:00 scheduler rebuild (and the manual refresh endpoint) replace
a user's system playlists + You-might-like rows but published no event, so a
client left open across the rebuild served yesterday's snapshot until a
manual reload — the stale-tab case behind #968. Add a user-scoped
playlist.system_rebuilt event (envelope {kind,user_id,data:{}}) from both the
scheduler (bus threaded into NewScheduler) and handleSystemPlaylistRefresh.
Clients consume it to invalidate home / system-playlist views and proactively
re-pull a stale active queue. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:59:27 -04:00
bvandeusen d4cc177db4 feat(android/player): self-heal a stale system-playlist / radio queue on total failure
android / Build + lint + test (push) Successful in 3m56s
Web parity with 27766ae0. When a load error exhausts a fully-unplayable
queue, re-pull the source instead of stopping: a bare-variant source is a
refreshable system playlist (re-pull via PlaylistsRepository.systemShuffle),
"radio:<seed>" re-seeds via RadioController. Reads the source from the
current MediaItem extra; bounded to one re-pull per exhaustion (reset when
a track next loads with real audio) so a still-stale refresh can't loop.
Album / artist / user-playlist / offline sources have nothing to refresh
and still stop. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:56:01 -04:00
bvandeusen 27766ae063 feat(web/player): self-heal a stale system-playlist / radio queue on total failure
test-web / test (push) Successful in 39s
When the whole queue proves unplayable (e.g. a tab left open across the
daily system-playlist rebuild — the exact stale-snapshot case), the player
now re-pulls the fresh snapshot and resumes instead of dead-ending on
"Try again". The seeder hands the store an opaque refetch closure so the
store stays decoupled from the playlist API and the per-artist
(songs_like_artist) identity problem: single-instance variants re-pull via
systemShuffle, per-artist mixes via getPlaylist(id), radio re-seeds from
its track. Bounded to one self-heal per exhaustion (reset on the next
successful play) so a still-broken refresh can't loop; "Try again" stays
the genuine last resort. Wired from PlaylistCard, the playlist detail page,
and playRadio. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:51:47 -04:00
bvandeusen 335d782215 fix(android/player): auto-skip a failed track on load error, not just zero-duration
android / Build + lint + test (push) Successful in 3m47s
onPlayerError fired a `load_failed` event and the snackbar reporter coalesced
it into "Skipped N unplayable tracks" — but nothing actually skipped, so a
bad/stale track stranded playback while the toast claimed otherwise. Mirror
the zero_duration path: advance to the next item and re-prepare (a load error
leaves the player IDLE), or stop at the end. Forward-only bounds a fully-
unplayable queue. Web parity with 2a8de82a. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:41:30 -04:00
bvandeusen 2a8de82a17 fix(web/player): auto-skip a failed track instead of dead-ending on "Try again"
test-web / test (push) Successful in 39s
A track that fails to load (e.g. a stale system-playlist snapshot pointing
at a rebuilt/removed file) hard-set the player to the 'error' state and
stranded the user on a "Try again" button that just re-queued the same
failing track. Now a load error advances to the next track; the error
state only surfaces once the whole queue has proven unplayable — every
track failed, or we reached the end. A failure streak capped at queue
length stops a fully-broken queue from cycling, and resets on the next
successful play.

Next (Track B cont.): self-heal a stale system-playlist / radio queue by
re-pulling the fresh snapshot on total failure, plus the Android
equivalent. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:40:08 -04:00
bvandeusen 096a3c0b15 fix(clients): never leave cover tiles blank — shared web <Cover> + Android Coil placeholder/error
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 4m10s
Cover tiles (worst in the "You might like" home row, which surfaces
unplayed items whose art is often not yet backfilled) sat empty while
loading and stayed blank on a 404. The server returns a fast 404; the
gap was missing client-side loading/fallback states.

Web: new shared Cover.svelte owns the loading placeholder + onerror
fallback (static cover, or Disc3 for artists). AlbumCard, ArtistCard and
CompactTrackCard now reuse it instead of three hand-rolled <img> tags
that disagreed on fallback handling — notably ArtistCard had no onerror.

Android: ServerImage tracks Coil's load state so the per-caller fallback
doubles as a placeholder (loading) and an error state (404 / unreachable),
instead of only guarding the null-URL case. All five call sites pass an
explicit size modifier, so the new Box wrapper is layout-safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:29:05 -04:00
bvandeusen a251dce7e3 Merge pull request 'feat: scan on startup by default + README first-run walkthrough & screenshots' (#102) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 37s
release / Build + push container image (push) Successful in 1m44s
test-go / integration (push) Successful in 4m35s
2026-06-20 11:40:56 -04:00
bvandeusen 97e0e88483 feat(server): scan library on startup by default + README first-run walkthrough
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m38s
Make a fresh install usable out of the box and document the first-run flow
for the public-facing repo.

- Default scan_on_startup to true (Default() + config.example.yaml, which is
  the live config baked into the image). Previously false, so a fresh stack
  came up with an empty library and no hint to scan. Scans are incremental
  (mtime skip), so the per-restart cost is just a directory walk. Re-point
  the env-override test to exercise the override against the new default.
- README: add a "First run" walkthrough (register -> scan -> integrations ->
  install Android app -> invite users), each grounded in a real route.
- Add docs/screenshots/ with six captures, referenced via width-constrained
  <img> wrapped in a link (shrink inline + click to open full size).
  API token and invite token were cropped/redacted out of the captures
  before commit so no live credential lands in the public history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:35:22 -04:00
bvandeusen 938dae7163 Merge pull request 'docs: correct README setup/OOBE + fix data-volume mount' (#101) from dev into main 2026-06-20 10:58:07 -04:00
bvandeusen ad37937949 docs: correct README setup/OOBE + fix data-volume mount
Bring the README and client/README in line with the current product and
release pipeline ahead of making the repo public-facing:

- Highlights: replace the retired "Flutter client in flight" bullet with
  the native-Android-APK-bundled-in-the-image story.
- Quickstart: document the purpose of each compose volume mount, and fix
  the minstrel-data mount (/data -> /app/data) so it matches the image's
  MINSTREL_STORAGE_DATA_DIR and generated artefacts actually persist.
- Configuration: tie MINSTREL_STORAGE_DATA_DIR to the /app/data mount.
- Updating: replace the bogus :v1.0.x scheme with the real tag model
  (:latest, immutable :vYYYY.MM.DD, :main) and note the bundled APK.
- client/README: rewrite the Production section to match release.yml
  (android-release + needs: ordering, non-tag :latest bundle path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:57:28 -04:00
31 changed files with 743 additions and 76 deletions
+44 -7
View File
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
> State and intelligence belong on the server, not the client. > State and intelligence belong on the server, not the client.
<!-- TODO: screenshot of the home page --> <a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
## Highlights ## Highlights
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag. - **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit. - **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy. - **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356). - **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Quickstart ## Quickstart
@@ -24,10 +24,18 @@ services:
image: git.fabledsword.com/bvandeusen/minstrel:latest image: git.fabledsword.com/bvandeusen/minstrel:latest
ports: ['4533:4533'] ports: ['4533:4533']
volumes: volumes:
# Your music library. Point ./music at wherever your audio files
# live. Mounted read-only — Minstrel never writes to your library.
- ./music:/music:ro - ./music:/music:ro
- minstrel-data:/data # Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
# sets to /app/data — keep this mount on /app/data or your cache
# won't survive a container recreate.
- minstrel-data:/app/data
environment: environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container
# path of the read-only music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS: /music MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db] depends_on: [db]
@@ -37,6 +45,8 @@ services:
POSTGRES_USER: minstrel POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel POSTGRES_DB: minstrel
# Postgres data dir — users, likes, play history, sessions, settings.
# The one volume you must never lose; back it up with pg_dump.
volumes: [pgdata:/var/lib/postgresql/data] volumes: [pgdata:/var/lib/postgresql/data]
volumes: volumes:
@@ -48,7 +58,29 @@ volumes:
docker compose up -d docker compose up -d
``` ```
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration). ## First run
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The &quot;Install the Android app&quot; download card in Settings"></a>
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml). For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
@@ -57,7 +89,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
Most operators only need the env vars in the quickstart above. A few extras worth knowing: Most operators only need the env vars in the quickstart above. A few extras worth knowing:
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews. - `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
- `MINSTREL_STORAGE_DATA_DIR`defaults to `./data`. Holds playlist cover collages and other generated artefacts. - `MINSTREL_STORAGE_DATA_DIR`where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`). - `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting. ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
@@ -66,8 +98,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
## Updating ## Updating
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment. Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
## Specs ## Specs
@@ -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
@@ -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()
}
} }
} }
+18 -8
View File
@@ -6,17 +6,27 @@ in-app update flow (#397).
## Production ## Production
CI populates this directory on tag releases: CI populates this directory in `release.yml` (no Flutter — the client is
1. `flutter.yml` builds `app-release.apk` and attaches it to the native Android, built by the `android-release` job):
Gitea release as `minstrel-<TAG>.apk`.
2. `release.yml` waits for that asset to appear, downloads it into - **Tag release (`v*`):** the `android-release` job builds + signs the
this directory as `minstrel.apk`, writes the tag string to APK, uploads it as a workflow artifact, and attaches it to the Gitea
`minstrel.apk.version`, and `docker buildx build` includes both release as `minstrel-<TAG>.apk`. The `image-release` job
via `COPY client/ /app/client/`. (`needs: android-release`) downloads that artifact into this directory
as `minstrel.apk` and writes the computed versionName to
`minstrel.apk.version`.
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the
image still moves `:latest`, so `image-release` pulls the most-recent
release's signed APK from the Gitea API and reconstructs its exact
versionName — keeping the in-app update channel alive on `:latest`
instead of stripping it until the next tag.
Either way, `docker buildx build` bakes both files into the image via
`COPY client/ /app/client/`.
## Development ## Development
Empty directory works fine — the endpoints return 404 and the Flutter Empty directory works fine — the endpoints return 404 and the Android
update banner stays hidden ("no update channel" graceful degradation). update banner stays hidden ("no update channel" graceful degradation).
To smoke-test the update flow locally, drop a real APK + a version To smoke-test the update flow locally, drop a real APK + a version
+6 -1
View File
@@ -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)
} }
+7 -2
View File
@@ -26,8 +26,13 @@ library:
# Filesystem roots to scan for music. Each path is walked recursively. # Filesystem roots to scan for music. Each path is walked recursively.
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style) # Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
scan_paths: [] scan_paths: []
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP # Kick off a scan on server startup. On by default so a fresh install
scan_on_startup: false # populates its library on first boot with no manual step. Scans are
# incremental (unchanged files are skipped by mtime), so the cost on
# every later restart is just a directory walk, not a full re-index.
# Set false if you'd rather scan only on a schedule / via the admin UI.
# Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
scan_on_startup: true
# Per-instance branding. Operators can rename their instance ("Family # Per-instance branding. Operators can rename their instance ("Family
# Jukebox", "Office Music", etc.) and override the OG share-preview # Jukebox", "Office Music", etc.) and override the OG share-preview
+19
View File
@@ -0,0 +1,19 @@
# README screenshots
Drop the files below into this directory with these **exact names** — the
root `README.md` already references them, so no Markdown editing is needed.
Export as PNG. Capture while logged in as admin and *after* the first
library scan so nothing looks empty; keep the same browser width and theme
across all shots for a consistent look.
| Filename | Route | What to capture |
|---|---|---|
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
| `register.png` | `/register` | The account-creation form on a fresh instance |
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
| `android-download.png` | `/settings` | The "Install the Android app" download card |
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
Tip: to control display width in the README, you can swap the Markdown
`![alt](path)` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+15
View File
@@ -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
+2
View File
@@ -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(),
+5
View File
@@ -135,6 +135,11 @@ func Default() Config {
RadioSizeMax: 200, RadioSizeMax: 200,
}, },
Library: LibraryConfig{ Library: LibraryConfig{
// On by default so a fresh install populates its library on
// first boot without the operator hunting for a "scan now"
// button. Scans are incremental (unchanged files skipped by
// mtime), so every later restart is just a cheap directory walk.
ScanOnStartup: true,
CoverArtFromMBCAA: true, CoverArtFromMBCAA: true,
}, },
} }
+4 -3
View File
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
func TestLibraryEnvOverrides(t *testing.T) { func TestLibraryEnvOverrides(t *testing.T) {
stubStreamSecret(t) stubStreamSecret(t)
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third") t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true") // Default is true; set false so the override path is actually exercised.
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
cfg, err := Load("") cfg, err := Load("")
if err != nil { if err != nil {
@@ -112,8 +113,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p) t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
} }
} }
if !cfg.Library.ScanOnStartup { if cfg.Library.ScanOnStartup {
t.Error("scan_on_startup = false, want true") t.Error("scan_on_startup = true, want false (env override of the true default)")
} }
} }
+30 -2
View File
@@ -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.
+3 -10
View File
@@ -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"
+6 -11
View File
@@ -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}
alt="" shape="round"
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]" fallback="artist"
loading="lazy" class="transition-transform group-hover:scale-[1.03]"
/> />
{: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}`}
+3 -12
View File
@@ -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>
+66
View File
@@ -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>
+17 -2
View File
@@ -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);
} }
+101 -6
View File
@@ -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 = 'Couldnt 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)
} }
+44
View File
@@ -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 {
+33
View File
@@ -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);
};
}
+50
View File
@@ -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();
};
});
}
+2
View File
@@ -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>
+76 -5
View File
@@ -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,17 +65,32 @@
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);
// #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)); playQueue(live, Math.max(0, startIdx));
} }
}
function toTrackRef(t: { function toTrackRef(t: {
track_id: string | null; track_id: string | null;
@@ -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(`Couldnt 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">