diff --git a/model/user.go b/model/user.go index 1c8541ccf..917827b6f 100644 --- a/model/user.go +++ b/model/user.go @@ -1,6 +1,7 @@ package model import ( + "context" "time" ) @@ -41,6 +42,26 @@ func (u User) HasLibraryAccess(libraryID int) bool { type Users []User +type RatingStat struct { + Rating int `json:"rating"` + Count int `json:"count"` +} + +type UserRatingStats struct { + UserID string `json:"userId"` + UserName string `json:"userName"` + SongStats []RatingStat `json:"songStats"` + AlbumStats []RatingStat `json:"albumStats"` +} + +type RatedItem struct { + ID string `json:"id"` + Name string `json:"name"` + Artist string `json:"artist"` + AlbumID string `json:"albumId,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + type UserRepository interface { ResourceRepository CountAll(...QueryOptions) (int64, error) @@ -59,4 +80,7 @@ type UserRepository interface { // Library association methods GetUserLibraries(userID string) (Libraries, error) SetUserLibraries(userID string, libraryIDs []int) error + + RatingStats(ctx context.Context, userID string) ([]UserRatingStats, error) + RatingItems(ctx context.Context, userID, itemType string, rating int) ([]RatedItem, error) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9decff4e5..26c9fc13e 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -478,6 +478,94 @@ func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error return nil } +func (r *userRepository) RatingStats(ctx context.Context, userID string) ([]model.UserRatingStats, error) { + type row struct { + UserID string `db:"user_id"` + UserName string `db:"user_name"` + ItemType string `db:"item_type"` + Rating int `db:"rating"` + Count int `db:"cnt"` + } + + where := And{Gt{"a.rating": 0}, Eq{"a.item_type": []interface{}{"media_file", "album"}}} + if userID != "" { + where = append(where, Eq{"a.user_id": userID}) + } + sel := Select(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`, `count(*) as cnt`). + From(`annotation a`). + Join(`"user" u ON a.user_id = u.id`). + Where(where). + GroupBy(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`). + OrderBy(`u.user_name`, `a.item_type`, `a.rating DESC`) + + var rows []row + if err := r.queryAll(sel, &rows); err != nil && err != model.ErrNotFound { + return nil, err + } + + byUser := map[string]*model.UserRatingStats{} + order := []string{} + for _, row := range rows { + if _, ok := byUser[row.UserID]; !ok { + byUser[row.UserID] = &model.UserRatingStats{ + UserID: row.UserID, + UserName: row.UserName, + } + order = append(order, row.UserID) + } + stat := model.RatingStat{Rating: row.Rating, Count: row.Count} + switch row.ItemType { + case "media_file": + byUser[row.UserID].SongStats = append(byUser[row.UserID].SongStats, stat) + case "album": + byUser[row.UserID].AlbumStats = append(byUser[row.UserID].AlbumStats, stat) + } + } + + result := make([]model.UserRatingStats, 0, len(order)) + for _, uid := range order { + result = append(result, *byUser[uid]) + } + return result, nil +} + +func (r *userRepository) RatingItems(ctx context.Context, userID, itemType string, rating int) ([]model.RatedItem, error) { + type row struct { + ID string `db:"id"` + Name string `db:"name"` + Artist string `db:"artist"` + AlbumID string `db:"album_id"` + UpdatedAt time.Time `db:"updated_at"` + } + + var table, nameCol, artistCol, albumIDCol string + switch itemType { + case "album": + table, nameCol, artistCol, albumIDCol = "album", "album.name", "album.album_artist", "'' as album_id" + case "song": + table, nameCol, artistCol, albumIDCol = "media_file", "media_file.title", "media_file.artist", "media_file.album_id" + default: + return nil, fmt.Errorf("invalid item type: %q", itemType) + } + + sel := Select(table+".id", nameCol+" as name", artistCol+" as artist", albumIDCol, table+".updated_at"). + From("annotation a"). + Join(table + " ON a.item_id = " + table + ".id"). + Where(Eq{"a.user_id": userID, "a.item_type": table, "a.rating": rating}). + OrderBy("name") + + var rows []row + if err := r.queryAll(sel, &rows); err != nil && err != model.ErrNotFound { + return nil, err + } + + result := make([]model.RatedItem, len(rows)) + for i, row := range rows { + result[i] = model.RatedItem{ID: row.ID, Name: row.Name, Artist: row.Artist, AlbumID: row.AlbumID, UpdatedAt: row.UpdatedAt} + } + return result, nil +} + var _ model.UserRepository = (*userRepository)(nil) var _ rest.Repository = (*userRepository)(nil) var _ rest.Persistable = (*userRepository)(nil) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..7563ae66b 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -85,6 +85,7 @@ func (api *Router) routes() http.Handler { api.addMissingFilesRoute(r) api.addKeepAliveRoute(r) api.addInsightsRoute(r) + api.addRatingStatsRoute(r) r.With(adminOnlyMiddleware).Group(func(r chi.Router) { api.addInspectRoute(r) diff --git a/server/nativeapi/rating_stats.go b/server/nativeapi/rating_stats.go new file mode 100644 index 000000000..4e15b4ce4 --- /dev/null +++ b/server/nativeapi/rating_stats.go @@ -0,0 +1,76 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +func (api *Router) addRatingStatsRoute(r chi.Router) { + r.Get("/ratingStats", getRatingStats(api.ds)) + r.Get("/ratingItems", getRatingItems(api.ds)) +} + +func getRatingStats(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + currentUser, _ := request.UserFrom(r.Context()) + filterUserID := "" + if !currentUser.IsAdmin { + filterUserID = currentUser.ID + } + stats, err := ds.User(r.Context()).RatingStats(r.Context(), filterUserID) + if err != nil { + log.Error(r.Context(), "Error getting rating stats", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(stats); err != nil { + log.Error(r.Context(), "Error encoding rating stats", err) + } + } +} + +func getRatingItems(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + currentUser, _ := request.UserFrom(r.Context()) + userID := r.URL.Query().Get("userId") + itemType := r.URL.Query().Get("type") + ratingStr := r.URL.Query().Get("rating") + + rating, err := strconv.Atoi(ratingStr) + if err != nil || rating < 1 || rating > 5 { + http.Error(w, "rating must be between 1 and 5", http.StatusBadRequest) + return + } + if userID == "" { + http.Error(w, "userId is required", http.StatusBadRequest) + return + } + if itemType != "album" && itemType != "song" { + http.Error(w, "type must be 'album' or 'song'", http.StatusBadRequest) + return + } + + if !currentUser.IsAdmin && currentUser.ID != userID { + http.Error(w, "non-admin users can only query their own ratings", http.StatusForbidden) + return + } + + items, err := ds.User(r.Context()).RatingItems(r.Context(), userID, itemType, rating) + if err != nil { + log.Error(r.Context(), "Error getting rating items", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(items); err != nil { + log.Error(r.Context(), "Error encoding rating items", err) + } + } +} 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 2d6ff3c02..112435eb3 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" @@ -18,9 +19,11 @@ func CreateMockUserRepo() *MockedUserRepo { type MockedUserRepo struct { model.UserRepository - Error error - Data map[string]*model.User - UserLibraries map[string][]int // userID -> libraryIDs + Error error + Data map[string]*model.User + UserLibraries map[string][]int // userID -> libraryIDs + RatingStatsData []model.UserRatingStats + RatingItemsData []model.RatedItem } func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { @@ -146,6 +149,20 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error return nil } +func (u *MockedUserRepo) RatingStats(ctx context.Context, userID string) ([]model.UserRatingStats, error) { + if u.Error != nil { + return nil, u.Error + } + return u.RatingStatsData, nil +} + +func (u *MockedUserRepo) RatingItems(ctx context.Context, userID, itemType string, rating int) ([]model.RatedItem, error) { + if u.Error != nil { + return nil, u.Error + } + return u.RatingItemsData, nil +} + func (u *MockedUserRepo) Delete(id string) error { if u.Error != nil { return u.Error 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..78b14d513 --- /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 { useTranslate } from 'react-admin' +import clsx from 'clsx' + +const useStyles = makeStyles({ + rating: { + color: '#ffb400', + opacity: 0.6, + }, +}) + +export const AverageRatingField = ({ className, size, record = {}, ...rest }) => { + const classes = useStyles() + const translate = useTranslate() + + const avg = Number(record.averageRating) || 0 + if (avg <= 0) return null + + return ( + + } + /> + + ) +} + +AverageRatingField.propTypes = { + record: PropTypes.object, + size: PropTypes.string, +} + +AverageRatingField.defaultProps = { + size: 'small', +} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index ac8d7f62c..2a5c70708 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 c0e226453..5ce910869 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" @@ -605,6 +608,7 @@ "downloadOriginalFormat": "Download in original format" }, "menu": { + "userRatings": "User Ratings", "library": "Library", "librarySelector": { "allLibraries": "All Libraries (%{count})", @@ -723,5 +727,10 @@ "vol_down": "Volume Down", "toggle_love": "Add this track to favourites" } + }, + "userRatings": { + "noRatingsYet": "No ratings yet", + "noItemsFound": "No items found", + "avgRating": "Avg. Rating: %{avg}" } } 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 6b7bfaf96..4b5e66d24 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, @@ -174,6 +175,9 @@ const SongList = (props) => { className={classes.ratingField} /> ), + averageRating: config.enableStarRating && ( + + ), bpm: isDesktop && , genre: , mood: isDesktop && ( @@ -200,6 +204,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..5e0582897 --- /dev/null +++ b/ui/src/userRatings/UserRatingItems.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react' +import { + Avatar, + Card, + CardContent, + CircularProgress, + List, + ListItem, + ListItemAvatar, + ListItemText, + Typography, + makeStyles, +} from '@material-ui/core' +import Rating from '@material-ui/lab/Rating' +import StarIcon from '@material-ui/icons/Star' +import StarBorderIcon from '@material-ui/icons/StarBorder' +import ArrowBackIcon from '@material-ui/icons/ArrowBack' +import { Link } from 'react-router-dom' +import { Title, useTranslate } from 'react-admin' +import httpClient from '../dataProvider/httpClient' +import { REST_URL } from '../consts' +import subsonic from '../subsonic' + +const useStyles = makeStyles((theme) => ({ + root: { padding: theme.spacing(2) }, + back: { + display: 'flex', + alignItems: 'center', + color: theme.palette.primary.main, + textDecoration: 'none', + marginBottom: theme.spacing(2), + '&:hover': { textDecoration: 'underline' }, + }, + backIcon: { marginRight: theme.spacing(0.5), fontSize: 18 }, + header: { marginBottom: theme.spacing(2) }, + avatar: { width: 48, height: 48, borderRadius: 4 }, + listItem: { paddingLeft: 0, paddingRight: 0 }, +})) + +const UserRatingItems = ({ match }) => { + const { userId, userName, type, rating } = match.params + const classes = useStyles() + const translate = useTranslate() + const [items, setItems] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + httpClient( + `${REST_URL}/ratingItems?userId=${encodeURIComponent(userId)}&type=${type}&rating=${rating}`, + ) + .then(({ json }) => setItems(Array.isArray(json) ? json : [])) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)) + }, [userId, type, rating]) + + const typeLabel = + type === 'album' + ? translate('resources.album.name', { smart_count: 2 }) + : translate('resources.song.name', { smart_count: 2 }) + + const title = `${userName} · ${typeLabel} · ${rating}★` + + const getCoverUrl = (item) => { + return subsonic.getCoverArtUrl( + { id: item.id, albumArtist: type === 'album' ? item.artist : undefined, album: type === 'song' ? item.name : undefined, updatedAt: item.updatedAt }, + 40, + ) + } + + return ( +
+ + <Link to="/userRatings" className={classes.back}> + <ArrowBackIcon className={classes.backIcon} /> + {translate('menu.userRatings')} + </Link> + <div className={classes.header}> + <Typography variant="h5" gutterBottom> + {userName} + </Typography> + <Typography variant="subtitle1" color="textSecondary"> + {typeLabel} ·{' '} + <Rating + value={parseInt(rating, 10) || 0} + readOnly + size="small" + style={{ verticalAlign: 'middle' }} + icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />} + emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />} + /> + </Typography> + </div> + <Card> + <CardContent> + {loading && <CircularProgress />} + {error && <Typography color="error">{error}</Typography>} + {items && items.length === 0 && ( + <Typography color="textSecondary">{translate('userRatings.noItemsFound')}</Typography> + )} + {items && items.length > 0 && ( + <List disablePadding> + {items.map((item) => { + const albumId = type === 'album' ? item.id : item.albumId + return ( + <ListItem + key={item.id} + className={classes.listItem} + divider + button + component={Link} + to={`/album/${albumId}/show`} + > + <ListItemAvatar> + <Avatar + src={getCoverUrl(item)} + variant="square" + className={classes.avatar} + /> + </ListItemAvatar> + <ListItemText + primary={item.name} + secondary={item.artist} + /> + </ListItem> + ) + })} + </List> + )} + </CardContent> + </Card> + </div> + ) +} + +export default UserRatingItems diff --git a/ui/src/userRatings/UserRatings.jsx b/ui/src/userRatings/UserRatings.jsx new file mode 100644 index 000000000..4402387bc --- /dev/null +++ b/ui/src/userRatings/UserRatings.jsx @@ -0,0 +1,255 @@ +import React, { useEffect, useState } from 'react' +import { + Card, + CardContent, + CircularProgress, + Divider, + Tab, + Tabs, + Typography, + makeStyles, +} from '@material-ui/core' +import Rating from '@material-ui/lab/Rating' +import StarIcon from '@material-ui/icons/Star' +import StarBorderIcon from '@material-ui/icons/StarBorder' +import PeopleIcon from '@material-ui/icons/People' +import { useHistory } from 'react-router-dom' +import { Title, useTranslate } from 'react-admin' +import httpClient from '../dataProvider/httpClient' +import { REST_URL } from '../consts' + +const useStyles = makeStyles((theme) => ({ + root: { + padding: theme.spacing(2), + }, + userCard: { + marginBottom: theme.spacing(3), + }, + userName: { + marginBottom: theme.spacing(1), + }, + totalLabel: { + color: theme.palette.primary.main, + fontWeight: 'bold', + }, + table: { + width: '100%', + borderCollapse: 'collapse', + }, + row: { + '&:nth-child(even)': { + backgroundColor: theme.palette.action.hover, + }, + }, + ratingCell: { + width: 60, + textAlign: 'right', + paddingRight: theme.spacing(1), + color: theme.palette.primary.main, + fontWeight: 'bold', + whiteSpace: 'nowrap', + }, + countCell: { + width: 50, + textAlign: 'right', + paddingRight: theme.spacing(1), + fontWeight: 'bold', + whiteSpace: 'nowrap', + }, + barCell: { + padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`, + }, + barOuter: { + height: 20, + borderRadius: 3, + overflow: 'hidden', + backgroundColor: theme.palette.action.selected, + minWidth: 4, + }, + barInner: { + height: '100%', + borderRadius: 3, + minWidth: 4, + transition: 'width 0.4s ease', + }, + starsCell: { + width: 110, + paddingLeft: theme.spacing(1), + }, + emptyMsg: { + color: theme.palette.text.secondary, + fontStyle: 'italic', + marginTop: theme.spacing(1), + }, +})) + +const ratingColor = (rating) => { + if (rating >= 4) return '#69c76f' + if (rating >= 3) return '#b8c750' + if (rating >= 2) return '#d4a843' + return '#c75050' +} + +const ALL_RATINGS = [5, 4, 3, 2, 1] + +const RatingTable = ({ stats, label, userId, userName, type }) => { + const classes = useStyles() + const history = useHistory() + const translate = useTranslate() + + const countMap = {} + let total = 0 + ;(stats || []).forEach(({ rating, count }) => { + countMap[rating] = count + total += count + }) + + const maxCount = Math.max(...Object.values(countMap), 1) + + return ( + <div> + <Typography variant="subtitle1"> + {label}:{' '} + <span className={classes.totalLabel}>{total}</span> + </Typography> + {total === 0 ? ( + <Typography className={classes.emptyMsg}>{translate('userRatings.noRatingsYet')}</Typography> + ) : ( + <table className={classes.table}> + <tbody> + {ALL_RATINGS.map((r) => { + const count = countMap[r] || 0 + const width = count > 0 ? Math.max((count / maxCount) * 100, 2) : 2 + const linkTo = count > 0 + ? `/userRatings/${encodeURIComponent(userId)}/${encodeURIComponent(userName)}/${type}/${r}` + : null + return ( + <tr + key={r} + className={classes.row} + style={{ cursor: count > 0 ? 'pointer' : 'default' }} + onClick={() => count > 0 && history.push(linkTo)} + tabIndex={count > 0 ? 0 : undefined} + role={count > 0 ? 'button' : undefined} + onKeyDown={count > 0 ? (e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault() + history.push(linkTo) + } + } : undefined} + > + <td className={classes.ratingCell}>{r}.0</td> + <td className={classes.countCell}>{count}</td> + <td className={classes.barCell}> + <div className={classes.barOuter}> + <div + className={classes.barInner} + style={{ + width: `${width}%`, + backgroundColor: count > 0 ? ratingColor(r) : 'transparent', + opacity: count > 0 ? 1 : 0.2, + }} + /> + </div> + </td> + <td className={classes.starsCell}> + <Rating + value={r} + readOnly + size="small" + icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />} + emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />} + /> + </td> + </tr> + ) + })} + </tbody> + </table> + )} + </div> + ) +} + +const UserRatingCard = ({ user }) => { + const classes = useStyles() + const translate = useTranslate() + const [tab, setTab] = useState(0) + + return ( + <Card className={classes.userCard}> + <CardContent> + <Typography variant="h6" className={classes.userName}> + <PeopleIcon fontSize="small" style={{ verticalAlign: 'middle', marginRight: 6 }} /> + {user.userName} + </Typography> + <Tabs + value={tab} + onChange={(_, v) => setTab(v)} + indicatorColor="primary" + textColor="primary" + variant="scrollable" + > + <Tab label={translate('resources.song.name', { smart_count: 2 })} /> + <Tab label={translate('resources.album.name', { smart_count: 2 })} /> + </Tabs> + <Divider /> + <div style={{ marginTop: 12 }}> + {tab === 0 && ( + <RatingTable + stats={user.songStats} + label={translate('resources.song.name', { smart_count: 2 })} + userId={user.userId} + userName={user.userName} + type="song" + /> + )} + {tab === 1 && ( + <RatingTable + stats={user.albumStats} + label={translate('resources.album.name', { smart_count: 2 })} + userId={user.userId} + userName={user.userName} + type="album" + /> + )} + </div> + </CardContent> + </Card> + ) +} + +const UserRatings = () => { + const classes = useStyles() + const translate = useTranslate() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + httpClient(`${REST_URL}/ratingStats`) + .then(({ json }) => { + setData(Array.isArray(json) ? json : []) + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)) + }, []) + + return ( + <div className={classes.root}> + <Title title={translate('menu.userRatings')} /> + <Typography variant="h5" gutterBottom> + {translate('menu.userRatings')} + </Typography> + {loading && <CircularProgress />} + {error && <Typography color="error">{error}</Typography>} + {data && data.length === 0 && ( + <Typography color="textSecondary">{translate('userRatings.noRatingsYet')}</Typography> + )} + {data && + data.map((user) => <UserRatingCard key={user.userId} user={user} />)} + </div> + ) +} + +export default UserRatings