diff --git a/conf/configuration.go b/conf/configuration.go index 08f12fc94..204c72b70 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -81,6 +81,7 @@ type configOptions struct { EnableGravatar bool EnableFavourites bool EnableStarRating bool + SkipLowRatingInShuffle bool EnableUserEditing bool EnableArtworkUpload bool MaxImageUploadSize string @@ -780,6 +781,7 @@ func setViperDefaults() { viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) + viper.SetDefault("skiplowratinginshuffle", false) viper.SetDefault("enableuserediting", true) viper.SetDefault("defaulttheme", "Dark") viper.SetDefault("defaultlanguage", "") diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 559378262..ac6043e48 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -100,6 +100,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), + "not_disliked": notDislikedFilter, "genre_id": tagIDFilter, "missing": booleanFilter, "artists_id": artistFilter, diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 07bd96975..379090be9 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -44,6 +44,17 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec return query } +func notDislikedFilter(_ string, value any) Sqlizer { + v, ok := value.(string) + if !ok { + return nil + } + if strings.ToLower(v) == "true" { + return NotEq{"COALESCE(annotation.rating, 0)": 1} + } + return nil +} + func annotationBoolFilter(field string) func(string, any) Sqlizer { return func(_ string, value any) Sqlizer { v, ok := value.(string) diff --git a/server/serve_index.go b/server/serve_index.go index 13fa4a9ce..8fdd15151 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -51,6 +51,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "enableDownloads": conf.Server.EnableDownloads, "enableFavourites": conf.Server.EnableFavourites, "enableStarRating": conf.Server.EnableStarRating, + "skipLowRatingInShuffle": conf.Server.SkipLowRatingInShuffle, "defaultTheme": conf.Server.DefaultTheme, "defaultLanguage": conf.Server.DefaultLanguage, "defaultUIVolume": conf.Server.DefaultUIVolume, diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 0d82c8be9..83d3d1547 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -6,6 +6,8 @@ import ( "strconv" "time" + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -242,6 +244,13 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) } opts := filter.SongsByRandom(genre, fromYear, toYear) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + if conf.Server.SkipLowRatingInShuffle { + if opts.Filters != nil { + opts.Filters = squirrel.And{opts.Filters, filter.NotDisliked()} + } else { + opts.Filters = filter.NotDisliked() + } + } songs, err := api.getSongs(r.Context(), 0, size, opts) if err != nil { diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 817238aaf..6ec2e12eb 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -358,6 +358,16 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) return nil, err } + if conf.Server.SkipLowRatingInShuffle { + filtered := songs[:0] + for _, s := range songs { + if s.Rating != 1 { + filtered = append(filtered, s) + } + } + songs = filtered + } + response := newResponse() response.SimilarSongs = &responses.SimilarSongs{ Song: slice.MapWithArg(songs, ctx, childFromMediaFile), diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 856870a6c..0e822b579 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -108,6 +108,10 @@ func SongsByRandom(genre string, fromYear, toYear int) Options { return addDefaultFilters(options) } +func NotDisliked() Sqlizer { + return NotEq{"COALESCE(annotation.rating, 0)": 1} +} + func SongsByArtistTitleWithLyricsFirst(artist, title string) Options { return addDefaultFilters(Options{ Sort: "lyrics, updated_at", diff --git a/ui/src/actions/player.js b/ui/src/actions/player.js index 9056abeb6..7bae9074e 100644 --- a/ui/src/actions/player.js +++ b/ui/src/actions/player.js @@ -9,6 +9,7 @@ export const PLAYER_SET_VOLUME = 'PLAYER_SET_VOLUME' export const PLAYER_SET_MODE = 'PLAYER_SET_MODE' export const TRANSCODING_SET_PROFILE = 'TRANSCODING_SET_PROFILE' export const PLAYER_REFRESH_QUEUE = 'PLAYER_REFRESH_QUEUE' +export const PLAYER_REMOVE_FROM_QUEUE = 'PLAYER_REMOVE_FROM_QUEUE' export const setTrack = (data) => ({ type: PLAYER_SET_TRACK, @@ -114,3 +115,8 @@ export const refreshQueue = (resolvedUrls) => ({ type: PLAYER_REFRESH_QUEUE, data: resolvedUrls, }) + +export const removeFromQueue = (trackId) => ({ + type: PLAYER_REMOVE_FROM_QUEUE, + data: trackId, +}) diff --git a/ui/src/common/ShuffleAllButton.jsx b/ui/src/common/ShuffleAllButton.jsx index 1631e2cf5..dcd5ea18e 100644 --- a/ui/src/common/ShuffleAllButton.jsx +++ b/ui/src/common/ShuffleAllButton.jsx @@ -4,6 +4,7 @@ import { useDispatch } from 'react-redux' import ShuffleIcon from '@material-ui/icons/Shuffle' import { playTracks } from '../actions' import PropTypes from 'prop-types' +import config from '../config' export const ShuffleAllButton = ({ filters }) => { const translate = useTranslate() @@ -11,6 +12,9 @@ export const ShuffleAllButton = ({ filters }) => { const dispatch = useDispatch() const notify = useNotify() filters = { ...filters, missing: false } + if (config.skipLowRatingInShuffle) { + filters = { ...filters, not_disliked: true } + } const handleOnClick = () => { dataProvider diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx index 2eb5d9eca..2bd5f9efb 100644 --- a/ui/src/common/useRating.jsx +++ b/ui/src/common/useRating.jsx @@ -1,11 +1,17 @@ import { useState, useCallback, useEffect, useRef } from 'react' import { useDataProvider, useNotify } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' import subsonic from '../subsonic' +import { removeFromQueue, addTracks } from '../actions' +import config from '../config' export const useRating = (resource, record) => { const [loading, setLoading] = useState(false) const notify = useNotify() const dataProvider = useDataProvider() + const dispatch = useDispatch() + const queue = useSelector((state) => state.player?.queue) + const current = useSelector((state) => state.player?.current) const mountedRef = useRef(false) const rating = record.rating @@ -56,9 +62,20 @@ export const useRating = (resource, record) => { const rate = (val, id) => { setLoading(true) + const trackId = record.mediaFileId || record.id subsonic .setRating(id, val) - .then(refreshRating) + .then(() => { + if (config.skipLowRatingInShuffle && queue?.length > 0 && !record.playlistId) { + const inQueue = queue.some((item) => item.trackId === trackId) + if (val === 1 && inQueue) { + dispatch(removeFromQueue(trackId)) + } else if (rating === 1 && val !== 1 && !inQueue && queue.length < 500) { + dispatch(addTracks({ [trackId]: record })) + } + } + refreshRating() + }) .catch((e) => { // eslint-disable-next-line no-console console.log('Error setting star rating: ', e) diff --git a/ui/src/config.js b/ui/src/config.js index 39f0cd467..41e58b9c1 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -17,6 +17,7 @@ const defaultConfig = { gaTrackingId: '', devActivityPanel: true, enableStarRating: true, + skipLowRatingInShuffle: false, defaultTheme: 'Dark', defaultLanguage: '', defaultUIVolume: 100, diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index d6ab7484b..663a6cff9 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -12,6 +12,7 @@ import { PLAYER_SYNC_QUEUE, PLAYER_SET_MODE, PLAYER_REFRESH_QUEUE, + PLAYER_REMOVE_FROM_QUEUE, } from '../actions' import config from '../config' @@ -245,6 +246,12 @@ export const playerReducer = (previousState = initialState, payload) => { previousState.savedPlayIndex >= 0 ? previousState.savedPlayIndex : 0, } } + case PLAYER_REMOVE_FROM_QUEUE: + return { + ...previousState, + queue: previousState.queue.filter((item) => item.trackId !== payload.data), + clear: true, + } default: return previousState }