feat(ratings): add user ratings feature with average rating display and stats

This commit is contained in:
Платон Петров 2026-06-04 12:41:40 +03:00
parent bc107d1cee
commit c0bc10284b
17 changed files with 685 additions and 1 deletions

View File

@ -41,6 +41,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 +79,7 @@ type UserRepository interface {
// Library association methods
GetUserLibraries(userID string) (Libraries, error)
SetUserLibraries(userID string, libraryIDs []int) error
RatingStats() ([]UserRatingStats, error)
RatingItems(userID, itemType string, rating int) ([]RatedItem, error)
}

View File

@ -478,6 +478,90 @@ func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error
return nil
}
func (r *userRepository) RatingStats() ([]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"`
}
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(Gt{"a.rating": 0}).
GroupBy(`a.user_id`, `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(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, nil
}
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)

View File

@ -84,6 +84,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)

View File

@ -0,0 +1,56 @@
package nativeapi
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
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) {
stats, err := ds.User(r.Context()).RatingStats()
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) {
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 || userID == "" || (itemType != "album" && itemType != "song") {
http.Error(w, "invalid parameters", http.StatusBadRequest)
return
}
items, err := ds.User(r.Context()).RatingItems(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)
}
}
}

39
ui/package-lock.json generated
View File

@ -129,6 +129,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1743,6 +1744,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1766,6 +1768,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -2471,6 +2474,7 @@
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz",
"integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.3",
"ajv": "^6.10.2",
@ -2524,6 +2528,7 @@
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz",
"integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"lodash": "^4.17.15",
"object-hash": "^2.0.0"
@ -2539,6 +2544,7 @@
"integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==",
"deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.4.4",
"@material-ui/styles": "^4.11.5",
@ -2585,6 +2591,7 @@
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz",
"integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.4.4"
},
@ -3308,6 +3315,7 @@
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
@ -3360,6 +3368,7 @@
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -3381,6 +3390,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz",
"integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "^0.16",
@ -3550,6 +3560,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3883,6 +3894,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4567,6 +4579,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -4942,6 +4955,7 @@
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz",
"integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"lodash.isequalwith": "^4.4.0",
"prop-types": "^15.7.2"
@ -5822,6 +5836,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -6405,6 +6420,7 @@
"resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz",
"integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.10.0"
},
@ -6421,6 +6437,7 @@
"resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz",
"integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"final-form": "^4.20.8"
}
@ -6835,6 +6852,7 @@
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
@ -6948,6 +6966,7 @@
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
@ -8550,6 +8569,7 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"peer": true,
"engines": {
"node": "*"
}
@ -9298,6 +9318,7 @@
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@ -9389,6 +9410,7 @@
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz",
"integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"classnames": "~2.3.1",
"date-fns": "^1.29.0",
@ -9803,6 +9825,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
"integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
@ -9884,6 +9907,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
"integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
@ -9956,6 +9980,7 @@
"resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz",
"integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.15.4"
},
@ -9973,6 +9998,7 @@
"resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz",
"integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.19.4"
},
@ -10078,6 +10104,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
"integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
@ -10113,6 +10140,7 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@ -10133,6 +10161,7 @@
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
"integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@ -10314,6 +10343,7 @@
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.9.2"
}
@ -10323,6 +10353,7 @@
"resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz",
"integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@redux-saga/core": "^1.4.2"
}
@ -10582,6 +10613,7 @@
"integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -11511,6 +11543,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -11732,6 +11765,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -11976,6 +12010,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -12100,6 +12135,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -12113,6 +12149,7 @@
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.2",
"@vitest/mocker": "4.1.2",
@ -12632,6 +12669,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -12741,6 +12779,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},

View File

@ -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 ? (

View File

@ -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',

View File

@ -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 ? (

View File

@ -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 ? (

View 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 { useRecordContext } from 'react-admin'
import clsx from 'clsx'
const useStyles = makeStyles({
rating: {
color: '#ffb400',
opacity: 0.6,
},
})
export const AverageRatingField = ({ className, size, ...rest }) => {
const record = useRecordContext(rest) || {}
const classes = useStyles()
const avg = record.averageRating || 0
if (avg <= 0) return null
return (
<span title={`Avg. Rating: ${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',
}

View File

@ -1,4 +1,5 @@
export * from './AddToPlaylistButton'
export * from './AverageRatingField'
export * from './ArtistLinkField'
export * from './BatchPlayButton'
export * from './BitrateField'

View File

@ -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"
@ -604,6 +607,7 @@
"downloadOriginalFormat": "Download in original format"
},
"menu": {
"userRatings": "User Ratings",
"library": "Library",
"librarySelector": {
"allLibraries": "All Libraries (%{count})",

View File

@ -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 />

View File

@ -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

View File

@ -22,6 +22,7 @@ import {
SongInfo,
SongTitleField,
SongSimpleList,
AverageRatingField,
RatingField,
useResourceRefresh,
ArtistLinkField,
@ -170,6 +171,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 && (
@ -196,6 +200,7 @@ const SongList = (props) => {
'bpm',
'playDate',
'albumArtist',
'averageRating',
'genre',
'mood',
'comment',

View File

@ -0,0 +1,138 @@
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'
import config from '../config'
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) => {
const prefix = type === 'album' ? 'al-' : 'mf-'
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)}
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">No items found.</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

View File

@ -0,0 +1,246 @@
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 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}>No ratings yet</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)}
>
<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">No ratings yet.</Typography>
)}
{data &&
data.map((user) => <UserRatingCard key={user.userId} user={user} />)}
</div>
)
}
export default UserRatings