feat: skip low-rated songs in shuffle playback

Add server config option SkipLowRatingInShuffle (env: ND_SKIPLOWRATINGINSHUFFLE,
default: false). When enabled, songs rated 1 star are excluded from shuffle/random
playback while remaining accessible for manual play.

- Shuffle All (Web UI): filters 1-star songs before building play queue
- Subsonic API getRandomSongs: excludes 1-star songs via SQL filter
- Subsonic API getSimilarSongs (Instant Mix): filters 1-star songs from results
- Live queue: rating a song 1 star removes it from the current queue (skips if
  playing); removing the 1-star rating re-adds it to the end of the queue
- Queue manipulation only applies to non-playlist queues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Finomosec 2026-06-01 14:38:42 +02:00
parent 2a43c4683e
commit 6082dcd250
12 changed files with 81 additions and 1 deletions

View File

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

View File

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

View File

@ -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(rating, 0)": 1}
}
return nil
}
func annotationBoolFilter(field string) func(string, any) Sqlizer {
return func(_ string, value any) Sqlizer {
v, ok := value.(string)

View File

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

View File

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

View File

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

View File

@ -108,6 +108,10 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options)
}
func NotDisliked() Sqlizer {
return NotEq{"COALESCE(rating, 0)": 1}
}
func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
return addDefaultFilters(Options{
Sort: "lyrics, updated_at",

View File

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

View File

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

View File

@ -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,26 @@ 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))
if (current?.trackId === trackId) {
const audio = document.querySelector('audio')
if (audio) {
audio.dispatchEvent(new Event('ended'))
}
}
} else if (val !== 1 && !inQueue) {
dispatch(addTracks({ [trackId]: record }))
}
}
refreshRating()
})
.catch((e) => {
// eslint-disable-next-line no-console
console.log('Error setting star rating: ', e)

View File

@ -17,6 +17,7 @@ const defaultConfig = {
gaTrackingId: '',
devActivityPanel: true,
enableStarRating: true,
skipLowRatingInShuffle: false,
defaultTheme: 'Dark',
defaultLanguage: '',
defaultUIVolume: 100,

View File

@ -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,13 @@ 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,
autoPlay: false,
}
default:
return previousState
}