735383fb1d
System-generated playlists ("For You", "Songs like X") rendered with
empty placeholder boxes in the UI even when their contributing tracks
had album art. The build path inserted playlist rows directly without
ever invoking the existing playlists.GenerateCollage machinery —
that was only wired into user-edited playlist mutations.
After this change, BuildSystemPlaylists generates a 4-cell collage
for every system playlist it creates, post-commit. Same cover
mechanism user playlists use, same on-disk layout
(<DataDir>/playlist_covers/<id>.jpg). Cells whose source album lacks
art fall back to the FabledSword glyph, so a partially-covered
library still produces a sensible visual.
Threading: BuildSystemPlaylists, StartSystemPlaylistCron, and the
api lazy-build path all gain a dataDir string parameter; main.go
passes cfg.Storage.DataDir.
Drops the now-redundant PickTopAlbumCoverForArtistByUser lookup —
the collage is more visually informative than a single album cover
copy, and the seed-artist's top album is one of the contributing
tracks in the mix anyway, so it'll appear as one of the four cells.
Tests pass t.TempDir() for the dataDir parameter.
509 lines
17 KiB
Go
509 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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/auth"
|
|
"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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
|
|
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
|
if kind == "" {
|
|
kind = "user"
|
|
}
|
|
switch kind {
|
|
case "user", "system", "all":
|
|
// valid
|
|
default:
|
|
writeErr(w, http.StatusBadRequest, "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, http.StatusInternalServerError, "server_error", "list failed")
|
|
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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
var body createPlaylistBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON 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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
|
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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
|
return
|
|
}
|
|
var body updatePlaylistBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON 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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
|
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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
|
return
|
|
}
|
|
var body appendTracksBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
|
return
|
|
}
|
|
trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs))
|
|
for _, s := range body.TrackIDs {
|
|
u, ok := parseUUID(s)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
|
return
|
|
}
|
|
posStr := chi.URLParam(r, "position")
|
|
pos64, err := strconv.ParseInt(posStr, 10, 32)
|
|
if err != nil || pos64 < 0 {
|
|
writeErr(w, http.StatusBadRequest, "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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
|
return
|
|
}
|
|
var body reorderTracksBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON 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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
|
return
|
|
}
|
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
|
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, http.StatusNotFound, "not_found", "no cover")
|
|
return
|
|
}
|
|
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
|
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, http.StatusNotFound, "not_found", "playlist not found")
|
|
case errors.Is(err, playlists.ErrForbidden):
|
|
writeErr(w, http.StatusForbidden, "not_authorized", "you don't own this playlist")
|
|
case errors.Is(err, playlists.ErrSystemPlaylistReadonly):
|
|
writeErr(w, http.StatusForbidden, "system_playlist_readonly", "system-generated playlists cannot be edited")
|
|
case errors.Is(err, playlists.ErrInvalidInput):
|
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
case errors.Is(err, playlists.ErrTrackNotFound):
|
|
writeErr(w, http.StatusBadRequest, "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, http.StatusInternalServerError, "server_error", op+" failed")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|