Like/unlike are POST/DELETE on /api/likes/{type}/{id}; idempotent (204
on repeats). List endpoints return Page<TrackRef|AlbumRef|ArtistRef>
sorted liked_at DESC. /api/likes/ids returns flat id arrays for the
client-side heart-button cache.
Three tables keyed on (user_id, entity_id) with liked_at. Per-table
indexes on (user_id, liked_at DESC) for the recently-liked feed.
sqlc queries cover like/unlike/list-rows/count/list-ids per entity.
submission=false → play_started (replaces in-memory nowPlayingMap).
submission=true (default) → synthetic completed play. The nowPlayingMap
struct + factory + the nowPlaying field on mediaHandlers are deleted;
play_events WHERE ended_at IS NULL is now the source of truth for
'currently playing,' reachable from M3+ work.
api.Mount now takes the shared *playevents.Writer instead of cfg so
the same writer instance feeds both surfaces.
Discriminated-union JSON over three event types: play_started,
play_ended, play_skipped. Auth via existing RequireUser. play_started
returns play_event_id + session_id; the other two return { ok: true }.
Validates ownership: play_ended/play_skipped on another user's row
returns 403. Mount signature now takes EventsConfig and constructs
the playevents.Writer; server.New propagates it through.
Owns the auto-close-prior step (caps each user at one open row),
spec §6 skip classification rule on play_ended (AND of completion <
0.5 and duration_played < 30s), and skip_events row writes. Synthetic
completed play wires the Subsonic /rest/scrobble?submission=true path
into the same store as native events.
Per spec §6: returns existing session id when last_event_at is within
timeout; inserts a new session otherwise. Touch updates last_event_at
and increments track_count so callers can assume FindOrCreate == one
play started.
Tables and indexes per spec §5. session_vector_at_play ships nullable
so M3 doesn't need a follow-up migration. Table is named play_sessions
to avoid collision with the existing sessions (HTTP auth) table from
migration 0004.
M6 stub. Validates seed_track param (400 on missing/blank/bad UUID),
looks up the track (404 on miss), returns RadioResponse with a single
TrackRef. M4 will replace the body with similarity-driven candidate
pool + scoring; the request/response shape is final.
- NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404
- Everything else falls through to the embedded web handler, which
resolves static assets or returns index.html for SPA deep links
go:embed paths are relative to the source file and cannot reach parent
directories, so internal/web/embed.go could not see web/build/ without
duplicating the placeholder. Relocating to web/embed.go lets the
directive resolve to the real SvelteKit build output with no copy step.
CI's golangci-lint run flagged three files; two pre-existed this
branch but the Plan 3 seedTrackWithFile struct-literal alignment
is new. Applying gofmt across all three keeps the lint baseline clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sessionTokenFromHTTP now matches extractBearerToken's trimming
behavior. Without this, a bearer header with trailing whitespace
(e.g. a buggy client or proxy) authenticates via RequireUser but
hashes the padded value on logout, silently no-oping and leaving
the server-side session alive.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the native JSON surface alongside the existing /api/admin
and /rest/* routes. Subsonic compatibility remains untouched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Returns the authenticated user (id/username/is_admin). Shape
matches UserView used in the login response so SPA stores stay
typed against one interface.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deletes the session row keyed by the cookie/bearer token and
clears the cookie on the client. Best-effort DB delete — logout
still succeeds for the client if the row's already gone.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Verifies bcrypt password hash, mints a session token, stores its
sha256 in the sessions table, and returns the token in both the
response body (for Flutter) and an httpOnly/SameSite=Strict
cookie (for the web SPA).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces internal/api with Mount(), the {error:{code,message}}
response shape, and stubbed login/logout/me so later tasks can
land one handler at a time without breaking the build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves /api/* callers from session cookie first, Authorization
bearer second. Touches last_seen on success for future active-
sessions UI. Adds GetUserByID query used by the middleware.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Shared primitives for /api/* auth: mint a url-safe opaque token,
hash it for storage, verify a bcrypt password hash.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stores sha256(token) plus user_agent + last_seen_at so future
active-sessions UI doesn't need another migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scrubbing/seeking in clients was a no-op because every track shipped
with duration_ms=0. Shell out to ffprobe (already in the image) per
file during scan and record the parsed duration. ffprobe failures are
warned + recorded as 0 so a single bad file doesn't sink the scan.
Tightened the incremental skip to also require duration_ms > 0 so
existing libraries get backfilled on the next rescan instead of
needing a wipe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tag-supplied years were being passed straight to Postgres' date column
without validation. Files with corrupt or 5+ digit years (seen in the
wild on a couple of dozen albums) tripped SQLSTATE 22008 and the entire
album upsert failed, dropping every track on those albums from the
library.
Validate the year is within 1..9999 before constructing the date. If
it's outside that window, log a warning naming the album and year, and
insert the album with no release_date — a soft field shouldn't take
down the whole row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Feishin's first-login flow hits getUser to discover the authenticated
identity's roles. We never registered the route, so chi returned a
plain-text 404 — Feishin's parser treats anything non-Subsonic as a
generic auth failure ("Failed to log in"), masking the real cause.
- Implement /rest/getUser with the full role bag. Admins get every
role; non-admins get the play-music subset. Single-user M1 means
cross-user lookups by admins return the caller's roles for now;
revisit when user management lands.
- Set sub.NotFound on /rest/* to emit a Subsonic envelope (code 0,
"Method not implemented") instead of plain text. Any future client
probing an unimplemented endpoint now sees a parseable failure.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bootstrap was creating password_hash + api_token but leaving
subsonic_password nil, which meant Subsonic clients (Feishin, Symfonium)
got ErrTokenNotSupported on t+s auth — the server had no plaintext to
hash against. Mirror the bootstrap password into subsonic_password so
the admin can sign in to Subsonic clients with the same credential
printed on first boot. Plaintext at rest is the cost of Subsonic's
legacy auth; matches Navidrome's posture.
Also folds in the local dev compose tweaks: dedicated bridge network
with postgres unpublished from the host, and a bind-mount aimed at the
operator's real library path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
getCoverArt now falls back to sidecar images next to the first track in
an album, so browse responses can advertise a coverArt id whether or not
albums.cover_art_path is set. Worst case the client gets a Subsonic 70
when no sidecar exists.
http.ServeContent handles Range/ETag/If-Modified-Since; download forces
attachment disposition. getCoverArt tries albums.cover_art_path, then
falls back to cover.{jpg,jpeg,png}/folder.{jpg,jpeg,png} next to the first
track's file. scrobble submission=false records a track into an in-memory
nowPlaying map keyed by user — M2 will read this and wire real events.