Extend the offline-library behaviour to the detail screens: with the
backend unreachable, both pages resolve their entity + tracks/albums from
the locally-cached library (reusing the `selectLocal*` selectors, filtered
by id) instead of showing a retry-only error.
- album detail: album + tracks from cache; offline banner; "not available
offline" state when the album isn't cached; inner track states no longer
error over locally-available tracks
- artist detail: artist + discography + tracks from cache; same treatment
- i18n: `common.offlineBanner`, `album.offline.*`, `artist.offline.*`
(en + ru)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Library showed a blocking error with the backend unreachable. Now it
composes a read-only library from everything already in the RTK Query
cache (Tier-2 rehydrated last-seen data + anything fetched this session),
so it keeps rendering offline instead of erroring.
- selectors: `selectLocalTracks/Albums/Artists` — memoized, union + dedupe
across getTracks/getAlbums/getArtists, the per-album/artist list
endpoints, and single-entity fetches; skips pending/rejected entries
- LibraryPage: when offline, fall back to the composed lists (live data
still wins online), filter client-side for search, show an offline
banner, and never show the retry-only ErrorState
- i18n: `library.offline.*` (en + ru)
- test: selector composition / dedup / status filtering (3 cases)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The transient client-side upload queue vanished on refresh, so a just-
uploaded track seemed to disappear. Add a server-backed "Recently
uploaded" section (source=upload, newest first) that survives refresh and
auto-refreshes after each upload (the upload mutation already invalidates
the `Track` tag this query provides).
- api: `source` filter on `LibraryFilters` → `GET /tracks?source=`
- i18n: `upload.recent.*` (en + ru); loading/error/empty states
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the scaffold Placeholder with a real artist page wired to the
existing `/artists/{id}` (+ `/albums`, `/tracks`) endpoints: header with
procedural avatar / counts / "Play all" (queues with source=artist),
discography grid (cards → album detail), and the full track list. All
three list states per async section.
Also make the Library artists-tab rows clickable → `/artists/:id`,
closing the previous navigation dead-end.
i18n: new `artist.*` block (en + ru).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the "coming soon" stub with a real dashboard wired to
`GET /storage`. modern-sk visuals: a layered disk-capacity gauge (library
share vs other-used vs free), stat tiles (tracks/artists/albums/playtime/
footprint/avg size), per-format size bars, metadata-health badges, source
breakdown, a popularity-weighted top-genres cloud, and playful fun facts.
- types: full `StorageStats` shape + `toStorageStats` snake→camel mapper
- endpoint: re-point `getStorageStats` to `GET /storage` with transform
- lib: `formatLongDuration` for big playtime spans
- i18n: `storage.*` keys (en + ru)
- three list states (loading / error / empty) per the UI invariant
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the labelled availability/metadata badges in track rows with
small icon+tooltip indicators (cloud/hard-drives/warning/etc, derived
from TrackAvailability and MetadataStatus).
Add a `connection` slice fed by a single status poller (Sidebar) so
other components can cheaply check backend reachability. TrackRow uses
this plus the offline audio cache to show "Local" instead of a stale
"On server" when the backend is down but the track is already cached.
Album detail header falls back to a track's cover when the album
record itself has none, and each track row hides its per-track art in
favour of a large album-position number, since the header already
shows the album's cover once.
Overlay the accent-coloured "hopping bars" PlayingIndicator on a
track's cover art wherever TrackRow appears when it's the active
player track, and reuse the same component/overlay for the current
entry's cover in the queue panel.
Add a play overlay button shown on cover art hover that inserts the
track into the queue right after the current track and jumps to it,
so it takes priority over what's already queued. Replaces the
double-click-to-play interaction with a new playNow queue action.
Replace the bare "remove" cross on each queue row with a ghost
three-dot menu offering Play now, Move next (reposition right after
the current track), Track info, and Remove — consolidating the
previously separate info button into the same menu.
Show all queue entries (played and upcoming) in one list instead of
splitting into a "Now playing" card + "Next up" tail, so previously
played tracks don't disappear and reappear when navigating back/forward.
The current track is outlined and shows a reusable "hopping bars"
PlayingIndicator (modern-sk style equalizer animation) for future reuse
across track lists.
Queue rows passed albumArtUrl straight to ArtTile, but for tracks that
field is usually empty — the real cover is served per-track from
/tracks/{id}/cover. Apply the same resolution PersistentPlayer uses
(getCoverUrl ?? getTrackCoverUrl) for both the now-playing tile and the
up-next rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the placeholder with a controlled form for title/artist/album/
year/genre/track number, an AcoustID "find matches" action showing
ranked candidates with confidence, a diff/apply picker, a re-enrich
button, and save via PUT /metadata. Adds matches/apply API endpoints,
mappers, types, and en/ru i18n strings. Batch editor remains a
placeholder (deferred).
Add a right-side track info drawer that sits to the right of the queue
panel when both are open. Shows a large cover, title/artist/album links,
a Play/Queue/Edit actions row, and Status/General/File/Identifiers
sections (empty rows omitted). Opens from the track context menu, the
player now-playing tile, and the queue now-playing card.
- ui slice: trackInfoId + open/closeTrackInfo
- TrackInfoDrawer rendered after QueuePanel in AppShell; overlays content
on narrow viewports
- map source/createdAt/enrichedAt from the wire (were unmapped)
- formatDateTime helper, info icon, i18n (en/ru)
- drop orphaned toggleNowPlaying/isNowPlayingOpen from player slice
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The queue slice stores denormalized display fields captured at play-time, so the
player and queue panel kept showing pre-enrichment title/artist after a track's
metadata was updated — the library (RTKQ cache) and the player disagreed.
Add useResolvedQueueEntry: read through to the RTKQ Track cache and prefer its
fresh values, keeping the snapshot only as instant/offline fallback. Wire it into
PersistentPlayer (now-playing + cover) and QueuePanel (now-playing + up-next
rows), so enrichment updates reach the player through the same Track tags that
refresh the library.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mapper dropped metadata_status and hardcoded availability, so enrichment
state was invisible and a just-uploaded track never appeared to change. Map
metadata_status/metadata_error/has_cover onto Track; add MetadataStatusBadge
(pending spinner / enriched / failed-with-reason / manual) shown in TrackRow,
and serve token-bearing track covers via getTrackCoverUrl.
UploadPage now polls each uploaded track (stops once enrichment settles) so the
resolved title/artist — or a failure reason — appears live. i18n in en + ru.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RTK Query 2.12's invalidation slice reads `provided.tags` during cache
rehydration (`Object.entries(provided.tags ?? {})`). Our persisted
snapshot only carried `{ queries, mutations }`, so `provided` was
undefined and `.tags` threw on every startup with a cached snapshot —
crashing the app inside the rehydrate reducer / immer produce.
Snapshot now carries the real `provided` (so invalidation tags
rehydrate), and `load()` defaults it to `{ tags: {}, keys: {} }` so
snapshots persisted before this field existed recover without a manual
localStorage clear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a login/register toggle to ConnectPage backed by a new
useRegisterMutation (register -> /auth/me, mirroring login). The toggle
is shown only when REGISTRATION_ENABLED, resolved with the same
precedence as the API base URL: runtime window.__APP_CONFIG__ >
PUBLIC_ENABLE_REGISTRATION env > default true. The prod runtime-config
script injects the runtime flag. The backend's ALLOW_REGISTRATION stays
the real authority; this only gates the UI. EN/RU strings added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app painted white until <ThemeProvider> mounted and set data-theme,
then snapped to the dark theme. Two fixes:
- Inline head script (rsbuild html.tags) sets data-theme before first
paint, mirroring modern-sk's exact logic (localStorage 'modern-sk-theme'
|| 'dark') so there's no second flip when the provider mounts. Inline =
zero round-trips.
- body now paints var(--color-bg) so the themed background shows before
React mounts #root and layers the felt grain on top.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the faked ConnectPage login with a real /auth/login -> /auth/me
flow, including loading/error states. Add a backend-contract adapter layer
(api/mappers.ts) translating the backend's snake_case, lean *Out schemas and
{items,total,limit,offset} paging into the UI's camelCase domain types, so
swapping backends only touches the mappers.
- auth: chained login (tokens) + /auth/me (user); refresh on snake_case;
expiresIn optional (reauth is 401-driven, backend sends no TTL)
- streaming: GET /stream/{id}?token= (token query param for <audio>); SW
audio cache route + tests follow the path change (token stays cache-stable)
- library/playlists/likes/admin: correct paths (/tracks not /library/tracks),
page/pageSize<->limit/offset, duration_seconds->durationMs, likes as
append-only POST /likes event-log, admin is_superuser<->role
- downloads/storage: marked provisional (backend routes still stubs)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PROD image baked PUBLIC_API_BASE_URL at build time (rsbuild inlines
PUBLIC_* vars), so a prebuilt image could only ever target a same-origin
'/api/v1' and needed a reverse proxy in front. Move the operator default to
runtime so one image can point at any backend origin without rebuilding.
- public/config.js: committed stub setting window.__APP_CONFIG__ = {}, used
as the dev/build-time default and overwritten in prod at container start.
- rsbuild.config.ts: inject a classic (non-deferred) <script src="/config.js">
into <head> so it runs before the deferred app bundle.
- src/config/env.ts: DEFAULT_API_BASE_URL now resolves
window.__APP_CONFIG__.apiBaseUrl > import.meta.env.PUBLIC_API_BASE_URL >
'/api/v1'. The user-chosen instance still wins over all of these.
- dockerfiles/30-runtime-config.sh: nginx /docker-entrypoint.d hook that
regenerates /config.js from $PUBLIC_API_BASE_URL on every start.
- Dockerfile.prod: install the hook (build-time ARG is now just a fallback).
- nginx.conf: serve /config.js with Cache-Control: no-store.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three tiers of offline support, all scoped to the active backend's
localStorage namespace (mirroring the auth slice):
Tier 1 — persist client state. queue + player slices are saved (queue
entries/index/source; player track/position/volume/repeat/shuffle) and
rehydrated on load, so a reload with no backend restores where the user
left off. Playback never auto-resumes (browsers block autoplay). Retires
the DEMO_QUEUE and isQueueOpen:true stubs.
Tier 2 — persist the RTK Query cache. Last-seen library/albums/artists
are snapshotted (fulfilled queries only) and replayed via RTKQ's
extractRehydrationInfo at startup, so the library renders read-only when
the backend is down. ConnectionStatus tooltip flags cached data offline.
No server data is copied into a slice — the cache feeds itself back.
Tier 3 — service worker audio + cover cache (PWA). Audio streams are
cached keyed by content id (token stripped), range-aware (synthetic 206
slicing), with a 500MB LRU cap, so already-played tracks play fully
offline. Cover art uses stale-while-revalidate in its own bounded cache.
Module worker (ESM); pure helpers split into sw-core.js and unit-tested.
Web app manifest enables "Install app". Player source badge now reflects
real cached state.
tsc clean, lint clean, 19 new tests pass, production build verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the A8 upload screen against the existing /upload contract:
- UploadResponse type ({track_id, title, already_exists}) + mutation typed to it
- buildUploadFormData helper (single file under field `file`, per FastAPI)
- UploadPage: drag-and-drop + file picker, client-side queue with
concurrency cap (3), per-file status badges, retry on error,
already_exists -> "Already in library", deep-link to A7 metadata editor
- i18n upload.* section (en/ru) incl. "metadata pending" hint
Indeterminate spinner per file; percent progress is a follow-up
(needs an XHR baseQuery — fetchBaseQuery gives no upload progress).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build out the full web route map from music-selfhost-routes.md as
scaffolding (no functionality on new screens):
- Full route tree: /login, /albums/:id, /artists/:id, /playlists(+detail),
/discover, /upload, metadata editor (single + batch), /storage/maintenance,
/queue, nested /settings and /admin, and a 404.
- Sidebar rebuilt to the A1 spec with permission-gated Discover/Upload.
- ProtectedRoute gains requirePermission; Permission exported.
- AppShell wraps Outlet in a Suspense boundary for lazy routes.
- Reusable Placeholder + SubNav; Settings/Admin become nested layouts.
- Settings/Profile: wired language + theme selectors.
- Remove orphaned Home feature (web has no Home; / -> /library) and the
now-unused house icon + nav.home keys.
- i18n keys (en + ru) and CSS for page-title/sub-nav.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>