diff --git a/internal/api/api.go b/internal/api/api.go index 84887984..23215d03 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -17,13 +17,14 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, dataDir string) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -32,6 +33,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, + playlists: playlistsSvc, + dataDir: dataDir, } r.Route("/api", func(api chi.Router) { @@ -98,6 +101,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Delete("/tracks/{id}", h.handleRemoveTrack) }) + + authed.Get("/playlists", h.handleListPlaylists) + authed.Post("/playlists", h.handleCreatePlaylist) + authed.Get("/playlists/{id}", h.handleGetPlaylist) + authed.Patch("/playlists/{id}", h.handleUpdatePlaylist) + authed.Delete("/playlists/{id}", h.handleDeletePlaylist) + authed.Post("/playlists/{id}/tracks", h.handleAppendTracks) + authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack) + authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist) + authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover) }) }) } @@ -112,4 +125,6 @@ type handlers struct { lidarrRequests *lidarrrequests.Service lidarrQuarantine *lidarrquarantine.Service tracks *tracks.Service + playlists *playlists.Service + dataDir string } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 921aa3cd..39590574 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -26,6 +26,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) @@ -65,7 +66,9 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { // admin-tracks tests below override h.tracks via installTracksLidarrStub // when they need a stubbed Lidarr. tracksSvc := tracks.NewService(pool, logger, nil) - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc} + dataDir := t.TempDir() + playlistsSvc := playlists.NewService(pool, logger, dataDir) + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir} return h, pool } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 50734c6a..c267ce60 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -443,7 +443,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.dataDir) paths := []string{ "/api/artists", diff --git a/internal/api/playlists.go b/internal/api/playlists.go new file mode 100644 index 00000000..223f3884 --- /dev/null +++ b/internal/api/playlists.go @@ -0,0 +1,429 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "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"` + 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. +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 + } + 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) { + resp.Owned = append(resp.Owned, v) + } else { + resp.Public = append(resp.Public, v) + } + } + writeJSON(w, http.StatusOK, resp) +} + +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.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, + TrackCount: r.TrackCount, + DurationSec: r.DurationSec, + CreatedAt: formatTimestamp(r.CreatedAt), + UpdatedAt: formatTimestamp(r.UpdatedAt), + } + 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 +} diff --git a/internal/api/playlists_test.go b/internal/api/playlists_test.go new file mode 100644 index 00000000..241fdf8b --- /dev/null +++ b/internal/api/playlists_test.go @@ -0,0 +1,457 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// newPlaylistsRouter mounts the 9 playlist routes for an in-test chi +// instance. RequireUser is NOT applied — tests inject the user into +// context manually so we exercise the handler-owned auth defense +// directly. Mirrors the pattern from newAdminTracksRouter. +func newPlaylistsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/playlists", h.handleListPlaylists) + r.Post("/api/playlists", h.handleCreatePlaylist) + r.Get("/api/playlists/{id}", h.handleGetPlaylist) + r.Patch("/api/playlists/{id}", h.handleUpdatePlaylist) + r.Delete("/api/playlists/{id}", h.handleDeletePlaylist) + r.Post("/api/playlists/{id}/tracks", h.handleAppendTracks) + r.Delete("/api/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack) + r.Put("/api/playlists/{id}/tracks", h.handleReorderPlaylist) + r.Get("/api/playlists/{id}/cover", h.handleGetPlaylistCover) + return r +} + +// doPlaylistsReq fires an HTTP request with the given user injected into +// the request context. Body may be nil. +func doPlaylistsReq(h *handlers, user dbq.User, method, path string, body []byte) *httptest.ResponseRecorder { + var rdr *bytes.Reader + if body != nil { + rdr = bytes.NewReader(body) + } else { + rdr = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, rdr) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + newPlaylistsRouter(h).ServeHTTP(w, req) + return w +} + +// seedSimpleTrack inserts an artist + album + track triple under unique +// titles. Used by the append/reorder test that doesn't care about +// genre / file content. seedTrack from library_fixtures_test.go takes +// IDs the caller already has; we collapse the three calls here. +func seedSimpleTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track { + t.Helper() + a := seedArtist(t, pool, artist) + al := seedAlbum(t, pool, a.ID, artist+" - "+title+" Album", 0) + return seedTrack(t, pool, al.ID, a.ID, title, 1, 1000) +} + +// TestPlaylists_NoSession401 verifies the handler-owned defensive 401 +// path on each route. Real traffic hits RequireUser upstream, but the +// handler also defends against routing misconfigurations. +func TestPlaylists_NoSession401(t *testing.T) { + h, _ := testHandlers(t) + r := newPlaylistsRouter(h) + + req := httptest.NewRequest(http.MethodGet, "/api/playlists", nil) + w := httptest.NewRecorder() + // Note: NO user in context. + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String()) + } + var env errorBody + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v; body = %s", err, w.Body.String()) + } + if env.Error.Code != "unauthenticated" { + t.Errorf("error.code = %q, want unauthenticated", env.Error.Code) + } +} + +// TestPlaylists_CreateThenGet exercises the happy-path end-to-end: +// POST creates a row, the response carries the id, GET round-trips +// the same fields. +func TestPlaylists_CreateThenGet(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + body := []byte(`{"name":"Saturday morning","description":"Mellow","is_public":false}`) + w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body) + if w.Code != http.StatusOK { + t.Fatalf("create status = %d, body = %s", w.Code, w.Body.String()) + } + var created playlistRowView + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create: %v; body = %s", err, w.Body.String()) + } + if created.ID == "" { + t.Fatal("created.id empty") + } + if created.Name != "Saturday morning" { + t.Errorf("name = %q, want Saturday morning", created.Name) + } + if created.IsPublic { + t.Error("is_public = true, want false") + } + if created.OwnerUsername != user.Username { + t.Errorf("owner_username = %q, want %q", created.OwnerUsername, user.Username) + } + + w2 := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+created.ID, nil) + if w2.Code != http.StatusOK { + t.Fatalf("get status = %d, body = %s", w2.Code, w2.Body.String()) + } + var got playlistDetailView + if err := json.Unmarshal(w2.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get: %v; body = %s", err, w2.Body.String()) + } + if got.Name != "Saturday morning" { + t.Errorf("get name = %q", got.Name) + } + if got.Tracks == nil { + t.Error("tracks is nil; want empty slice") + } + if len(got.Tracks) != 0 { + t.Errorf("tracks len = %d, want 0", len(got.Tracks)) + } +} + +// TestPlaylists_CreateEmptyName400 verifies the service's +// ErrInvalidInput surfaces as bad_request on the wire. +func TestPlaylists_CreateEmptyName400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + body := []byte(`{"name":"","description":""}`) + w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + var env errorBody + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Error.Code != "bad_request" { + t.Errorf("error.code = %q, want bad_request", env.Error.Code) + } +} + +// TestPlaylists_PatchOwnerOnly verifies the ErrForbidden → 403 +// not_authorized path. Bob creates nothing; Alice creates a playlist; +// Bob tries to PATCH it. +func TestPlaylists_PatchOwnerOnly(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + createBody := []byte(`{"name":"Mine","is_public":true}`) + cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", createBody) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + + patchBody := []byte(`{"name":"Hijacked"}`) + pw := doPlaylistsReq(h, bob, http.MethodPatch, "/api/playlists/"+created.ID, patchBody) + if pw.Code != http.StatusForbidden { + t.Fatalf("non-owner patch status = %d, want 403; body = %s", pw.Code, pw.Body.String()) + } + var env errorBody + if err := json.Unmarshal(pw.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if env.Error.Code != "not_authorized" { + t.Errorf("error.code = %q, want not_authorized", env.Error.Code) + } +} + +// TestPlaylists_GetNotFound verifies a well-formed UUID with no row +// surfaces as 404 not_found. +func TestPlaylists_GetNotFound(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + missing := "00000000-0000-0000-0000-000000000099" + w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+missing, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } +} + +// TestPlaylists_ListSplitsOwnedAndPublic verifies the response carries +// owned vs. public buckets correctly. Alice owns one private + one +// public; Bob sees only Alice's public. +func TestPlaylists_ListSplitsOwnedAndPublic(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + for _, body := range [][]byte{ + []byte(`{"name":"AlicePrivate","is_public":false}`), + []byte(`{"name":"AlicePublic","is_public":true}`), + } { + w := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", body) + if w.Code != http.StatusOK { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + } + + // Bob lists: should see exactly one public playlist owned by Alice. + bw := doPlaylistsReq(h, bob, http.MethodGet, "/api/playlists", nil) + if bw.Code != http.StatusOK { + t.Fatalf("list: %d %s", bw.Code, bw.Body.String()) + } + var bobResp listPlaylistsResponse + if err := json.Unmarshal(bw.Body.Bytes(), &bobResp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(bobResp.Owned) != 0 { + t.Errorf("bob.owned len = %d, want 0", len(bobResp.Owned)) + } + if len(bobResp.Public) != 1 || bobResp.Public[0].Name != "AlicePublic" { + t.Errorf("bob.public = %+v, want one [AlicePublic]", bobResp.Public) + } + + // Alice lists: sees both as owned, public bucket empty. + aw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists", nil) + if aw.Code != http.StatusOK { + t.Fatalf("list alice: %d %s", aw.Code, aw.Body.String()) + } + var aliceResp listPlaylistsResponse + if err := json.Unmarshal(aw.Body.Bytes(), &aliceResp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(aliceResp.Owned) != 2 { + t.Errorf("alice.owned len = %d, want 2", len(aliceResp.Owned)) + } + if len(aliceResp.Public) != 0 { + t.Errorf("alice.public len = %d, want 0", len(aliceResp.Public)) + } +} + +// TestPlaylists_AppendThenReorder appends three tracks and then reverses +// their order via the reorder endpoint. The post-mutation detail body +// must show Track 3 before Track 1. +func TestPlaylists_AppendThenReorder(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + t1 := seedSimpleTrack(t, pool, "Track 1", "Artist") + t2 := seedSimpleTrack(t, pool, "Track 2", "Artist") + t3 := seedSimpleTrack(t, pool, "Track 3", "Artist") + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"R"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + + appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{ + uuidToString(t1.ID), uuidToString(t2.ID), uuidToString(t3.ID), + }}) + aw := doPlaylistsReq(h, user, http.MethodPost, + "/api/playlists/"+created.ID+"/tracks", appendPayload) + if aw.Code != http.StatusOK { + t.Fatalf("append: %d %s", aw.Code, aw.Body.String()) + } + var afterAppend playlistDetailView + if err := json.Unmarshal(aw.Body.Bytes(), &afterAppend); err != nil { + t.Fatalf("decode append: %v", err) + } + if len(afterAppend.Tracks) != 3 { + t.Fatalf("after append, tracks len = %d, want 3", len(afterAppend.Tracks)) + } + + // Reverse: positions [2, 1, 0]. + rw := doPlaylistsReq(h, user, http.MethodPut, + "/api/playlists/"+created.ID+"/tracks", + []byte(`{"ordered_positions":[2,1,0]}`)) + if rw.Code != http.StatusOK { + t.Fatalf("reorder: %d %s", rw.Code, rw.Body.String()) + } + + gw := doPlaylistsReq(h, user, http.MethodGet, + "/api/playlists/"+created.ID, nil) + if gw.Code != http.StatusOK { + t.Fatalf("get-after-reorder: %d %s", gw.Code, gw.Body.String()) + } + body := gw.Body.String() + pos3 := strings.Index(body, `"title":"Track 3"`) + pos1 := strings.Index(body, `"title":"Track 1"`) + if pos3 == -1 || pos1 == -1 { + t.Fatalf("titles missing from body: %s", body) + } + if pos3 > pos1 { + t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body) + } +} + +// TestPlaylists_RemoveTrack appends two tracks, removes position 0, and +// verifies the surviving track sits at position 0 with the renumbered +// list length 1. +func TestPlaylists_RemoveTrack(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + t1 := seedSimpleTrack(t, pool, "First", "Artist") + t2 := seedSimpleTrack(t, pool, "Second", "Artist") + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"X"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{ + uuidToString(t1.ID), uuidToString(t2.ID), + }}) + aw := doPlaylistsReq(h, user, http.MethodPost, + "/api/playlists/"+created.ID+"/tracks", appendPayload) + if aw.Code != http.StatusOK { + t.Fatalf("append: %d %s", aw.Code, aw.Body.String()) + } + + rw := doPlaylistsReq(h, user, http.MethodDelete, + "/api/playlists/"+created.ID+"/tracks/0", nil) + if rw.Code != http.StatusOK { + t.Fatalf("remove: %d %s", rw.Code, rw.Body.String()) + } + var afterRemove playlistDetailView + if err := json.Unmarshal(rw.Body.Bytes(), &afterRemove); err != nil { + t.Fatalf("decode: %v", err) + } + if len(afterRemove.Tracks) != 1 { + t.Fatalf("after remove, tracks len = %d, want 1", len(afterRemove.Tracks)) + } + if afterRemove.Tracks[0].Title != "Second" { + t.Errorf("survivor title = %q, want Second", afterRemove.Tracks[0].Title) + } + if afterRemove.Tracks[0].Position != 0 { + t.Errorf("survivor position = %d, want 0 (renumbered)", afterRemove.Tracks[0].Position) + } +} + +// TestPlaylists_DeleteOwnerOnly verifies non-owner DELETE is 403 and +// owner DELETE is 204. +func TestPlaylists_DeleteOwnerOnly(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + alice := seedUser(t, pool, "alice", "pw", false) + bob := seedUser(t, pool, "bob", "pw", false) + + cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", + []byte(`{"name":"D","is_public":true}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + bw := doPlaylistsReq(h, bob, http.MethodDelete, "/api/playlists/"+created.ID, nil) + if bw.Code != http.StatusForbidden { + t.Errorf("bob delete status = %d, want 403", bw.Code) + } + + aw := doPlaylistsReq(h, alice, http.MethodDelete, "/api/playlists/"+created.ID, nil) + if aw.Code != http.StatusNoContent { + t.Errorf("alice delete status = %d, want 204; body = %s", aw.Code, aw.Body.String()) + } + + // Subsequent GET must 404. + gw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists/"+created.ID, nil) + if gw.Code != http.StatusNotFound { + t.Errorf("get-after-delete status = %d, want 404", gw.Code) + } +} + +// TestPlaylists_BadUUID400 verifies a malformed playlist id returns +// bad_request rather than reaching the service layer. +func TestPlaylists_BadUUID400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/not-a-uuid", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// TestPlaylists_AppendInvalidTrackUUID400 verifies a malformed track id +// in the append body returns bad_request before reaching the DB. +func TestPlaylists_AppendInvalidTrackUUID400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"A"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + body := []byte(`{"track_ids":["not-a-uuid"]}`) + w := doPlaylistsReq(h, user, http.MethodPost, + "/api/playlists/"+created.ID+"/tracks", body) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } +} + +// TestPlaylists_CoverNoneReturns404 verifies that a freshly-created +// (empty) playlist has no cached cover and the /cover endpoint returns +// 404 not_found rather than serving a missing file. +func TestPlaylists_CoverNoneReturns404(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "pw", false) + + cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", + []byte(`{"name":"NoCover"}`)) + if cw.Code != http.StatusOK { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + var created playlistRowView + _ = json.Unmarshal(cw.Body.Bytes(), &created) + + w := doPlaylistsReq(h, user, http.MethodGet, + "/api/playlists/"+created.ID+"/cover", nil) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body = %s", w.Code, w.Body.String()) + } +} + diff --git a/internal/server/server.go b/internal/server/server.go index 17132173..2ffc0893 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,6 +23,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/web" @@ -62,6 +63,11 @@ type Server struct { SubsonicCfg subsonic.Config EventsCfg config.EventsConfig RecommendationCfg config.RecommendationConfig + // DataDir is the on-disk root for cached artifacts (currently + // playlist cover collages under /playlist_covers/). Empty + // strings are tolerated by tests that don't exercise persisted-cover + // codepaths; production callers should pass a writable directory. + DataDir string } func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server { @@ -98,7 +104,8 @@ func (s *Server) Router() http.Handler { lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil) lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}) - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc) + playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.DataDir) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second