package api import ( "context" "errors" "fmt" "net/http" "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" ) // playlistRowView is the wire shape for a playlist header. Mirrors // playlists.PlaylistRow but renders UUIDs / timestamps as strings and // derives the cover_url from cover_path so clients don't have to know // the on-disk layout. type playlistRowView struct { ID string `json:"id"` UserID string `json:"user_id"` OwnerUsername string `json:"owner_username"` Name string `json:"name"` Description string `json:"description"` IsPublic bool `json:"is_public"` Kind string `json:"kind"` SystemVariant *string `json:"system_variant,omitempty"` SeedArtistID *string `json:"seed_artist_id,omitempty"` CoverURL string `json:"cover_url,omitempty"` TrackCount int32 `json:"track_count"` DurationSec int32 `json:"duration_sec"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // playlistTrackView is one row of a playlist's track list on the wire. // TrackID / AlbumID / ArtistID / StreamURL are pointers so they encode as // JSON null when the upstream library row has been removed (ON DELETE // SET NULL semantics from the snapshot table). type playlistTrackView struct { Position int32 `json:"position"` TrackID *string `json:"track_id"` AlbumID *string `json:"album_id"` ArtistID *string `json:"artist_id"` Title string `json:"title"` ArtistName string `json:"artist_name"` AlbumTitle string `json:"album_title"` DurationSec int32 `json:"duration_sec"` StreamURL *string `json:"stream_url"` AddedAt string `json:"added_at"` } // playlistDetailView extends playlistRowView with the ordered track list. // Returned by Get / Append / Remove / Reorder so the SPA always has a // fresh authoritative state after a mutation. type playlistDetailView struct { playlistRowView Tracks []playlistTrackView `json:"tracks"` } // listPlaylistsResponse splits owned vs. public so the SPA can render // "your playlists" and "browse public" without re-filtering. Both // arrays are guaranteed non-nil for stable JSON output. type listPlaylistsResponse struct { Owned []playlistRowView `json:"owned"` Public []playlistRowView `json:"public"` } // handleListPlaylists implements GET /api/playlists. The service returns // every playlist visible to the caller (own + others' public); we split // the result here so the wire shape carries the distinction the UI cares // about without a second DB query. // // An optional ?kind= query parameter filters the Owned bucket: // - "user" (default): only user-created playlists // - "system": only system-generated mixes // - "all": no filter // // The Public bucket (other users' public playlists) is always unfiltered // since system mixes are always private. func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } kind := strings.TrimSpace(r.URL.Query().Get("kind")) if kind == "" { kind = "user" } switch kind { case "user", "system", "all": // valid default: writeErr(w, apierror.BadRequest("bad_kind", "kind must be one of: user, system, all")) return } // Lazy fallback: when the caller wants system mixes and they're stale, // kick a background build but serve current state. if kind == "system" || kind == "all" { h.maybeKickBackgroundBuild(r.Context(), caller.ID) } rows, err := h.playlists.List(r.Context(), caller.ID) if err != nil { h.logger.Error("api: list playlists failed", "err", err) writeErr(w, apierror.InternalMsg("list failed", err)) return } resp := listPlaylistsResponse{Owned: []playlistRowView{}, Public: []playlistRowView{}} for i := range rows { v := playlistRowToView(&rows[i]) if uuidEqual(rows[i].UserID, caller.ID) { // Filter own playlists by the kind selector. if kind != "all" && rows[i].Kind != kind { continue } resp.Owned = append(resp.Owned, v) } else { // Public-from-others: kind filter doesn't apply (system mixes // are always private; this slot is for public-share feature). resp.Public = append(resp.Public, v) } } writeJSON(w, http.StatusOK, resp) } // maybeKickBackgroundBuild fires a background BuildSystemPlaylists when the // run is stale (>24h) AND not currently in_flight. Best-effort: any error // fetching the run row is silently swallowed. A real cron tick will catch // up the user within 24h regardless. func (h *handlers) maybeKickBackgroundBuild(ctx context.Context, userID pgtype.UUID) { run, err := dbq.New(h.pool).GetSystemPlaylistRun(ctx, userID) var stale bool switch { case errors.Is(err, pgx.ErrNoRows): stale = true // no run yet — first request — definitely stale case err != nil: h.logger.Warn("lazy system build: get run row failed", "err", err) return case !run.LastRunAt.Valid: stale = true case run.LastRunAt.Time.Before(time.Now().Add(-24 * time.Hour)): stale = true } if !stale || run.InFlight { return } go func() { bgCtx := context.Background() if err := playlists.BuildSystemPlaylists(bgCtx, h.pool, h.logger, userID, time.Now(), h.dataDir); err != nil { h.logger.Warn("lazy system playlist build failed", "user_id", "redacted", "err", err) } }() } type createPlaylistBody struct { Name string `json:"name"` Description string `json:"description"` IsPublic bool `json:"is_public"` } // handleCreatePlaylist implements POST /api/playlists. Empty Name is // rejected by the service (ErrInvalidInput → 400 bad_request). func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } var body createPlaylistBody if !decodeBody(w, r, &body) { return } row, err := h.playlists.Create(r.Context(), caller.ID, body.Name, body.Description, body.IsPublic) if err != nil { h.writePlaylistErr(w, err, "create") return } writeJSON(w, http.StatusOK, playlistRowToView(row)) } // handleGetPlaylist implements GET /api/playlists/{id}. Visibility is // owner-or-public; the service returns ErrForbidden when a non-owner // asks for a private playlist. func (h *handlers) handleGetPlaylist(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } playlistID, ok := requireURLUUID(w, r, "id") if !ok { return } detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get") return } writeJSON(w, http.StatusOK, playlistDetailToView(detail)) } // updatePlaylistBody uses pointers so omitted fields stay untouched // (PATCH semantics). The service rejects an explicitly-empty Name. type updatePlaylistBody struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` IsPublic *bool `json:"is_public,omitempty"` } // handleUpdatePlaylist implements PATCH /api/playlists/{id}. Owner only; // non-owners get 403 not_authorized. func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } playlistID, ok := requireURLUUID(w, r, "id") if !ok { return } var body updatePlaylistBody if !decodeBody(w, r, &body) { return } row, err := h.playlists.Update(r.Context(), caller.ID, playlistID, playlists.UpdateInput{ Name: body.Name, Description: body.Description, IsPublic: body.IsPublic, }) if err != nil { h.writePlaylistErr(w, err, "update") return } writeJSON(w, http.StatusOK, playlistRowToView(row)) } // handleDeletePlaylist implements DELETE /api/playlists/{id}. Owner only; // returns 204 on success. func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } playlistID, ok := requireURLUUID(w, r, "id") if !ok { return } if err := h.playlists.Delete(r.Context(), caller.ID, playlistID); err != nil { h.writePlaylistErr(w, err, "delete") return } w.WriteHeader(http.StatusNoContent) } type appendTracksBody struct { TrackIDs []string `json:"track_ids"` } // handleAppendTracks implements POST /api/playlists/{id}/tracks. Owner // only. Returns the playlist detail (with the new track rows) so the // SPA never has to re-GET after a mutation. func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } playlistID, ok := requireURLUUID(w, r, "id") if !ok { return } var body appendTracksBody if !decodeBody(w, r, &body) { return } trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs)) for _, s := range body.TrackIDs { u, ok := parseUUID(s) if !ok { writeErr(w, apierror.BadRequest("bad_request", fmt.Sprintf("invalid track id %q", s))) return } trackIDs = append(trackIDs, u) } if err := h.playlists.AppendTracks(r.Context(), caller.ID, playlistID, trackIDs); err != nil { h.writePlaylistErr(w, err, "append tracks") return } detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-append") return } writeJSON(w, http.StatusOK, playlistDetailToView(detail)) } // handleRemovePlaylistTrack implements DELETE /api/playlists/{id}/tracks/{position}. // Owner only. Returns the post-mutation detail like AppendTracks. func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } playlistID, ok := requireURLUUID(w, r, "id") if !ok { return } posStr := chi.URLParam(r, "position") pos64, err := strconv.ParseInt(posStr, 10, 32) if err != nil || pos64 < 0 { writeErr(w, apierror.BadRequest("bad_request", "invalid position")) return } if err := h.playlists.RemoveTrack(r.Context(), caller.ID, playlistID, int32(pos64)); err != nil { h.writePlaylistErr(w, err, "remove track") return } detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-remove") return } writeJSON(w, http.StatusOK, playlistDetailToView(detail)) } type reorderTracksBody struct { OrderedPositions []int32 `json:"ordered_positions"` } // handleReorderPlaylist implements PUT /api/playlists/{id}/tracks. Owner // only. The body carries a permutation of every existing position; the // service validates length and uniqueness. func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } playlistID, ok := requireURLUUID(w, r, "id") if !ok { return } var body reorderTracksBody if !decodeBody(w, r, &body) { return } if err := h.playlists.Reorder(r.Context(), caller.ID, playlistID, body.OrderedPositions); err != nil { h.writePlaylistErr(w, err, "reorder") return } detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-reorder") return } writeJSON(w, http.StatusOK, playlistDetailToView(detail)) } // handleGetPlaylistCover serves the cached collage from disk. The // playlist visibility check (owner-or-public) runs in the service Get // call before we look at cover_path, so we don't leak the existence of // private playlists to non-owners. func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request) { caller, ok := requireUser(w, r) if !ok { return } playlistID, ok := requireURLUUID(w, r, "id") if !ok { return } detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get cover") return } if detail.CoverPath == nil || *detail.CoverPath == "" { writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "no cover"}) return } full := filepath.Join(h.dataDir, *detail.CoverPath) // Playlist collages recompute when the playlist's tracks change // (system playlists re-rendered on rebuild, user playlists when // modified). 5 minutes is short enough for normal edits to feel // fresh, long enough to skip repeat fetches during a session. w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") http.ServeFile(w, r, full) } // writePlaylistErr maps the typed errors out of the playlists service to // the project's standard error envelope. Unknown errors are logged at // the handler boundary and surfaced as 500 server_error so callers don't // see service-internal wrap text. func (h *handlers) writePlaylistErr(w http.ResponseWriter, err error, op string) { switch { case errors.Is(err, playlists.ErrNotFound): writeErr(w, apierror.NotFound("playlist")) case errors.Is(err, playlists.ErrForbidden): writeErr(w, apierror.Forbidden("not_authorized", "you don't own this playlist")) case errors.Is(err, playlists.ErrSystemPlaylistReadonly): writeErr(w, apierror.Forbidden("system_playlist_readonly", "system-generated playlists cannot be edited")) case errors.Is(err, playlists.ErrInvalidInput): writeErr(w, apierror.BadRequest("bad_request", err.Error())) case errors.Is(err, playlists.ErrTrackNotFound): writeErr(w, apierror.BadRequest("bad_request", "one of the supplied track ids does not exist")) default: h.logger.Error("api: playlist op failed", "op", op, "err", err) writeErr(w, apierror.InternalMsg(op+" failed", err)) } } // playlistRowToView projects a service-layer PlaylistRow onto the wire // shape. CoverURL is derived from the presence of cover_path — the // /cover endpoint serves the file from disk. func playlistRowToView(r *playlists.PlaylistRow) playlistRowView { v := playlistRowView{ ID: uuidToString(r.ID), UserID: uuidToString(r.UserID), OwnerUsername: r.OwnerUsername, Name: r.Name, Description: r.Description, IsPublic: r.IsPublic, Kind: r.Kind, SystemVariant: r.SystemVariant, TrackCount: r.TrackCount, DurationSec: r.DurationSec, CreatedAt: formatTimestamp(r.CreatedAt), UpdatedAt: formatTimestamp(r.UpdatedAt), } if r.SeedArtistID.Valid { s := uuidToString(r.SeedArtistID) v.SeedArtistID = &s } if r.CoverPath != nil && *r.CoverPath != "" { v.CoverURL = "/api/playlists/" + v.ID + "/cover" } return v } // playlistDetailToView extends playlistRowToView with the ordered track // list. Empty playlists get a non-nil empty slice so the JSON encoder // renders `"tracks": []` rather than `null`. func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView { out := playlistDetailView{ playlistRowView: playlistRowToView(&d.PlaylistRow), Tracks: make([]playlistTrackView, 0, len(d.Tracks)), } for _, t := range d.Tracks { v := playlistTrackView{ Position: t.Position, Title: t.Title, ArtistName: t.ArtistName, AlbumTitle: t.AlbumTitle, DurationSec: t.DurationSec, AddedAt: formatTimestamp(t.AddedAt), } if t.TrackID != nil { s := uuidToString(*t.TrackID) v.TrackID = &s streamURL := "/api/tracks/" + s + "/stream" v.StreamURL = &streamURL } if t.AlbumID != nil { s := uuidToString(*t.AlbumID) v.AlbumID = &s } if t.ArtistID != nil { s := uuidToString(*t.ArtistID) v.ArtistID = &s } out.Tracks = append(out.Tracks, v) } return out } // uuidEqual compares two pgtype.UUID values for identity. Two // invalid/zero UUIDs never compare equal — the caller's user id is // always a real value, and we don't want a malformed playlist row to // accidentally land in the "owned" bucket. func uuidEqual(a, b pgtype.UUID) bool { if !a.Valid || !b.Valid { return false } return a.Bytes == b.Bytes }