024493f2a7
Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit. - streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant. - audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs. - coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
505 lines
16 KiB
Go
505 lines
16 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"`
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
if t.TrackID != nil {
|
|
s := uuidToString(*t.TrackID)
|
|
v.TrackID = &s
|
|
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
|
|
}
|