From c0bc10284b55c484c30892342ea2d54b8b256eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D0=BE=D0=BD=20=D0=9F=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= Date: Thu, 4 Jun 2026 12:41:40 +0300 Subject: [PATCH 1/7] feat(ratings): add user ratings feature with average rating display and stats --- model/user.go | 23 +++ persistence/user_repository.go | 84 +++++++++ server/nativeapi/native_api.go | 1 + server/nativeapi/rating_stats.go | 56 ++++++ ui/package-lock.json | 39 ++++ ui/src/album/AlbumDetails.jsx | 5 + ui/src/album/AlbumSongs.jsx | 9 + ui/src/album/AlbumTableView.jsx | 6 +- ui/src/artist/ArtistList.jsx | 5 + ui/src/common/AverageRatingField.jsx | 44 +++++ ui/src/common/index.js | 1 + ui/src/i18n/en.json | 4 + ui/src/layout/Menu.jsx | 11 ++ ui/src/routes.jsx | 9 + ui/src/song/SongList.jsx | 5 + ui/src/userRatings/UserRatingItems.jsx | 138 ++++++++++++++ ui/src/userRatings/UserRatings.jsx | 246 +++++++++++++++++++++++++ 17 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 server/nativeapi/rating_stats.go create mode 100644 ui/src/common/AverageRatingField.jsx create mode 100644 ui/src/userRatings/UserRatingItems.jsx create mode 100644 ui/src/userRatings/UserRatings.jsx diff --git a/model/user.go b/model/user.go index 1c8541ccf..5aefc74b1 100644 --- a/model/user.go +++ b/model/user.go @@ -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) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index dc149e8ba..24a0999cb 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -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) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..39b2ed0ca 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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) diff --git a/server/nativeapi/rating_stats.go b/server/nativeapi/rating_stats.go new file mode 100644 index 000000000..614a79c14 --- /dev/null +++ b/server/nativeapi/rating_stats.go @@ -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) + } + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 1f95f14f8..264b93008 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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" }, diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index cec66eb8b..7704eedf8 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -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'} /> + )} {isDesktop ? ( diff --git a/ui/src/album/AlbumSongs.jsx b/ui/src/album/AlbumSongs.jsx index 8a7fd2ae4..a795ea70d 100644 --- a/ui/src/album/AlbumSongs.jsx +++ b/ui/src/album/AlbumSongs.jsx @@ -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 && ( + + ), } }, [isDesktop, classes.ratingField]) @@ -151,6 +159,7 @@ const AlbumSongs = (props) => { columns: toggleableFields, omittedColumns: ['title'], defaultOff: [ + 'averageRating', 'composer', 'channels', 'bpm', diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index d1a89d512..f62da7a62 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -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 && ( + + ), createdAt: isDesktop && , } }, [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 ? ( diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 4559aa8e6..63e0b529b 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -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 && ( + + ), }), [classes.ratingField], ) @@ -164,6 +168,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { const columns = useSelectedFields({ resource: 'artist', columns: toggleableFields, + defaultOff: ['averageRating'], }) return isXsmall ? ( diff --git a/ui/src/common/AverageRatingField.jsx b/ui/src/common/AverageRatingField.jsx new file mode 100644 index 000000000..9ac5a3854 --- /dev/null +++ b/ui/src/common/AverageRatingField.jsx @@ -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 ( + + } + /> + + ) +} + +AverageRatingField.propTypes = { + record: PropTypes.object, + size: PropTypes.string, +} + +AverageRatingField.defaultProps = { + size: 'small', +} \ No newline at end of file diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 362a0ced3..7b7c9b4e7 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -1,4 +1,5 @@ export * from './AddToPlaylistButton' +export * from './AverageRatingField' export * from './ArtistLinkField' export * from './BatchPlayButton' export * from './BitrateField' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..fada7ee5c 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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})", diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx index 45f40b26d..5a54b1976 100644 --- a/ui/src/layout/Menu.jsx +++ b/ui/src/layout/Menu.jsx @@ -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 }) => { )} {resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)} + {config.enableStarRating && ( + } + sidebarIsOpen={open} + dense={dense} + /> + )} {config.devSidebarPlaylists && open ? ( <> diff --git a/ui/src/routes.jsx b/ui/src/routes.jsx index 0b36ea5a9..5778ebf1c 100644 --- a/ui/src/routes.jsx +++ b/ui/src/routes.jsx @@ -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 = [ } key={'personal'} />, + } key={'userRatings'} />, + , ] export default routes diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index d928af549..ba63fbe08 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -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 && ( + + ), bpm: isDesktop && , genre: , mood: isDesktop && ( @@ -196,6 +200,7 @@ const SongList = (props) => { 'bpm', 'playDate', 'albumArtist', + 'averageRating', 'genre', 'mood', 'comment', diff --git a/ui/src/userRatings/UserRatingItems.jsx b/ui/src/userRatings/UserRatingItems.jsx new file mode 100644 index 000000000..07b66adfc --- /dev/null +++ b/ui/src/userRatings/UserRatingItems.jsx @@ -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 ( +
+ + <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 diff --git a/ui/src/userRatings/UserRatings.jsx b/ui/src/userRatings/UserRatings.jsx new file mode 100644 index 000000000..fd366a8c9 --- /dev/null +++ b/ui/src/userRatings/UserRatings.jsx @@ -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 From bd0daffd8b8ccbe8aa60af2bf2e5106fb0337dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D0=BE=D0=BD=20=D0=9F=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= <pplaton6@gmail.com> Date: Thu, 4 Jun 2026 13:02:31 +0300 Subject: [PATCH 2/7] feat(ratings): validate user ratings and improve error handling --- persistence/user_repository.go | 4 ++-- server/nativeapi/rating_stats.go | 2 +- ui/src/userRatings/UserRatingItems.jsx | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 24a0999cb..15710cceb 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -491,7 +491,7 @@ func (r *userRepository) RatingStats() ([]model.UserRatingStats, error) { 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`). + 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 @@ -541,7 +541,7 @@ func (r *userRepository) RatingItems(userID, itemType string, rating int) ([]mod case "song": table, nameCol, artistCol, albumIDCol = "media_file", "media_file.title", "media_file.artist", "media_file.album_id" default: - return nil, nil + return nil, fmt.Errorf("invalid item type: %q", itemType) } sel := Select(table+".id", nameCol+" as name", artistCol+" as artist", albumIDCol, table+".updated_at"). diff --git a/server/nativeapi/rating_stats.go b/server/nativeapi/rating_stats.go index 614a79c14..7c92dfeb3 100644 --- a/server/nativeapi/rating_stats.go +++ b/server/nativeapi/rating_stats.go @@ -37,7 +37,7 @@ func getRatingItems(ds model.DataStore) http.HandlerFunc { ratingStr := r.URL.Query().Get("rating") rating, err := strconv.Atoi(ratingStr) - if err != nil || userID == "" || (itemType != "album" && itemType != "song") { + if err != nil || rating < 1 || rating > 5 || userID == "" || (itemType != "album" && itemType != "song") { http.Error(w, "invalid parameters", http.StatusBadRequest) return } diff --git a/ui/src/userRatings/UserRatingItems.jsx b/ui/src/userRatings/UserRatingItems.jsx index 07b66adfc..43d67696c 100644 --- a/ui/src/userRatings/UserRatingItems.jsx +++ b/ui/src/userRatings/UserRatingItems.jsx @@ -20,7 +20,6 @@ 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) }, @@ -63,7 +62,6 @@ const UserRatingItems = ({ match }) => { 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, From ae203bc87278cc1eedb277ff22a0a155481c2bbd Mon Sep 17 00:00:00 2001 From: plut <pplaton6@gmail.com> Date: Thu, 4 Jun 2026 13:04:26 +0300 Subject: [PATCH 3/7] Update ui/src/userRatings/UserRatingItems.jsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ui/src/userRatings/UserRatingItems.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/userRatings/UserRatingItems.jsx b/ui/src/userRatings/UserRatingItems.jsx index 43d67696c..9756eb950 100644 --- a/ui/src/userRatings/UserRatingItems.jsx +++ b/ui/src/userRatings/UserRatingItems.jsx @@ -82,7 +82,7 @@ const UserRatingItems = ({ match }) => { <Typography variant="subtitle1" color="textSecondary"> {typeLabel} ·{' '} <Rating - value={parseInt(rating)} + value={parseInt(rating) || 0} readOnly size="small" style={{ verticalAlign: 'middle' }} From b438ac5436fd0db7ab551ecbe46b40f6fc44670d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D0=BE=D0=BD=20=D0=9F=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= <pplaton6@gmail.com> Date: Thu, 4 Jun 2026 17:21:53 +0300 Subject: [PATCH 4/7] feat(ratings): enhance user rating stats and item retrieval with user-specific access control --- persistence/user_repository.go | 2 +- server/nativeapi/rating_stats.go | 18 +++ server/nativeapi/rating_stats_test.go | 189 ++++++++++++++++++++++++++ tests/mock_user_repo.go | 22 ++- ui/src/userRatings/UserRatings.jsx | 3 + 5 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 server/nativeapi/rating_stats_test.go diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 15710cceb..cd8f85e3b 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -490,7 +490,7 @@ func (r *userRepository) RatingStats() ([]model.UserRatingStats, error) { 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}). + Where(And{Gt{"a.rating": 0}, Eq{"a.item_type": []interface{}{"media_file", "album"}}}). GroupBy(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`). OrderBy(`u.user_name`, `a.item_type`, `a.rating DESC`) diff --git a/server/nativeapi/rating_stats.go b/server/nativeapi/rating_stats.go index 7c92dfeb3..532eb2115 100644 --- a/server/nativeapi/rating_stats.go +++ b/server/nativeapi/rating_stats.go @@ -8,6 +8,7 @@ import ( "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) { @@ -17,12 +18,23 @@ func (api *Router) addRatingStatsRoute(r chi.Router) { func getRatingStats(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + currentUser, _ := request.UserFrom(r.Context()) 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 } + if !currentUser.IsAdmin { + filtered := make([]model.UserRatingStats, 0, 1) + for _, s := range stats { + if s.UserID == currentUser.ID { + filtered = append(filtered, s) + break + } + } + stats = filtered + } 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) @@ -32,6 +44,7 @@ func getRatingStats(ds model.DataStore) http.HandlerFunc { 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") @@ -42,6 +55,11 @@ func getRatingItems(ds model.DataStore) http.HandlerFunc { return } + if !currentUser.IsAdmin && currentUser.ID != userID { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + items, err := ds.User(r.Context()).RatingItems(userID, itemType, rating) if err != nil { log.Error(r.Context(), "Error getting rating items", err) diff --git a/server/nativeapi/rating_stats_test.go b/server/nativeapi/rating_stats_test.go new file mode 100644 index 000000000..d24474f9c --- /dev/null +++ b/server/nativeapi/rating_stats_test.go @@ -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)) + }) + }) + }) +}) diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 7c7dadbc4..5adc1625a 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -18,9 +18,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) { @@ -134,6 +136,20 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error return nil } +func (u *MockedUserRepo) RatingStats() ([]model.UserRatingStats, error) { + if u.Error != nil { + return nil, u.Error + } + return u.RatingStatsData, nil +} + +func (u *MockedUserRepo) RatingItems(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 diff --git a/ui/src/userRatings/UserRatings.jsx b/ui/src/userRatings/UserRatings.jsx index fd366a8c9..404e38c7c 100644 --- a/ui/src/userRatings/UserRatings.jsx +++ b/ui/src/userRatings/UserRatings.jsx @@ -128,6 +128,9 @@ const RatingTable = ({ stats, label, userId, userName, type }) => { 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) => (e.key === 'Enter' || e.key === ' ') && history.push(linkTo) : undefined} > <td className={classes.ratingCell}>{r}.0</td> <td className={classes.countCell}>{count}</td> From f7903383b6dfae72706c5d6787d1b3f6733424b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D0=BE=D0=BD=20=D0=9F=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= <pplaton6@gmail.com> Date: Sat, 13 Jun 2026 00:11:36 +0300 Subject: [PATCH 5/7] feat(ratings): update average rating display and enhance user feedback with translations --- model/user.go | 5 +++-- persistence/user_repository.go | 4 ++-- server/nativeapi/rating_stats.go | 4 ++-- tests/mock_user_repo.go | 5 +++-- ui/src/common/AverageRatingField.jsx | 10 +++++----- ui/src/i18n/en.json | 5 +++++ ui/src/userRatings/UserRatingItems.jsx | 4 ++-- ui/src/userRatings/UserRatings.jsx | 5 +++-- 8 files changed, 25 insertions(+), 17 deletions(-) diff --git a/model/user.go b/model/user.go index 5aefc74b1..b76aae091 100644 --- a/model/user.go +++ b/model/user.go @@ -1,6 +1,7 @@ package model import ( + "context" "time" ) @@ -80,6 +81,6 @@ type UserRepository interface { GetUserLibraries(userID string) (Libraries, error) SetUserLibraries(userID string, libraryIDs []int) error - RatingStats() ([]UserRatingStats, error) - RatingItems(userID, itemType string, rating int) ([]RatedItem, error) + RatingStats(ctx context.Context) ([]UserRatingStats, error) + RatingItems(ctx context.Context, userID, itemType string, rating int) ([]RatedItem, error) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index cd8f85e3b..e4071195a 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -478,7 +478,7 @@ func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error return nil } -func (r *userRepository) RatingStats() ([]model.UserRatingStats, error) { +func (r *userRepository) RatingStats(ctx context.Context) ([]model.UserRatingStats, error) { type row struct { UserID string `db:"user_id"` UserName string `db:"user_name"` @@ -525,7 +525,7 @@ func (r *userRepository) RatingStats() ([]model.UserRatingStats, error) { return result, nil } -func (r *userRepository) RatingItems(userID, itemType string, rating int) ([]model.RatedItem, error) { +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"` diff --git a/server/nativeapi/rating_stats.go b/server/nativeapi/rating_stats.go index 532eb2115..4e40738cb 100644 --- a/server/nativeapi/rating_stats.go +++ b/server/nativeapi/rating_stats.go @@ -19,7 +19,7 @@ func (api *Router) addRatingStatsRoute(r chi.Router) { func getRatingStats(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { currentUser, _ := request.UserFrom(r.Context()) - stats, err := ds.User(r.Context()).RatingStats() + stats, err := ds.User(r.Context()).RatingStats(r.Context()) if err != nil { log.Error(r.Context(), "Error getting rating stats", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -60,7 +60,7 @@ func getRatingItems(ds model.DataStore) http.HandlerFunc { return } - items, err := ds.User(r.Context()).RatingItems(userID, itemType, rating) + 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) diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 5adc1625a..81a66971a 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -1,6 +1,7 @@ package tests import ( + "context" "encoding/base64" "fmt" "strings" @@ -136,14 +137,14 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error return nil } -func (u *MockedUserRepo) RatingStats() ([]model.UserRatingStats, error) { +func (u *MockedUserRepo) RatingStats(ctx context.Context) ([]model.UserRatingStats, error) { if u.Error != nil { return nil, u.Error } return u.RatingStatsData, nil } -func (u *MockedUserRepo) RatingItems(userID, itemType string, rating int) ([]model.RatedItem, error) { +func (u *MockedUserRepo) RatingItems(ctx context.Context, userID, itemType string, rating int) ([]model.RatedItem, error) { if u.Error != nil { return nil, u.Error } diff --git a/ui/src/common/AverageRatingField.jsx b/ui/src/common/AverageRatingField.jsx index 9ac5a3854..7f3ed21e7 100644 --- a/ui/src/common/AverageRatingField.jsx +++ b/ui/src/common/AverageRatingField.jsx @@ -3,7 +3,7 @@ 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 { useTranslate } from 'react-admin' import clsx from 'clsx' const useStyles = makeStyles({ @@ -13,15 +13,15 @@ const useStyles = makeStyles({ }, }) -export const AverageRatingField = ({ className, size, ...rest }) => { - const record = useRecordContext(rest) || {} +export const AverageRatingField = ({ className, size, record = {}, ...rest }) => { const classes = useStyles() + const translate = useTranslate() const avg = record.averageRating || 0 if (avg <= 0) return null return ( - <span title={`Avg. Rating: ${avg}`}> + <span title={translate('userRatings.avgRating', { avg })}> <Rating className={clsx(className, classes.rating)} value={avg} @@ -41,4 +41,4 @@ AverageRatingField.propTypes = { AverageRatingField.defaultProps = { size: 'small', -} \ No newline at end of file +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index fada7ee5c..b34decb4b 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -725,5 +725,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}" } } diff --git a/ui/src/userRatings/UserRatingItems.jsx b/ui/src/userRatings/UserRatingItems.jsx index 9756eb950..5e0582897 100644 --- a/ui/src/userRatings/UserRatingItems.jsx +++ b/ui/src/userRatings/UserRatingItems.jsx @@ -82,7 +82,7 @@ const UserRatingItems = ({ match }) => { <Typography variant="subtitle1" color="textSecondary"> {typeLabel} ·{' '} <Rating - value={parseInt(rating) || 0} + value={parseInt(rating, 10) || 0} readOnly size="small" style={{ verticalAlign: 'middle' }} @@ -96,7 +96,7 @@ const UserRatingItems = ({ match }) => { {loading && <CircularProgress />} {error && <Typography color="error">{error}</Typography>} {items && items.length === 0 && ( - <Typography color="textSecondary">No items found.</Typography> + <Typography color="textSecondary">{translate('userRatings.noItemsFound')}</Typography> )} {items && items.length > 0 && ( <List disablePadding> diff --git a/ui/src/userRatings/UserRatings.jsx b/ui/src/userRatings/UserRatings.jsx index 404e38c7c..6fa852de9 100644 --- a/ui/src/userRatings/UserRatings.jsx +++ b/ui/src/userRatings/UserRatings.jsx @@ -95,6 +95,7 @@ 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 @@ -112,7 +113,7 @@ const RatingTable = ({ stats, label, userId, userName, type }) => { <span className={classes.totalLabel}>{total}</span> </Typography> {total === 0 ? ( - <Typography className={classes.emptyMsg}>No ratings yet</Typography> + <Typography className={classes.emptyMsg}>{translate('userRatings.noRatingsYet')}</Typography> ) : ( <table className={classes.table}> <tbody> @@ -238,7 +239,7 @@ const UserRatings = () => { {loading && <CircularProgress />} {error && <Typography color="error">{error}</Typography>} {data && data.length === 0 && ( - <Typography color="textSecondary">No ratings yet.</Typography> + <Typography color="textSecondary">{translate('userRatings.noRatingsYet')}</Typography> )} {data && data.map((user) => <UserRatingCard key={user.userId} user={user} />)} From b564acfa18b362901e83ecf4052bd529fa4d4cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D0=BE=D0=BD=20=D0=9F=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= <pplaton6@gmail.com> Date: Sat, 13 Jun 2026 17:15:18 +0300 Subject: [PATCH 6/7] feat(ratings): enhance user rating stats retrieval with user-specific filtering and improve error messages --- model/user.go | 2 +- persistence/user_repository.go | 8 +++++-- server/nativeapi/rating_stats.go | 30 ++++++++++++++------------ tests/mock_user_repo.go | 2 +- ui/src/common/AverageRatingField.jsx | 2 +- ui/src/userRatings/UserRatingItems.jsx | 6 ++---- ui/src/userRatings/UserRatings.jsx | 7 +++++- 7 files changed, 33 insertions(+), 24 deletions(-) diff --git a/model/user.go b/model/user.go index b76aae091..917827b6f 100644 --- a/model/user.go +++ b/model/user.go @@ -81,6 +81,6 @@ type UserRepository interface { GetUserLibraries(userID string) (Libraries, error) SetUserLibraries(userID string, libraryIDs []int) error - RatingStats(ctx context.Context) ([]UserRatingStats, error) + RatingStats(ctx context.Context, userID string) ([]UserRatingStats, error) RatingItems(ctx context.Context, userID, itemType string, rating int) ([]RatedItem, error) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 617669b6c..26c9fc13e 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -478,7 +478,7 @@ func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error return nil } -func (r *userRepository) RatingStats(ctx context.Context) ([]model.UserRatingStats, error) { +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"` @@ -487,10 +487,14 @@ func (r *userRepository) RatingStats(ctx context.Context) ([]model.UserRatingSta 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(And{Gt{"a.rating": 0}, Eq{"a.item_type": []interface{}{"media_file", "album"}}}). + Where(where). GroupBy(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`). OrderBy(`u.user_name`, `a.item_type`, `a.rating DESC`) diff --git a/server/nativeapi/rating_stats.go b/server/nativeapi/rating_stats.go index 4e40738cb..4e15b4ce4 100644 --- a/server/nativeapi/rating_stats.go +++ b/server/nativeapi/rating_stats.go @@ -19,22 +19,16 @@ func (api *Router) addRatingStatsRoute(r chi.Router) { func getRatingStats(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { currentUser, _ := request.UserFrom(r.Context()) - stats, err := ds.User(r.Context()).RatingStats(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 } - if !currentUser.IsAdmin { - filtered := make([]model.UserRatingStats, 0, 1) - for _, s := range stats { - if s.UserID == currentUser.ID { - filtered = append(filtered, s) - break - } - } - stats = filtered - } 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) @@ -50,13 +44,21 @@ func getRatingItems(ds model.DataStore) http.HandlerFunc { ratingStr := r.URL.Query().Get("rating") rating, err := strconv.Atoi(ratingStr) - if err != nil || rating < 1 || rating > 5 || userID == "" || (itemType != "album" && itemType != "song") { - http.Error(w, "invalid parameters", http.StatusBadRequest) + 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, "forbidden", http.StatusForbidden) + http.Error(w, "non-admin users can only query their own ratings", http.StatusForbidden) return } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 81a66971a..1c8e26e61 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -137,7 +137,7 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error return nil } -func (u *MockedUserRepo) RatingStats(ctx context.Context) ([]model.UserRatingStats, error) { +func (u *MockedUserRepo) RatingStats(ctx context.Context, userID string) ([]model.UserRatingStats, error) { if u.Error != nil { return nil, u.Error } diff --git a/ui/src/common/AverageRatingField.jsx b/ui/src/common/AverageRatingField.jsx index 7f3ed21e7..78b14d513 100644 --- a/ui/src/common/AverageRatingField.jsx +++ b/ui/src/common/AverageRatingField.jsx @@ -17,7 +17,7 @@ export const AverageRatingField = ({ className, size, record = {}, ...rest }) => const classes = useStyles() const translate = useTranslate() - const avg = record.averageRating || 0 + const avg = Number(record.averageRating) || 0 if (avg <= 0) return null return ( diff --git a/ui/src/userRatings/UserRatingItems.jsx b/ui/src/userRatings/UserRatingItems.jsx index 5e0582897..7c1201808 100644 --- a/ui/src/userRatings/UserRatingItems.jsx +++ b/ui/src/userRatings/UserRatingItems.jsx @@ -62,10 +62,8 @@ const UserRatingItems = ({ match }) => { 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, - ) + const coverArtId = type === 'album' ? item.id : item.albumId + return subsonic.getCoverArtUrl({ id: coverArtId, updatedAt: item.updatedAt }, 40) } return ( diff --git a/ui/src/userRatings/UserRatings.jsx b/ui/src/userRatings/UserRatings.jsx index 6fa852de9..4402387bc 100644 --- a/ui/src/userRatings/UserRatings.jsx +++ b/ui/src/userRatings/UserRatings.jsx @@ -131,7 +131,12 @@ const RatingTable = ({ stats, label, userId, userName, type }) => { onClick={() => count > 0 && history.push(linkTo)} tabIndex={count > 0 ? 0 : undefined} role={count > 0 ? 'button' : undefined} - onKeyDown={count > 0 ? (e) => (e.key === 'Enter' || e.key === ' ') && history.push(linkTo) : 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> From 3444fc1871b0cc23e401fef2990186e0d913bb14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D1=82=D0=BE=D0=BD=20=D0=9F=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= <pplaton6@gmail.com> Date: Sat, 13 Jun 2026 17:22:37 +0300 Subject: [PATCH 7/7] feat(ratings): improve cover art URL retrieval with enhanced item properties --- ui/src/userRatings/UserRatingItems.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/userRatings/UserRatingItems.jsx b/ui/src/userRatings/UserRatingItems.jsx index 7c1201808..5e0582897 100644 --- a/ui/src/userRatings/UserRatingItems.jsx +++ b/ui/src/userRatings/UserRatingItems.jsx @@ -62,8 +62,10 @@ const UserRatingItems = ({ match }) => { const title = `${userName} · ${typeLabel} · ${rating}★` const getCoverUrl = (item) => { - const coverArtId = type === 'album' ? item.id : item.albumId - return subsonic.getCoverArtUrl({ id: coverArtId, updatedAt: item.updatedAt }, 40) + return subsonic.getCoverArtUrl( + { id: item.id, albumArtist: type === 'album' ? item.artist : undefined, album: type === 'song' ? item.name : undefined, updatedAt: item.updatedAt }, + 40, + ) } return (