mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge 0d8add78a50fea9faa5504365bb104e66608704b into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e
This commit is contained in:
commit
1672703452
@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -41,6 +42,26 @@ func (u User) HasLibraryAccess(libraryID int) bool {
|
||||
|
||||
type Users []User
|
||||
|
||||
type RatingStat struct {
|
||||
Rating int `json:"rating"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type UserRatingStats struct {
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
SongStats []RatingStat `json:"songStats"`
|
||||
AlbumStats []RatingStat `json:"albumStats"`
|
||||
}
|
||||
|
||||
type RatedItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumID string `json:"albumId,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
@ -59,4 +80,7 @@ type UserRepository interface {
|
||||
// Library association methods
|
||||
GetUserLibraries(userID string) (Libraries, error)
|
||||
SetUserLibraries(userID string, libraryIDs []int) error
|
||||
|
||||
RatingStats(ctx context.Context, userID string) ([]UserRatingStats, error)
|
||||
RatingItems(ctx context.Context, userID, itemType string, rating int) ([]RatedItem, error)
|
||||
}
|
||||
|
||||
@ -478,6 +478,94 @@ func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *userRepository) RatingStats(ctx context.Context, userID string) ([]model.UserRatingStats, error) {
|
||||
type row struct {
|
||||
UserID string `db:"user_id"`
|
||||
UserName string `db:"user_name"`
|
||||
ItemType string `db:"item_type"`
|
||||
Rating int `db:"rating"`
|
||||
Count int `db:"cnt"`
|
||||
}
|
||||
|
||||
where := And{Gt{"a.rating": 0}, Eq{"a.item_type": []interface{}{"media_file", "album"}}}
|
||||
if userID != "" {
|
||||
where = append(where, Eq{"a.user_id": userID})
|
||||
}
|
||||
sel := Select(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`, `count(*) as cnt`).
|
||||
From(`annotation a`).
|
||||
Join(`"user" u ON a.user_id = u.id`).
|
||||
Where(where).
|
||||
GroupBy(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`).
|
||||
OrderBy(`u.user_name`, `a.item_type`, `a.rating DESC`)
|
||||
|
||||
var rows []row
|
||||
if err := r.queryAll(sel, &rows); err != nil && err != model.ErrNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byUser := map[string]*model.UserRatingStats{}
|
||||
order := []string{}
|
||||
for _, row := range rows {
|
||||
if _, ok := byUser[row.UserID]; !ok {
|
||||
byUser[row.UserID] = &model.UserRatingStats{
|
||||
UserID: row.UserID,
|
||||
UserName: row.UserName,
|
||||
}
|
||||
order = append(order, row.UserID)
|
||||
}
|
||||
stat := model.RatingStat{Rating: row.Rating, Count: row.Count}
|
||||
switch row.ItemType {
|
||||
case "media_file":
|
||||
byUser[row.UserID].SongStats = append(byUser[row.UserID].SongStats, stat)
|
||||
case "album":
|
||||
byUser[row.UserID].AlbumStats = append(byUser[row.UserID].AlbumStats, stat)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]model.UserRatingStats, 0, len(order))
|
||||
for _, uid := range order {
|
||||
result = append(result, *byUser[uid])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) RatingItems(ctx context.Context, userID, itemType string, rating int) ([]model.RatedItem, error) {
|
||||
type row struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Artist string `db:"artist"`
|
||||
AlbumID string `db:"album_id"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
var table, nameCol, artistCol, albumIDCol string
|
||||
switch itemType {
|
||||
case "album":
|
||||
table, nameCol, artistCol, albumIDCol = "album", "album.name", "album.album_artist", "'' as album_id"
|
||||
case "song":
|
||||
table, nameCol, artistCol, albumIDCol = "media_file", "media_file.title", "media_file.artist", "media_file.album_id"
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid item type: %q", itemType)
|
||||
}
|
||||
|
||||
sel := Select(table+".id", nameCol+" as name", artistCol+" as artist", albumIDCol, table+".updated_at").
|
||||
From("annotation a").
|
||||
Join(table + " ON a.item_id = " + table + ".id").
|
||||
Where(Eq{"a.user_id": userID, "a.item_type": table, "a.rating": rating}).
|
||||
OrderBy("name")
|
||||
|
||||
var rows []row
|
||||
if err := r.queryAll(sel, &rows); err != nil && err != model.ErrNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]model.RatedItem, len(rows))
|
||||
for i, row := range rows {
|
||||
result[i] = model.RatedItem{ID: row.ID, Name: row.Name, Artist: row.Artist, AlbumID: row.AlbumID, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var _ model.UserRepository = (*userRepository)(nil)
|
||||
var _ rest.Repository = (*userRepository)(nil)
|
||||
var _ rest.Persistable = (*userRepository)(nil)
|
||||
|
||||
@ -85,6 +85,7 @@ func (api *Router) routes() http.Handler {
|
||||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
api.addRatingStatsRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
api.addInspectRoute(r)
|
||||
|
||||
76
server/nativeapi/rating_stats.go
Normal file
76
server/nativeapi/rating_stats.go
Normal file
@ -0,0 +1,76 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
func (api *Router) addRatingStatsRoute(r chi.Router) {
|
||||
r.Get("/ratingStats", getRatingStats(api.ds))
|
||||
r.Get("/ratingItems", getRatingItems(api.ds))
|
||||
}
|
||||
|
||||
func getRatingStats(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, _ := request.UserFrom(r.Context())
|
||||
filterUserID := ""
|
||||
if !currentUser.IsAdmin {
|
||||
filterUserID = currentUser.ID
|
||||
}
|
||||
stats, err := ds.User(r.Context()).RatingStats(r.Context(), filterUserID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting rating stats", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||
log.Error(r.Context(), "Error encoding rating stats", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getRatingItems(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, _ := request.UserFrom(r.Context())
|
||||
userID := r.URL.Query().Get("userId")
|
||||
itemType := r.URL.Query().Get("type")
|
||||
ratingStr := r.URL.Query().Get("rating")
|
||||
|
||||
rating, err := strconv.Atoi(ratingStr)
|
||||
if err != nil || rating < 1 || rating > 5 {
|
||||
http.Error(w, "rating must be between 1 and 5", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if userID == "" {
|
||||
http.Error(w, "userId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if itemType != "album" && itemType != "song" {
|
||||
http.Error(w, "type must be 'album' or 'song'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !currentUser.IsAdmin && currentUser.ID != userID {
|
||||
http.Error(w, "non-admin users can only query their own ratings", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
items, err := ds.User(r.Context()).RatingItems(r.Context(), userID, itemType, rating)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting rating items", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(items); err != nil {
|
||||
log.Error(r.Context(), "Error encoding rating items", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
189
server/nativeapi/rating_stats_test.go
Normal file
189
server/nativeapi/rating_stats_test.go
Normal file
@ -0,0 +1,189 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Rating Stats API", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var router http.Handler
|
||||
var adminUser, regularUser model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
adminUser = model.User{
|
||||
ID: "admin-1",
|
||||
UserName: "admin",
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
NewPassword: "adminpass",
|
||||
}
|
||||
regularUser = model.User{
|
||||
ID: "user-1",
|
||||
UserName: "regular",
|
||||
Name: "Regular User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "userpass",
|
||||
}
|
||||
|
||||
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
||||
|
||||
mockedUserRepo := ds.MockedUser.(*tests.MockedUserRepo)
|
||||
mockedUserRepo.RatingStatsData = []model.UserRatingStats{
|
||||
{
|
||||
UserID: "admin-1",
|
||||
UserName: "admin",
|
||||
SongStats: []model.RatingStat{
|
||||
{Rating: 5, Count: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
UserID: "user-1",
|
||||
UserName: "regular",
|
||||
SongStats: []model.RatingStat{
|
||||
{Rating: 4, Count: 2},
|
||||
},
|
||||
},
|
||||
}
|
||||
mockedUserRepo.RatingItemsData = []model.RatedItem{
|
||||
{ID: "song-1", Name: "Test Song", Artist: "Test Artist"},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("GET /ratingStats", func() {
|
||||
Context("as admin user", func() {
|
||||
It("returns all users stats", func() {
|
||||
adminToken, err := auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/ratingStats", nil, adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var stats []model.UserRatingStats
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &stats)).To(Succeed())
|
||||
Expect(stats).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("as regular user", func() {
|
||||
It("returns only own stats", func() {
|
||||
userToken, err := auth.CreateToken(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/ratingStats", nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var stats []model.UserRatingStats
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &stats)).To(Succeed())
|
||||
Expect(stats).To(HaveLen(1))
|
||||
Expect(stats[0].UserID).To(Equal("user-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("without authentication", func() {
|
||||
It("returns 401", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/ratingStats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /ratingItems", func() {
|
||||
Context("as admin user", func() {
|
||||
It("can query any user's items", func() {
|
||||
adminToken, err := auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=4", nil, adminToken)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
|
||||
Context("as regular user", func() {
|
||||
var userToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
userToken, err = auth.CreateToken(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("can query own items", func() {
|
||||
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=4", nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 403 when querying another user's items", func() {
|
||||
req := createAuthenticatedRequest("GET", "/ratingItems?userId=admin-1&type=song&rating=5", nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("returns 400 for invalid rating", func() {
|
||||
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=9", nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 400 for invalid type", func() {
|
||||
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=artist&rating=3", nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 400 when userId is missing", func() {
|
||||
req := createAuthenticatedRequest("GET", "/ratingItems?type=song&rating=3", nil, userToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Context("without authentication", func() {
|
||||
It("returns 401", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=4", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
@ -18,9 +19,11 @@ func CreateMockUserRepo() *MockedUserRepo {
|
||||
|
||||
type MockedUserRepo struct {
|
||||
model.UserRepository
|
||||
Error error
|
||||
Data map[string]*model.User
|
||||
UserLibraries map[string][]int // userID -> libraryIDs
|
||||
Error error
|
||||
Data map[string]*model.User
|
||||
UserLibraries map[string][]int // userID -> libraryIDs
|
||||
RatingStatsData []model.UserRatingStats
|
||||
RatingItemsData []model.RatedItem
|
||||
}
|
||||
|
||||
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
|
||||
@ -146,6 +149,20 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *MockedUserRepo) RatingStats(ctx context.Context, userID string) ([]model.UserRatingStats, error) {
|
||||
if u.Error != nil {
|
||||
return nil, u.Error
|
||||
}
|
||||
return u.RatingStatsData, nil
|
||||
}
|
||||
|
||||
func (u *MockedUserRepo) RatingItems(ctx context.Context, userID, itemType string, rating int) ([]model.RatedItem, error) {
|
||||
if u.Error != nil {
|
||||
return nil, u.Error
|
||||
}
|
||||
return u.RatingItemsData, nil
|
||||
}
|
||||
|
||||
func (u *MockedUserRepo) Delete(id string) error {
|
||||
if u.Error != nil {
|
||||
return u.Error
|
||||
|
||||
@ -23,6 +23,7 @@ import 'react-image-lightbox/style.css'
|
||||
import subsonic from '../subsonic'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
AverageRatingField,
|
||||
CollapsibleComment,
|
||||
DurationField,
|
||||
formatRange,
|
||||
@ -309,6 +310,10 @@ const AlbumDetails = (props) => {
|
||||
resource={'album'}
|
||||
size={isDesktop ? 'medium' : 'small'}
|
||||
/>
|
||||
<AverageRatingField
|
||||
record={record}
|
||||
size={isDesktop ? 'medium' : 'small'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDesktop ? (
|
||||
|
||||
@ -16,6 +16,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import { playTracks } from '../actions'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
AverageRatingField,
|
||||
DateField,
|
||||
DurationField,
|
||||
QualityInfo,
|
||||
@ -143,6 +144,13 @@ const AlbumSongs = (props) => {
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: isDesktop && config.enableStarRating && (
|
||||
<AverageRatingField
|
||||
source="averageRating"
|
||||
label="resources.song.fields.averageRating"
|
||||
sortable={false}
|
||||
/>
|
||||
),
|
||||
}
|
||||
}, [isDesktop, classes.ratingField])
|
||||
|
||||
@ -151,6 +159,7 @@ const AlbumSongs = (props) => {
|
||||
columns: toggleableFields,
|
||||
omittedColumns: ['title'],
|
||||
defaultOff: [
|
||||
'averageRating',
|
||||
'composer',
|
||||
'channels',
|
||||
'bpm',
|
||||
|
||||
@ -14,6 +14,7 @@ import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
AverageRatingField,
|
||||
CoverArtAvatar,
|
||||
DurationField,
|
||||
RangeField,
|
||||
@ -126,6 +127,9 @@ const AlbumTableView = ({
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: config.enableStarRating && (
|
||||
<AverageRatingField source={'averageRating'} sortByOrder={'DESC'} />
|
||||
),
|
||||
createdAt: isDesktop && <DateField source="createdAt" showTime />,
|
||||
}
|
||||
}, [classes.ratingField, isDesktop])
|
||||
@ -133,7 +137,7 @@ const AlbumTableView = ({
|
||||
const columns = useSelectedFields({
|
||||
resource: 'album',
|
||||
columns: toggleableFields,
|
||||
defaultOff: ['createdAt', 'size', 'mood'],
|
||||
defaultOff: ['averageRating', 'createdAt', 'size', 'mood'],
|
||||
})
|
||||
|
||||
return isXsmall ? (
|
||||
|
||||
@ -22,6 +22,7 @@ import { useDrag } from 'react-dnd'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ArtistContextMenu,
|
||||
AverageRatingField,
|
||||
CoverArtAvatar,
|
||||
List,
|
||||
useGetHandleArtistClick,
|
||||
@ -157,6 +158,9 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: config.enableStarRating && (
|
||||
<AverageRatingField source="averageRating" sortByOrder={'DESC'} />
|
||||
),
|
||||
}),
|
||||
[classes.ratingField],
|
||||
)
|
||||
@ -164,6 +168,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
||||
const columns = useSelectedFields({
|
||||
resource: 'artist',
|
||||
columns: toggleableFields,
|
||||
defaultOff: ['averageRating'],
|
||||
})
|
||||
|
||||
return isXsmall ? (
|
||||
|
||||
44
ui/src/common/AverageRatingField.jsx
Normal file
44
ui/src/common/AverageRatingField.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
rating: {
|
||||
color: '#ffb400',
|
||||
opacity: 0.6,
|
||||
},
|
||||
})
|
||||
|
||||
export const AverageRatingField = ({ className, size, record = {}, ...rest }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
|
||||
const avg = Number(record.averageRating) || 0
|
||||
if (avg <= 0) return null
|
||||
|
||||
return (
|
||||
<span title={translate('userRatings.avgRating', { avg })}>
|
||||
<Rating
|
||||
className={clsx(className, classes.rating)}
|
||||
value={avg}
|
||||
precision={0.5}
|
||||
size={size}
|
||||
readOnly
|
||||
icon={<StarIcon fontSize="inherit" />}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
AverageRatingField.propTypes = {
|
||||
record: PropTypes.object,
|
||||
size: PropTypes.string,
|
||||
}
|
||||
|
||||
AverageRatingField.defaultProps = {
|
||||
size: 'small',
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from './AddToPlaylistButton'
|
||||
export * from './AverageRatingField'
|
||||
export * from './ArtistLinkField'
|
||||
export * from './BatchPlayButton'
|
||||
export * from './BitrateField'
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"starred": "Favourite",
|
||||
"comment": "Comment",
|
||||
"rating": "Rating",
|
||||
"averageRating": "Avg. Rating",
|
||||
"quality": "Quality",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Last Played",
|
||||
@ -76,6 +77,7 @@
|
||||
"updatedAt": "Updated at",
|
||||
"comment": "Comment",
|
||||
"rating": "Rating",
|
||||
"averageRating": "Avg. Rating",
|
||||
"createdAt": "Date added",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Catalog Number",
|
||||
@ -114,6 +116,7 @@
|
||||
"size": "Size",
|
||||
"playCount": "Plays",
|
||||
"rating": "Rating",
|
||||
"averageRating": "Avg. Rating",
|
||||
"genre": "Genre",
|
||||
"role": "Role",
|
||||
"missing": "Missing"
|
||||
@ -605,6 +608,7 @@
|
||||
"downloadOriginalFormat": "Download in original format"
|
||||
},
|
||||
"menu": {
|
||||
"userRatings": "User Ratings",
|
||||
"library": "Library",
|
||||
"librarySelector": {
|
||||
"allLibraries": "All Libraries (%{count})",
|
||||
@ -723,5 +727,10 @@
|
||||
"vol_down": "Volume Down",
|
||||
"toggle_love": "Add this track to favourites"
|
||||
}
|
||||
},
|
||||
"userRatings": {
|
||||
"noRatingsYet": "No ratings yet",
|
||||
"noItemsFound": "No items found",
|
||||
"avgRating": "Avg. Rating: %{avg}"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import clsx from 'clsx'
|
||||
import { useTranslate, MenuItemLink, getResources } from 'react-admin'
|
||||
import ViewListIcon from '@material-ui/icons/ViewList'
|
||||
import AlbumIcon from '@material-ui/icons/Album'
|
||||
import PeopleIcon from '@material-ui/icons/People'
|
||||
import SubMenu from './SubMenu'
|
||||
import { humanize, pluralize } from 'inflection'
|
||||
import albumLists from '../album/albumLists'
|
||||
@ -126,6 +127,16 @@ const Menu = ({ dense = false }) => {
|
||||
)}
|
||||
</SubMenu>
|
||||
{resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)}
|
||||
{config.enableStarRating && (
|
||||
<MenuItemLink
|
||||
to="/userRatings"
|
||||
activeClassName={classes.active}
|
||||
primaryText={translate('menu.userRatings')}
|
||||
leftIcon={<PeopleIcon />}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
)}
|
||||
{config.devSidebarPlaylists && open ? (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import React from 'react'
|
||||
import { Route } from 'react-router-dom'
|
||||
import Personal from './personal/Personal'
|
||||
import UserRatings from './userRatings/UserRatings'
|
||||
import UserRatingItems from './userRatings/UserRatingItems'
|
||||
|
||||
const routes = [
|
||||
<Route exact path="/personal" render={() => <Personal />} key={'personal'} />,
|
||||
<Route exact path="/userRatings" render={() => <UserRatings />} key={'userRatings'} />,
|
||||
<Route
|
||||
exact
|
||||
path="/userRatings/:userId/:userName/:type/:rating"
|
||||
component={UserRatingItems}
|
||||
key={'userRatingItems'}
|
||||
/>,
|
||||
]
|
||||
|
||||
export default routes
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
SongInfo,
|
||||
SongTitleField,
|
||||
SongSimpleList,
|
||||
AverageRatingField,
|
||||
RatingField,
|
||||
useResourceRefresh,
|
||||
ArtistLinkField,
|
||||
@ -174,6 +175,9 @@ const SongList = (props) => {
|
||||
className={classes.ratingField}
|
||||
/>
|
||||
),
|
||||
averageRating: config.enableStarRating && (
|
||||
<AverageRatingField source="averageRating" sortByOrder={'DESC'} />
|
||||
),
|
||||
bpm: isDesktop && <NumberField source="bpm" />,
|
||||
genre: <TextField source="genre" />,
|
||||
mood: isDesktop && (
|
||||
@ -200,6 +204,7 @@ const SongList = (props) => {
|
||||
'bpm',
|
||||
'playDate',
|
||||
'albumArtist',
|
||||
'averageRating',
|
||||
'genre',
|
||||
'mood',
|
||||
'comment',
|
||||
|
||||
136
ui/src/userRatings/UserRatingItems.jsx
Normal file
136
ui/src/userRatings/UserRatingItems.jsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Title, useTranslate } from 'react-admin'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
import { REST_URL } from '../consts'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: { padding: theme.spacing(2) },
|
||||
back: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: 'none',
|
||||
marginBottom: theme.spacing(2),
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
},
|
||||
backIcon: { marginRight: theme.spacing(0.5), fontSize: 18 },
|
||||
header: { marginBottom: theme.spacing(2) },
|
||||
avatar: { width: 48, height: 48, borderRadius: 4 },
|
||||
listItem: { paddingLeft: 0, paddingRight: 0 },
|
||||
}))
|
||||
|
||||
const UserRatingItems = ({ match }) => {
|
||||
const { userId, userName, type, rating } = match.params
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [items, setItems] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
httpClient(
|
||||
`${REST_URL}/ratingItems?userId=${encodeURIComponent(userId)}&type=${type}&rating=${rating}`,
|
||||
)
|
||||
.then(({ json }) => setItems(Array.isArray(json) ? json : []))
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [userId, type, rating])
|
||||
|
||||
const typeLabel =
|
||||
type === 'album'
|
||||
? translate('resources.album.name', { smart_count: 2 })
|
||||
: translate('resources.song.name', { smart_count: 2 })
|
||||
|
||||
const title = `${userName} · ${typeLabel} · ${rating}★`
|
||||
|
||||
const getCoverUrl = (item) => {
|
||||
return subsonic.getCoverArtUrl(
|
||||
{ id: item.id, albumArtist: type === 'album' ? item.artist : undefined, album: type === 'song' ? item.name : undefined, updatedAt: item.updatedAt },
|
||||
40,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Title title={title} />
|
||||
<Link to="/userRatings" className={classes.back}>
|
||||
<ArrowBackIcon className={classes.backIcon} />
|
||||
{translate('menu.userRatings')}
|
||||
</Link>
|
||||
<div className={classes.header}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{userName}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="textSecondary">
|
||||
{typeLabel} ·{' '}
|
||||
<Rating
|
||||
value={parseInt(rating, 10) || 0}
|
||||
readOnly
|
||||
size="small"
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />}
|
||||
emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />}
|
||||
/>
|
||||
</Typography>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{loading && <CircularProgress />}
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
{items && items.length === 0 && (
|
||||
<Typography color="textSecondary">{translate('userRatings.noItemsFound')}</Typography>
|
||||
)}
|
||||
{items && items.length > 0 && (
|
||||
<List disablePadding>
|
||||
{items.map((item) => {
|
||||
const albumId = type === 'album' ? item.id : item.albumId
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
className={classes.listItem}
|
||||
divider
|
||||
button
|
||||
component={Link}
|
||||
to={`/album/${albumId}/show`}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
src={getCoverUrl(item)}
|
||||
variant="square"
|
||||
className={classes.avatar}
|
||||
/>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={item.artist}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserRatingItems
|
||||
255
ui/src/userRatings/UserRatings.jsx
Normal file
255
ui/src/userRatings/UserRatings.jsx
Normal file
@ -0,0 +1,255 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import StarIcon from '@material-ui/icons/Star'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import PeopleIcon from '@material-ui/icons/People'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Title, useTranslate } from 'react-admin'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
import { REST_URL } from '../consts'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
userCard: {
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
userName: {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
totalLabel: {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
},
|
||||
row: {
|
||||
'&:nth-child(even)': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
},
|
||||
ratingCell: {
|
||||
width: 60,
|
||||
textAlign: 'right',
|
||||
paddingRight: theme.spacing(1),
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 'bold',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
countCell: {
|
||||
width: 50,
|
||||
textAlign: 'right',
|
||||
paddingRight: theme.spacing(1),
|
||||
fontWeight: 'bold',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
barCell: {
|
||||
padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`,
|
||||
},
|
||||
barOuter: {
|
||||
height: 20,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
minWidth: 4,
|
||||
},
|
||||
barInner: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
minWidth: 4,
|
||||
transition: 'width 0.4s ease',
|
||||
},
|
||||
starsCell: {
|
||||
width: 110,
|
||||
paddingLeft: theme.spacing(1),
|
||||
},
|
||||
emptyMsg: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontStyle: 'italic',
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}))
|
||||
|
||||
const ratingColor = (rating) => {
|
||||
if (rating >= 4) return '#69c76f'
|
||||
if (rating >= 3) return '#b8c750'
|
||||
if (rating >= 2) return '#d4a843'
|
||||
return '#c75050'
|
||||
}
|
||||
|
||||
const ALL_RATINGS = [5, 4, 3, 2, 1]
|
||||
|
||||
const RatingTable = ({ stats, label, userId, userName, type }) => {
|
||||
const classes = useStyles()
|
||||
const history = useHistory()
|
||||
const translate = useTranslate()
|
||||
|
||||
const countMap = {}
|
||||
let total = 0
|
||||
;(stats || []).forEach(({ rating, count }) => {
|
||||
countMap[rating] = count
|
||||
total += count
|
||||
})
|
||||
|
||||
const maxCount = Math.max(...Object.values(countMap), 1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography variant="subtitle1">
|
||||
{label}:{' '}
|
||||
<span className={classes.totalLabel}>{total}</span>
|
||||
</Typography>
|
||||
{total === 0 ? (
|
||||
<Typography className={classes.emptyMsg}>{translate('userRatings.noRatingsYet')}</Typography>
|
||||
) : (
|
||||
<table className={classes.table}>
|
||||
<tbody>
|
||||
{ALL_RATINGS.map((r) => {
|
||||
const count = countMap[r] || 0
|
||||
const width = count > 0 ? Math.max((count / maxCount) * 100, 2) : 2
|
||||
const linkTo = count > 0
|
||||
? `/userRatings/${encodeURIComponent(userId)}/${encodeURIComponent(userName)}/${type}/${r}`
|
||||
: null
|
||||
return (
|
||||
<tr
|
||||
key={r}
|
||||
className={classes.row}
|
||||
style={{ cursor: count > 0 ? 'pointer' : 'default' }}
|
||||
onClick={() => count > 0 && history.push(linkTo)}
|
||||
tabIndex={count > 0 ? 0 : undefined}
|
||||
role={count > 0 ? 'button' : undefined}
|
||||
onKeyDown={count > 0 ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||||
e.preventDefault()
|
||||
history.push(linkTo)
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<td className={classes.ratingCell}>{r}.0</td>
|
||||
<td className={classes.countCell}>{count}</td>
|
||||
<td className={classes.barCell}>
|
||||
<div className={classes.barOuter}>
|
||||
<div
|
||||
className={classes.barInner}
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: count > 0 ? ratingColor(r) : 'transparent',
|
||||
opacity: count > 0 ? 1 : 0.2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className={classes.starsCell}>
|
||||
<Rating
|
||||
value={r}
|
||||
readOnly
|
||||
size="small"
|
||||
icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />}
|
||||
emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserRatingCard = ({ user }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [tab, setTab] = useState(0)
|
||||
|
||||
return (
|
||||
<Card className={classes.userCard}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" className={classes.userName}>
|
||||
<PeopleIcon fontSize="small" style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||
{user.userName}
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
>
|
||||
<Tab label={translate('resources.song.name', { smart_count: 2 })} />
|
||||
<Tab label={translate('resources.album.name', { smart_count: 2 })} />
|
||||
</Tabs>
|
||||
<Divider />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{tab === 0 && (
|
||||
<RatingTable
|
||||
stats={user.songStats}
|
||||
label={translate('resources.song.name', { smart_count: 2 })}
|
||||
userId={user.userId}
|
||||
userName={user.userName}
|
||||
type="song"
|
||||
/>
|
||||
)}
|
||||
{tab === 1 && (
|
||||
<RatingTable
|
||||
stats={user.albumStats}
|
||||
label={translate('resources.album.name', { smart_count: 2 })}
|
||||
userId={user.userId}
|
||||
userName={user.userName}
|
||||
type="album"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const UserRatings = () => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
httpClient(`${REST_URL}/ratingStats`)
|
||||
.then(({ json }) => {
|
||||
setData(Array.isArray(json) ? json : [])
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Title title={translate('menu.userRatings')} />
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{translate('menu.userRatings')}
|
||||
</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
{data && data.length === 0 && (
|
||||
<Typography color="textSecondary">{translate('userRatings.noRatingsYet')}</Typography>
|
||||
)}
|
||||
{data &&
|
||||
data.map((user) => <UserRatingCard key={user.userId} user={user} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserRatings
|
||||
Loading…
x
Reference in New Issue
Block a user