Merge 0d8add78a50fea9faa5504365bb104e66608704b into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e

This commit is contained in:
plut 2026-07-31 15:55:21 -04:00 committed by GitHub
commit 1672703452
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 892 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,76 @@
package nativeapi
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
func (api *Router) addRatingStatsRoute(r chi.Router) {
r.Get("/ratingStats", getRatingStats(api.ds))
r.Get("/ratingItems", getRatingItems(api.ds))
}
func getRatingStats(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentUser, _ := request.UserFrom(r.Context())
filterUserID := ""
if !currentUser.IsAdmin {
filterUserID = currentUser.ID
}
stats, err := ds.User(r.Context()).RatingStats(r.Context(), filterUserID)
if err != nil {
log.Error(r.Context(), "Error getting rating stats", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats); err != nil {
log.Error(r.Context(), "Error encoding rating stats", err)
}
}
}
func getRatingItems(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentUser, _ := request.UserFrom(r.Context())
userID := r.URL.Query().Get("userId")
itemType := r.URL.Query().Get("type")
ratingStr := r.URL.Query().Get("rating")
rating, err := strconv.Atoi(ratingStr)
if err != nil || rating < 1 || rating > 5 {
http.Error(w, "rating must be between 1 and 5", http.StatusBadRequest)
return
}
if userID == "" {
http.Error(w, "userId is required", http.StatusBadRequest)
return
}
if itemType != "album" && itemType != "song" {
http.Error(w, "type must be 'album' or 'song'", http.StatusBadRequest)
return
}
if !currentUser.IsAdmin && currentUser.ID != userID {
http.Error(w, "non-admin users can only query their own ratings", http.StatusForbidden)
return
}
items, err := ds.User(r.Context()).RatingItems(r.Context(), userID, itemType, rating)
if err != nil {
log.Error(r.Context(), "Error getting rating items", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(items); err != nil {
log.Error(r.Context(), "Error encoding rating items", err)
}
}
}

View File

@ -0,0 +1,189 @@
package nativeapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Rating Stats API", func() {
var ds *tests.MockDataStore
var router http.Handler
var adminUser, regularUser model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.TODO()).Put(&regularUser)).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(&regularUser)
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(&regularUser)
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))
})
})
})
})

View File

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

View File

@ -23,6 +23,7 @@ import 'react-image-lightbox/style.css'
import subsonic from '../subsonic'
import {
ArtistLinkField,
AverageRatingField,
CollapsibleComment,
DurationField,
formatRange,
@ -309,6 +310,10 @@ const AlbumDetails = (props) => {
resource={'album'}
size={isDesktop ? 'medium' : 'small'}
/>
<AverageRatingField
record={record}
size={isDesktop ? 'medium' : 'small'}
/>
</div>
)}
{isDesktop ? (

View File

@ -16,6 +16,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { playTracks } from '../actions'
import {
ArtistLinkField,
AverageRatingField,
DateField,
DurationField,
QualityInfo,
@ -143,6 +144,13 @@ const AlbumSongs = (props) => {
className={classes.ratingField}
/>
),
averageRating: isDesktop && config.enableStarRating && (
<AverageRatingField
source="averageRating"
label="resources.song.fields.averageRating"
sortable={false}
/>
),
}
}, [isDesktop, classes.ratingField])
@ -151,6 +159,7 @@ const AlbumSongs = (props) => {
columns: toggleableFields,
omittedColumns: ['title'],
defaultOff: [
'averageRating',
'composer',
'channels',
'bpm',

View File

@ -14,6 +14,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useDrag } from 'react-dnd'
import {
ArtistLinkField,
AverageRatingField,
CoverArtAvatar,
DurationField,
RangeField,
@ -126,6 +127,9 @@ const AlbumTableView = ({
className={classes.ratingField}
/>
),
averageRating: config.enableStarRating && (
<AverageRatingField source={'averageRating'} sortByOrder={'DESC'} />
),
createdAt: isDesktop && <DateField source="createdAt" showTime />,
}
}, [classes.ratingField, isDesktop])
@ -133,7 +137,7 @@ const AlbumTableView = ({
const columns = useSelectedFields({
resource: 'album',
columns: toggleableFields,
defaultOff: ['createdAt', 'size', 'mood'],
defaultOff: ['averageRating', 'createdAt', 'size', 'mood'],
})
return isXsmall ? (

View File

@ -22,6 +22,7 @@ import { useDrag } from 'react-dnd'
import clsx from 'clsx'
import {
ArtistContextMenu,
AverageRatingField,
CoverArtAvatar,
List,
useGetHandleArtistClick,
@ -157,6 +158,9 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
className={classes.ratingField}
/>
),
averageRating: config.enableStarRating && (
<AverageRatingField source="averageRating" sortByOrder={'DESC'} />
),
}),
[classes.ratingField],
)
@ -164,6 +168,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
const columns = useSelectedFields({
resource: 'artist',
columns: toggleableFields,
defaultOff: ['averageRating'],
})
return isXsmall ? (

View File

@ -0,0 +1,44 @@
import React from 'react'
import PropTypes from 'prop-types'
import Rating from '@material-ui/lab/Rating'
import { makeStyles } from '@material-ui/core/styles'
import StarIcon from '@material-ui/icons/Star'
import { useTranslate } from 'react-admin'
import clsx from 'clsx'
const useStyles = makeStyles({
rating: {
color: '#ffb400',
opacity: 0.6,
},
})
export const AverageRatingField = ({ className, size, record = {}, ...rest }) => {
const classes = useStyles()
const translate = useTranslate()
const avg = Number(record.averageRating) || 0
if (avg <= 0) return null
return (
<span title={translate('userRatings.avgRating', { avg })}>
<Rating
className={clsx(className, classes.rating)}
value={avg}
precision={0.5}
size={size}
readOnly
icon={<StarIcon fontSize="inherit" />}
/>
</span>
)
}
AverageRatingField.propTypes = {
record: PropTypes.object,
size: PropTypes.string,
}
AverageRatingField.defaultProps = {
size: 'small',
}

View File

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

View File

@ -30,6 +30,7 @@
"starred": "Favourite",
"comment": "Comment",
"rating": "Rating",
"averageRating": "Avg. Rating",
"quality": "Quality",
"bpm": "BPM",
"playDate": "Last Played",
@ -76,6 +77,7 @@
"updatedAt": "Updated at",
"comment": "Comment",
"rating": "Rating",
"averageRating": "Avg. Rating",
"createdAt": "Date added",
"recordLabel": "Label",
"catalogNum": "Catalog Number",
@ -114,6 +116,7 @@
"size": "Size",
"playCount": "Plays",
"rating": "Rating",
"averageRating": "Avg. Rating",
"genre": "Genre",
"role": "Role",
"missing": "Missing"
@ -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}"
}
}

View File

@ -5,6 +5,7 @@ import clsx from 'clsx'
import { useTranslate, MenuItemLink, getResources } from 'react-admin'
import ViewListIcon from '@material-ui/icons/ViewList'
import AlbumIcon from '@material-ui/icons/Album'
import PeopleIcon from '@material-ui/icons/People'
import SubMenu from './SubMenu'
import { humanize, pluralize } from 'inflection'
import albumLists from '../album/albumLists'
@ -126,6 +127,16 @@ const Menu = ({ dense = false }) => {
)}
</SubMenu>
{resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)}
{config.enableStarRating && (
<MenuItemLink
to="/userRatings"
activeClassName={classes.active}
primaryText={translate('menu.userRatings')}
leftIcon={<PeopleIcon />}
sidebarIsOpen={open}
dense={dense}
/>
)}
{config.devSidebarPlaylists && open ? (
<>
<Divider />

View File

@ -1,9 +1,18 @@
import React from 'react'
import { Route } from 'react-router-dom'
import Personal from './personal/Personal'
import UserRatings from './userRatings/UserRatings'
import UserRatingItems from './userRatings/UserRatingItems'
const routes = [
<Route exact path="/personal" render={() => <Personal />} key={'personal'} />,
<Route exact path="/userRatings" render={() => <UserRatings />} key={'userRatings'} />,
<Route
exact
path="/userRatings/:userId/:userName/:type/:rating"
component={UserRatingItems}
key={'userRatingItems'}
/>,
]
export default routes

View File

@ -22,6 +22,7 @@ import {
SongInfo,
SongTitleField,
SongSimpleList,
AverageRatingField,
RatingField,
useResourceRefresh,
ArtistLinkField,
@ -174,6 +175,9 @@ const SongList = (props) => {
className={classes.ratingField}
/>
),
averageRating: config.enableStarRating && (
<AverageRatingField source="averageRating" sortByOrder={'DESC'} />
),
bpm: isDesktop && <NumberField source="bpm" />,
genre: <TextField source="genre" />,
mood: isDesktop && (
@ -200,6 +204,7 @@ const SongList = (props) => {
'bpm',
'playDate',
'albumArtist',
'averageRating',
'genre',
'mood',
'comment',

View File

@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react'
import {
Avatar,
Card,
CardContent,
CircularProgress,
List,
ListItem,
ListItemAvatar,
ListItemText,
Typography,
makeStyles,
} from '@material-ui/core'
import Rating from '@material-ui/lab/Rating'
import StarIcon from '@material-ui/icons/Star'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import ArrowBackIcon from '@material-ui/icons/ArrowBack'
import { Link } from 'react-router-dom'
import { Title, useTranslate } from 'react-admin'
import httpClient from '../dataProvider/httpClient'
import { REST_URL } from '../consts'
import subsonic from '../subsonic'
const useStyles = makeStyles((theme) => ({
root: { padding: theme.spacing(2) },
back: {
display: 'flex',
alignItems: 'center',
color: theme.palette.primary.main,
textDecoration: 'none',
marginBottom: theme.spacing(2),
'&:hover': { textDecoration: 'underline' },
},
backIcon: { marginRight: theme.spacing(0.5), fontSize: 18 },
header: { marginBottom: theme.spacing(2) },
avatar: { width: 48, height: 48, borderRadius: 4 },
listItem: { paddingLeft: 0, paddingRight: 0 },
}))
const UserRatingItems = ({ match }) => {
const { userId, userName, type, rating } = match.params
const classes = useStyles()
const translate = useTranslate()
const [items, setItems] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
httpClient(
`${REST_URL}/ratingItems?userId=${encodeURIComponent(userId)}&type=${type}&rating=${rating}`,
)
.then(({ json }) => setItems(Array.isArray(json) ? json : []))
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [userId, type, rating])
const typeLabel =
type === 'album'
? translate('resources.album.name', { smart_count: 2 })
: translate('resources.song.name', { smart_count: 2 })
const title = `${userName} · ${typeLabel} · ${rating}`
const getCoverUrl = (item) => {
return subsonic.getCoverArtUrl(
{ id: item.id, albumArtist: type === 'album' ? item.artist : undefined, album: type === 'song' ? item.name : undefined, updatedAt: item.updatedAt },
40,
)
}
return (
<div className={classes.root}>
<Title title={title} />
<Link to="/userRatings" className={classes.back}>
<ArrowBackIcon className={classes.backIcon} />
{translate('menu.userRatings')}
</Link>
<div className={classes.header}>
<Typography variant="h5" gutterBottom>
{userName}
</Typography>
<Typography variant="subtitle1" color="textSecondary">
{typeLabel} ·{' '}
<Rating
value={parseInt(rating, 10) || 0}
readOnly
size="small"
style={{ verticalAlign: 'middle' }}
icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />}
emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />}
/>
</Typography>
</div>
<Card>
<CardContent>
{loading && <CircularProgress />}
{error && <Typography color="error">{error}</Typography>}
{items && items.length === 0 && (
<Typography color="textSecondary">{translate('userRatings.noItemsFound')}</Typography>
)}
{items && items.length > 0 && (
<List disablePadding>
{items.map((item) => {
const albumId = type === 'album' ? item.id : item.albumId
return (
<ListItem
key={item.id}
className={classes.listItem}
divider
button
component={Link}
to={`/album/${albumId}/show`}
>
<ListItemAvatar>
<Avatar
src={getCoverUrl(item)}
variant="square"
className={classes.avatar}
/>
</ListItemAvatar>
<ListItemText
primary={item.name}
secondary={item.artist}
/>
</ListItem>
)
})}
</List>
)}
</CardContent>
</Card>
</div>
)
}
export default UserRatingItems

View File

@ -0,0 +1,255 @@
import React, { useEffect, useState } from 'react'
import {
Card,
CardContent,
CircularProgress,
Divider,
Tab,
Tabs,
Typography,
makeStyles,
} from '@material-ui/core'
import Rating from '@material-ui/lab/Rating'
import StarIcon from '@material-ui/icons/Star'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import PeopleIcon from '@material-ui/icons/People'
import { useHistory } from 'react-router-dom'
import { Title, useTranslate } from 'react-admin'
import httpClient from '../dataProvider/httpClient'
import { REST_URL } from '../consts'
const useStyles = makeStyles((theme) => ({
root: {
padding: theme.spacing(2),
},
userCard: {
marginBottom: theme.spacing(3),
},
userName: {
marginBottom: theme.spacing(1),
},
totalLabel: {
color: theme.palette.primary.main,
fontWeight: 'bold',
},
table: {
width: '100%',
borderCollapse: 'collapse',
},
row: {
'&:nth-child(even)': {
backgroundColor: theme.palette.action.hover,
},
},
ratingCell: {
width: 60,
textAlign: 'right',
paddingRight: theme.spacing(1),
color: theme.palette.primary.main,
fontWeight: 'bold',
whiteSpace: 'nowrap',
},
countCell: {
width: 50,
textAlign: 'right',
paddingRight: theme.spacing(1),
fontWeight: 'bold',
whiteSpace: 'nowrap',
},
barCell: {
padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`,
},
barOuter: {
height: 20,
borderRadius: 3,
overflow: 'hidden',
backgroundColor: theme.palette.action.selected,
minWidth: 4,
},
barInner: {
height: '100%',
borderRadius: 3,
minWidth: 4,
transition: 'width 0.4s ease',
},
starsCell: {
width: 110,
paddingLeft: theme.spacing(1),
},
emptyMsg: {
color: theme.palette.text.secondary,
fontStyle: 'italic',
marginTop: theme.spacing(1),
},
}))
const ratingColor = (rating) => {
if (rating >= 4) return '#69c76f'
if (rating >= 3) return '#b8c750'
if (rating >= 2) return '#d4a843'
return '#c75050'
}
const ALL_RATINGS = [5, 4, 3, 2, 1]
const RatingTable = ({ stats, label, userId, userName, type }) => {
const classes = useStyles()
const history = useHistory()
const translate = useTranslate()
const countMap = {}
let total = 0
;(stats || []).forEach(({ rating, count }) => {
countMap[rating] = count
total += count
})
const maxCount = Math.max(...Object.values(countMap), 1)
return (
<div>
<Typography variant="subtitle1">
{label}:{' '}
<span className={classes.totalLabel}>{total}</span>
</Typography>
{total === 0 ? (
<Typography className={classes.emptyMsg}>{translate('userRatings.noRatingsYet')}</Typography>
) : (
<table className={classes.table}>
<tbody>
{ALL_RATINGS.map((r) => {
const count = countMap[r] || 0
const width = count > 0 ? Math.max((count / maxCount) * 100, 2) : 2
const linkTo = count > 0
? `/userRatings/${encodeURIComponent(userId)}/${encodeURIComponent(userName)}/${type}/${r}`
: null
return (
<tr
key={r}
className={classes.row}
style={{ cursor: count > 0 ? 'pointer' : 'default' }}
onClick={() => count > 0 && history.push(linkTo)}
tabIndex={count > 0 ? 0 : undefined}
role={count > 0 ? 'button' : undefined}
onKeyDown={count > 0 ? (e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault()
history.push(linkTo)
}
} : undefined}
>
<td className={classes.ratingCell}>{r}.0</td>
<td className={classes.countCell}>{count}</td>
<td className={classes.barCell}>
<div className={classes.barOuter}>
<div
className={classes.barInner}
style={{
width: `${width}%`,
backgroundColor: count > 0 ? ratingColor(r) : 'transparent',
opacity: count > 0 ? 1 : 0.2,
}}
/>
</div>
</td>
<td className={classes.starsCell}>
<Rating
value={r}
readOnly
size="small"
icon={<StarIcon fontSize="inherit" style={{ color: '#ffb400' }} />}
emptyIcon={<StarBorderIcon fontSize="inherit" style={{ color: '#ffb400', opacity: 0.4 }} />}
/>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)
}
const UserRatingCard = ({ user }) => {
const classes = useStyles()
const translate = useTranslate()
const [tab, setTab] = useState(0)
return (
<Card className={classes.userCard}>
<CardContent>
<Typography variant="h6" className={classes.userName}>
<PeopleIcon fontSize="small" style={{ verticalAlign: 'middle', marginRight: 6 }} />
{user.userName}
</Typography>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
>
<Tab label={translate('resources.song.name', { smart_count: 2 })} />
<Tab label={translate('resources.album.name', { smart_count: 2 })} />
</Tabs>
<Divider />
<div style={{ marginTop: 12 }}>
{tab === 0 && (
<RatingTable
stats={user.songStats}
label={translate('resources.song.name', { smart_count: 2 })}
userId={user.userId}
userName={user.userName}
type="song"
/>
)}
{tab === 1 && (
<RatingTable
stats={user.albumStats}
label={translate('resources.album.name', { smart_count: 2 })}
userId={user.userId}
userName={user.userName}
type="album"
/>
)}
</div>
</CardContent>
</Card>
)
}
const UserRatings = () => {
const classes = useStyles()
const translate = useTranslate()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
httpClient(`${REST_URL}/ratingStats`)
.then(({ json }) => {
setData(Array.isArray(json) ? json : [])
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false))
}, [])
return (
<div className={classes.root}>
<Title title={translate('menu.userRatings')} />
<Typography variant="h5" gutterBottom>
{translate('menu.userRatings')}
</Typography>
{loading && <CircularProgress />}
{error && <Typography color="error">{error}</Typography>}
{data && data.length === 0 && (
<Typography color="textSecondary">{translate('userRatings.noRatingsYet')}</Typography>
)}
{data &&
data.map((user) => <UserRatingCard key={user.userId} user={user} />)}
</div>
)
}
export default UserRatings