Every browse, discover and mix query filters missing_since, so a track
whose file vanished disappears from the places Minstrel chooses music.
A playlist is different: the entry is there because the user put it
there, and silently dropping it rewrites their list behind their back.
So playlists keep the row and mark it instead. ListPlaylistTracks now
carries missing_since (still deliberately unfiltered), the service
layer surfaces it as PlaylistTrack.Unavailable, and the wire gains
"unavailable" on each entry.
A missing entry also loses its stream_url. Refusing to hand out a URL
that cannot serve is stronger than trusting every client to honour the
flag, and "stream_url": null is a shape the clients already model --
PlaylistWire.streamUrl is documented nullable for the track-removed
case -- so an older build degrades to "present but not playable" with
no change.
Nothing is deleted here and nothing should be: the row, its play
history, its likes and its taste contribution all survive a file going
missing, because the file may come back (and #2528 will adopt it if it
comes back renamed).
Also corrects two comments that had drifted into lying. delete.go still
claimed the file-gone case was NOT auto-reconciled and told admins to
delete rows by hand -- untrue since f6d1cf24, and that exact staleness
is what produced drift #572. It now says what DeleteTrackFile really is:
the destructive admin action, which CASCADEs play_events and likes, and
is emphatically not the missing-file path. watcher.go claimed the
safety-net scan "covers anything missed"; the walk only covers
additions, and it is reconcile that covers removals.
518 lines
17 KiB
Go
518 lines
17 KiB
Go
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"`
|
|
// Refreshable: a singleton system kind addressable by the generic
|
|
// /system/{kind}/{refresh,shuffle} endpoints. Lets clients show
|
|
// the per-tile refresh affordance generically without hardcoding
|
|
// which kinds support it (false for user + songs_like_artist).
|
|
Refreshable bool `json:"refreshable"`
|
|
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"`
|
|
// Unavailable marks an entry whose file is missing from disk (#2527).
|
|
// The track is still a real, known track — its history and likes are
|
|
// intact and the file may come back — so the entry keeps its place and
|
|
// its text; clients render it greyed out and skip over it on playback.
|
|
Unavailable bool `json:"unavailable"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
h.publishPlaylistEvent("playlist.created", caller.ID, row.ID)
|
|
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
|
|
}
|
|
h.publishPlaylistEvent("playlist.updated", caller.ID, playlistID)
|
|
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
|
|
}
|
|
h.publishPlaylistEvent("playlist.deleted", caller.ID, playlistID)
|
|
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
|
|
}
|
|
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
|
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
|
|
}
|
|
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
|
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
|
|
}
|
|
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
|
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,
|
|
Refreshable: r.SystemVariant != nil && playlists.RefreshableSystemKind(*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),
|
|
Unavailable: t.Unavailable,
|
|
}
|
|
if t.TrackID != nil {
|
|
s := uuidToString(*t.TrackID)
|
|
v.TrackID = &s
|
|
// No stream URL for a missing file. The wire refuses to offer
|
|
// a URL that cannot serve rather than trusting every client to
|
|
// honour Unavailable — and `"stream_url": null` is a shape the
|
|
// clients already handle (the track-removed case), so an older
|
|
// build degrades to "present but not playable" on its own.
|
|
if !t.Unavailable {
|
|
url := streamURL(*t.TrackID)
|
|
v.StreamURL = &url
|
|
}
|
|
}
|
|
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
|
|
}
|