Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions client/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,22 @@ async function getRewindStats(args: timeframe): Promise<RewindStats> {
return handleJson<RewindStats>(r);
}

async function getTrackFeedback(trackId: number): Promise<TrackFeedback> {
return apiFetch<TrackFeedback>(`/apis/web/v1/track/${trackId}/feedback`);
}

async function setTrackFeedback(
trackId: number,
score: number,
): Promise<TrackFeedback> {
const r = await fetch(`/apis/web/v1/track/${trackId}/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ score }),
});
return handleJson<TrackFeedback>(r);
}

export {
getTopAlbums,
search,
Expand Down Expand Up @@ -315,6 +331,8 @@ export {
getExport,
submitListen,
getRewindStats,
getTrackFeedback,
setTrackFeedback,
};
type ImageList = {
xs: string;
Expand Down Expand Up @@ -441,6 +459,9 @@ type NowPlaying = {
currently_playing: boolean;
track: Track;
};
type TrackFeedback = {
score: number;
};
type RewindStats = {
title: string;
top_artists: Ranked<Artist>[];
Expand Down Expand Up @@ -481,4 +502,5 @@ export type {
Stats,
RewindStats,
ImageList,
TrackFeedback,
};
47 changes: 45 additions & 2 deletions client/app/routes/MediaItems/MediaLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import React, { useEffect, useState } from "react";
import { average } from "color.js";
import { type ImageList, type SearchResponse } from "api/api";
import {
getTrackFeedback,
setTrackFeedback,
type ImageList,
type SearchResponse,
} from "api/api";
import ImageDropHandler from "~/components/ImageDropHandler";
import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
import { Edit, Heart, ImageIcon, Merge, Plus, Trash } from "lucide-react";
import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
Expand All @@ -13,6 +18,7 @@ import MbzIcon from "~/components/icons/MbzIcon";
import { timeListenedString } from "~/utils/utils";
import { Link } from "react-router";
import useWindowWidth from "~/hooks/useWindowWidth";
import { useQuery, useQueryClient } from "@tanstack/react-query";

export type MergeFunc = (
from: number,
Expand Down Expand Up @@ -48,14 +54,39 @@ export default function MediaLayout(props: Props) {
const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
const [feedbackPending, setFeedbackPending] = useState(false);
const { user } = useAppContext();
const queryClient = useQueryClient();
const feedbackQueryKey = ["track-feedback", props.id] as const;

useEffect(() => {
average(props.img.xs, { amount: 1 }).then((color) => {
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.2)`);
});
}, [props.img]);

const { data: feedback } = useQuery({
queryKey: feedbackQueryKey,
queryFn: () => getTrackFeedback(props.id),
enabled: !!user && props.type === "Track",
});
const loved = feedback?.score === 1;

const toggleLove = async () => {
if (feedbackPending || props.type !== "Track") return;
const previous = feedback ?? { score: 0 };
const next = { score: loved ? 0 : 1 };
queryClient.setQueryData(feedbackQueryKey, next);
setFeedbackPending(true);
try {
await setTrackFeedback(props.id, next.score);
} catch {
queryClient.setQueryData(feedbackQueryKey, previous);
} finally {
setFeedbackPending(false);
}
};

const replaceImageCallback = () => {
window.location.reload();
};
Expand Down Expand Up @@ -152,6 +183,18 @@ export default function MediaLayout(props: Props) {
<>
{props.type === "Track" && (
<>
<button
title={loved ? "Unlove" : "Love"}
className="hover:cursor-pointer"
disabled={feedbackPending}
onClick={toggleLove}
>
<Heart
size={iconSize}
fill={loved ? "currentColor" : "none"}
className="hover:stroke-(--color-fg-secondary) hover:text-(--color-fg-secondary)"
/>
</button>
<button
title="Add Listen"
className="hover:cursor-pointer"
Expand Down
17 changes: 17 additions & 0 deletions db/migrations_sqlite/003_recording_feedback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- +goose Up

CREATE TABLE IF NOT EXISTS recording_feedback (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
score INTEGER NOT NULL CHECK (score IN (1, -1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, track_id)
);

CREATE INDEX IF NOT EXISTS idx_recording_feedback_track_id ON recording_feedback(track_id);

-- +goose Down

DROP INDEX IF EXISTS idx_recording_feedback_track_id;
DROP TABLE IF EXISTS recording_feedback;
33 changes: 33 additions & 0 deletions engine/handlers/get_track.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,36 @@ func GetTrackInterestHandler(store db.ListenStore) http.HandlerFunc {
utils.WriteJSON(w, http.StatusOK, interest)
}
}

// GetTrackFeedbackHandler returns feedback score for a track.
func GetTrackFeedbackHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)

trackID, err := utils.ParseIDParam(r, "id")
if err != nil {
l.Debug().AnErr("error", err).Msg("GetTrackFeedbackHandler: Invalid track id")
utils.WriteError(w, "invalid track id", http.StatusBadRequest)
return
}

user, err := store.GetAdminUser(ctx)
if err != nil || user == nil {
l.Err(err).Msg("GetTrackFeedbackHandler: Failed to get admin user")
utils.WriteError(w, "failed to retrieve feedback", http.StatusInternalServerError)
return
}

score, err := store.GetTrackFeedback(ctx, user.ID, trackID)
if err != nil {
l.Err(err).Msg("GetTrackFeedbackHandler: Failed to get feedback")
utils.WriteError(w, "failed to retrieve feedback", http.StatusInternalServerError)
return
}

utils.WriteJSON(w, http.StatusOK, struct {
Score int `json:"score"`
}{Score: score})
}
}
185 changes: 185 additions & 0 deletions engine/handlers/lbz_get_feedback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package handlers

import (
"errors"
"net/http"
"strconv"
"strings"

"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
)

const (
lbzFeedbackDefaultCount = 25
lbzFeedbackMaxCount = 100
)

type lbzFeedbackItem struct {
UserID string `json:"user_id"`
RecordingMSID *string `json:"recording_msid"`
RecordingMBID *string `json:"recording_mbid"`
Score int `json:"score"`
Created int64 `json:"created,omitempty"`
}

// LbzGetUserFeedbackHandler returns paginated feedback.
//
// The {user_name} path segment is accepted for ListenBrainz API compatibility but
// ignored for now (Koito is single-user; feedback is always the admin's).
// TODO: validate/match user_name when multi-user support is added.
func LbzGetUserFeedbackHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)

user, ok := lbzFeedbackUser(w, r, store)
if !ok {
return
}

offset := 0
if raw := r.URL.Query().Get("offset"); raw != "" {
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
utils.WriteError(w, "offset must be a non-negative integer", http.StatusBadRequest)
return
}
offset = v
}

count := lbzFeedbackDefaultCount
if raw := r.URL.Query().Get("count"); raw != "" {
v, err := strconv.Atoi(raw)
if err != nil || v < 1 {
utils.WriteError(w, "count must be a positive integer", http.StatusBadRequest)
return
}
count = v
}
count = min(count, lbzFeedbackMaxCount)

var scoreFilter *int
if raw := r.URL.Query().Get("score"); raw != "" {
v, err := strconv.Atoi(raw)
if err != nil || (v != 1 && v != -1) {
utils.WriteError(w, "score must be 1 or -1", http.StatusBadRequest)
return
}
scoreFilter = &v
}

items, total, err := store.ListUserFeedback(ctx, db.ListUserFeedbackOpts{
UserID: user.ID,
Score: scoreFilter,
Limit: count,
Offset: offset,
})
if err != nil {
l.Err(err).Msg("LbzGetUserFeedbackHandler: Failed to list feedback")
utils.WriteError(w, "failed to retrieve feedback", http.StatusInternalServerError)
return
}

feedback := make([]lbzFeedbackItem, len(items))
for i, item := range items {
feedback[i] = toLbzFeedbackItem(user.Username, item)
}
utils.WriteJSON(w, http.StatusOK, map[string]any{
"count": len(feedback),
"feedback": feedback,
"offset": offset,
"total_count": total,
})
}
}

// LbzGetFeedbackForRecordingsHandler returns feedback scores for the given recordings.
//
// The {user_name} path segment is accepted for ListenBrainz API compatibility but
// ignored for now (Koito is single-user; feedback is always the admin's).
// TODO: validate/match user_name when multi-user support is added.
func LbzGetFeedbackForRecordingsHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)

user, ok := lbzFeedbackUser(w, r, store)
if !ok {
return
}

mbids, err := parseRecordingMBIDs(r)
if err != nil {
utils.WriteError(w, err.Error(), http.StatusBadRequest)
return
}

items, err := store.GetFeedbackForRecordings(ctx, db.GetFeedbackForRecordingsOpts{
UserID: user.ID,
MBIDs: mbids,
})
if err != nil {
l.Err(err).Msg("LbzGetFeedbackForRecordingsHandler: Failed to get feedback")
utils.WriteError(w, "failed to retrieve feedback", http.StatusInternalServerError)
return
}

feedback := make([]lbzFeedbackItem, len(items))
for i, item := range items {
feedback[i] = toLbzFeedbackItem(user.Username, item)
}
utils.WriteJSON(w, http.StatusOK, map[string]any{"feedback": feedback})
}
}

func lbzFeedbackUser(w http.ResponseWriter, r *http.Request, store db.UserStore) (*models.User, bool) {
u, err := store.GetAdminUser(r.Context())
if err != nil || u == nil {
logger.FromContext(r.Context()).Err(err).Msg("lbzFeedbackUser: Failed to get admin user")
utils.WriteError(w, "failed to look up user", http.StatusInternalServerError)
return nil, false
}
return u, true
}

func toLbzFeedbackItem(username string, item db.RecordingFeedback) lbzFeedbackItem {
out := lbzFeedbackItem{UserID: username, Score: item.Score, Created: item.CreatedAt}
if item.RecordingMBID != nil {
s := item.RecordingMBID.String()
out.RecordingMBID = &s
}
return out
}

func parseRecordingMBIDs(r *http.Request) ([]uuid.UUID, error) {
var raw []string
if r.Method == http.MethodPost {
body, err := utils.DecodeBody[struct {
RecordingMBIDs []string `json:"recording_mbids"`
}](r)
if err != nil {
return nil, errors.New("invalid JSON sent")
}
raw = body.RecordingMBIDs
} else if param := r.URL.Query().Get("recording_mbids"); param != "" {
raw = strings.Split(param, ",")
}

mbids := make([]uuid.UUID, 0, len(raw))
for _, s := range raw {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, err := uuid.Parse(s)
if err != nil {
return nil, errors.New("invalid recording_mbid")
}
mbids = append(mbids, id)
}
return mbids, nil
}
Loading