Files
minstrel/internal/api/likes.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
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>
2026-10-23 12:23:58 -04:00

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
}