Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
11 KiB
Go
350 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
|
|
"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/playevents"
|
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
|
)
|
|
|
|
// logLikeChange writes a library_changes row for a like / unlike. Best-
|
|
// effort: a failure here means the next sync may miss the row, but the
|
|
// HTTP response still succeeds. Cheap consequence — the user's other
|
|
// device sees the like once it manually pulls, or once a later like/unlike
|
|
// on the same entity emits a fresh log row.
|
|
func (h *handlers) logLikeChange(r *http.Request, et syncpkg.EntityType, userID, entityID pgtype.UUID, op syncpkg.Op) {
|
|
if err := syncpkg.LogChange(r.Context(), h.pool, et,
|
|
syncpkg.EncodeLikeID(syncpkg.FormatUUID(userID), syncpkg.FormatUUID(entityID)), op); err != nil {
|
|
h.logger.Warn("api: LogChange like", "kind", et, "user", userID, "entity", entityID, "err", err)
|
|
}
|
|
}
|
|
|
|
type likedIDsResponse struct {
|
|
TrackIDs []string `json:"track_ids"`
|
|
AlbumIDs []string `json:"album_ids"`
|
|
ArtistIDs []string `json:"artist_ids"`
|
|
}
|
|
|
|
func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
if _, err := q.GetTrackByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, apierror.NotFound("track"))
|
|
return
|
|
}
|
|
h.logger.Error("api: like track lookup", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
|
|
if err != nil {
|
|
h.logger.Error("api: like track insert", "err", err)
|
|
writeErr(w, apierror.InternalMsg("insert failed", err))
|
|
return
|
|
}
|
|
if slices.Contains(liked, id) {
|
|
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
|
|
}
|
|
// The like is on the song: every copy it reached changes on
|
|
// the client too.
|
|
for _, t := range withTrack(id, liked) {
|
|
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpUpsert)
|
|
h.publishLikeEvent(user.ID, t, "track", true)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// withTrack is the tracks a like or unlike changed, led by the one asked for,
|
|
// which is reported even when it was already in that state.
|
|
func withTrack(id pgtype.UUID, changed []pgtype.UUID) []pgtype.UUID {
|
|
out := []pgtype.UUID{id}
|
|
for _, t := range changed {
|
|
if t != id {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
unliked, err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id})
|
|
if err != nil {
|
|
h.logger.Error("api: unlike track", "err", err)
|
|
writeErr(w, apierror.InternalMsg("delete failed", err))
|
|
return
|
|
}
|
|
if err := playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, id); err != nil {
|
|
h.logger.Error("api: soft-delete contextual_likes", "err", err)
|
|
// Don't fail the response — soft-delete is best-effort.
|
|
}
|
|
for _, t := range withTrack(id, unliked) {
|
|
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpDelete)
|
|
h.publishLikeEvent(user.ID, t, "track", false)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
if _, err := q.GetAlbumByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, apierror.NotFound("album"))
|
|
return
|
|
}
|
|
h.logger.Error("api: like album lookup", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
|
|
h.logger.Error("api: like album insert", "err", err)
|
|
writeErr(w, apierror.InternalMsg("insert failed", err))
|
|
return
|
|
}
|
|
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpUpsert)
|
|
h.publishLikeEvent(user.ID, id, "album", true)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := dbq.New(h.pool).UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
|
|
h.logger.Error("api: unlike album", "err", err)
|
|
writeErr(w, apierror.InternalMsg("delete failed", err))
|
|
return
|
|
}
|
|
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpDelete)
|
|
h.publishLikeEvent(user.ID, id, "album", false)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, apierror.NotFound("artist"))
|
|
return
|
|
}
|
|
h.logger.Error("api: like artist lookup", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
|
|
h.logger.Error("api: like artist insert", "err", err)
|
|
writeErr(w, apierror.InternalMsg("insert failed", err))
|
|
return
|
|
}
|
|
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpUpsert)
|
|
h.publishLikeEvent(user.ID, id, "artist", true)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := dbq.New(h.pool).UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
|
|
h.logger.Error("api: unlike artist", "err", err)
|
|
writeErr(w, apierror.InternalMsg("delete failed", err))
|
|
return
|
|
}
|
|
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpDelete)
|
|
h.publishLikeEvent(user.ID, id, "artist", false)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *handlers) handleListLikedTracks(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit, offset, err := parsePaging(r.URL.Query())
|
|
if err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
rows, err := q.ListLikedTrackRows(r.Context(), dbq.ListLikedTrackRowsParams{
|
|
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: list liked tracks", "err", err)
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
total, err := q.CountLikedTracks(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("api: count liked tracks", "err", err)
|
|
writeErr(w, apierror.InternalMsg("count failed", err))
|
|
return
|
|
}
|
|
items, err := h.resolveTrackRefs(r.Context(), q, rows)
|
|
if err != nil {
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Page[TrackRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
|
}
|
|
|
|
func (h *handlers) handleListLikedAlbums(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit, offset, err := parsePaging(r.URL.Query())
|
|
if err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
rows, err := q.ListLikedAlbumRows(r.Context(), dbq.ListLikedAlbumRowsParams{
|
|
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: list liked albums", "err", err)
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
total, err := q.CountLikedAlbums(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("api: count liked albums", "err", err)
|
|
writeErr(w, apierror.InternalMsg("count failed", err))
|
|
return
|
|
}
|
|
items, err := h.resolveAlbumRefs(r.Context(), q, rows)
|
|
if err != nil {
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Page[AlbumRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
|
}
|
|
|
|
func (h *handlers) handleListLikedArtists(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
limit, offset, err := parsePaging(r.URL.Query())
|
|
if err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
rows, err := q.ListLikedArtistRows(r.Context(), dbq.ListLikedArtistRowsParams{
|
|
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: list liked artists", "err", err)
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
total, err := q.CountLikedArtists(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("api: count liked artists", "err", err)
|
|
writeErr(w, apierror.InternalMsg("count failed", err))
|
|
return
|
|
}
|
|
items := make([]ArtistRef, 0, len(rows))
|
|
for _, a := range rows {
|
|
albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
|
|
if aerr != nil {
|
|
h.logger.Error("api: list albums for artist", "err", aerr)
|
|
writeErr(w, apierror.InternalMsg("list failed", aerr))
|
|
return
|
|
}
|
|
items = append(items, artistRefFrom(a, len(albums)))
|
|
}
|
|
writeJSON(w, http.StatusOK, Page[ArtistRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
|
}
|
|
|
|
func (h *handlers) handleGetLikedIDs(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
q := dbq.New(h.pool)
|
|
trackIDs, err := q.ListLikedTrackIDs(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("api: list liked track ids", "err", err)
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
albumIDs, err := q.ListLikedAlbumIDs(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("api: list liked album ids", "err", err)
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
artistIDs, err := q.ListLikedArtistIDs(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("api: list liked artist ids", "err", err)
|
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
|
return
|
|
}
|
|
resp := likedIDsResponse{
|
|
TrackIDs: uuidsToStrings(trackIDs),
|
|
AlbumIDs: uuidsToStrings(albumIDs),
|
|
ArtistIDs: uuidsToStrings(artistIDs),
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func uuidsToStrings(ids []pgtype.UUID) []string {
|
|
out := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, uuidToString(id))
|
|
}
|
|
return out
|
|
}
|